@filefreak.io/sdk 0.4.0 → 0.6.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 +30 -3
- package/dist/client.js +2 -1
- package/dist/cut.d.ts +96 -0
- package/dist/cut.js +220 -0
- package/dist/generated.d.ts +75 -9
- package/dist/generated.js +13 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/links.js +26 -3
- package/dist/reader.d.ts +11 -0
- package/dist/reader.js +69 -27
- package/dist/shards.d.ts +31 -0
- package/dist/shards.js +64 -0
- package/dist/uploads.d.ts +6 -3
- package/dist/uploads.js +250 -72
- package/package.json +1 -1
package/dist/uploads.js
CHANGED
|
@@ -16,7 +16,17 @@
|
|
|
16
16
|
// answers 202 until it has, and is asked again when it says.
|
|
17
17
|
//
|
|
18
18
|
// A part is read into memory whole to be hashed and sent, so the memory an
|
|
19
|
-
// upload needs is about
|
|
19
|
+
// upload needs is about the size of a part times the parts sent at once.
|
|
20
|
+
//
|
|
21
|
+
// CUT BY CONTENT, as the CLI cuts a file (cut.ts). A file of 16 MiB or more
|
|
22
|
+
// is read through once before it is begun, cut where its content says, and
|
|
23
|
+
// begun with its blocks, each its size and SHA-256. Each part is then a
|
|
24
|
+
// block, and a block the file it replaces has already, or one an earlier
|
|
25
|
+
// part repeats, is not sent: the API lists those in reused. So a new version
|
|
26
|
+
// of a large file sends only the blocks that changed. Each block is hashed
|
|
27
|
+
// again as it is sent and refused if it is not the block that was declared,
|
|
28
|
+
// since the file has changed. A block is 16 MiB at most, or 64 MiB in a file
|
|
29
|
+
// past 32 GiB.
|
|
20
30
|
//
|
|
21
31
|
// SEALED AS IT IS SENT (plan 04, F6 step 4, answer 46). Every begin says the
|
|
22
32
|
// SDK can seal (protectable), as the website and the CLI do. Into a place
|
|
@@ -30,9 +40,21 @@
|
|
|
30
40
|
// Carrying on, every fragment not recorded is confirmed before it is sent
|
|
31
41
|
// again, since storage may hold it already. A fragment is up to 64 MiB and
|
|
32
42
|
// sealed in memory, so the memory a sealed upload needs is the same.
|
|
43
|
+
//
|
|
44
|
+
// Every begin also says the SDK writes version 2 of the shard format, in
|
|
45
|
+
// which a protected file of more than 16 MiB is answered. Its fragments are
|
|
46
|
+
// blocks: the file is cut again with a table made from its own key, each
|
|
47
|
+
// block named by its id under the key (cutSealed), and the blocks declared
|
|
48
|
+
// (uploads.blocks). Those the file's last version has under the same key are
|
|
49
|
+
// kept and never sent, so a new version of a large protected file sends only
|
|
50
|
+
// what changed, sealed here. Each block is checked against the id declared
|
|
51
|
+
// for its place as it is sealed, and completing sends the outline's MAC. A
|
|
52
|
+
// file not cut into two to ten thousand blocks, which version 2 does not
|
|
53
|
+
// take, is begun again in version 1.
|
|
54
|
+
import { cutPlain, cutSealed, hex, RULE } from "./cut.js";
|
|
33
55
|
import { canDrawFrames, drawFrame, sendFrame, takesFrame } from "./frames.js";
|
|
34
56
|
import { base64, Md5 } from "./md5.js";
|
|
35
|
-
import { sealFragment, ShardError, shardKey, ShardLayout } from "./shards.js";
|
|
57
|
+
import { BlockLayout, fileKeys, outlineMac, sealBlock, sealFragment, ShardError, shardKey, ShardLayout } from "./shards.js";
|
|
36
58
|
import { FileFreakError, sleep } from "./transport.js";
|
|
37
59
|
const MIB = 1024 * 1024;
|
|
38
60
|
/** The API's bounds on a part. */
|
|
@@ -54,6 +76,8 @@ const CONFIRM_MOST = 100;
|
|
|
54
76
|
const CONFIRM_PATIENCE_MS = 5 * 60_000;
|
|
55
77
|
/** How many times fragments storage did not keep are sealed again and sent before the upload stops. */
|
|
56
78
|
const SEALED_ROUNDS = 4;
|
|
79
|
+
/** The versions of the shard format this writes. */
|
|
80
|
+
const WRITES_FORMATS = [1, 2];
|
|
57
81
|
/** An upload that stopped part way. What arrived stays in storage until the upload expires: carry on with `resume: uploadId`, or cancel it. */
|
|
58
82
|
export class UploadStopped extends Error {
|
|
59
83
|
uploadId;
|
|
@@ -64,33 +88,106 @@ export class UploadStopped extends Error {
|
|
|
64
88
|
this.name = "UploadStopped";
|
|
65
89
|
}
|
|
66
90
|
}
|
|
67
|
-
/**
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
*/
|
|
72
|
-
|
|
91
|
+
/** A part that is not what was declared for it: the file has changed since it was cut. */
|
|
92
|
+
function changed(part) {
|
|
93
|
+
return new FileFreakError(0, "source_changed", `Part ${part} of the file is not what it was when the upload began: the file has changed since.`);
|
|
94
|
+
}
|
|
95
|
+
/** A sealed upload's key, from what uploads.get says, and what it says of the file. */
|
|
96
|
+
function keyOf(state) {
|
|
73
97
|
const shards = state.shards;
|
|
74
98
|
if (!shards)
|
|
75
99
|
throw new FileFreakError(0, "unreadable", "The API began a sealed upload and then gave no key for it.");
|
|
76
|
-
let layout;
|
|
77
100
|
let raw;
|
|
78
101
|
try {
|
|
79
|
-
layout = new ShardLayout(size, shards.fragmentBytes);
|
|
80
102
|
raw = Uint8Array.from(atob(shards.key), (c) => c.charCodeAt(0));
|
|
81
103
|
}
|
|
82
104
|
catch (e) {
|
|
83
|
-
throw Object.assign(new FileFreakError(0, "unreadable", "The API answered with a sealed upload whose
|
|
105
|
+
throw Object.assign(new FileFreakError(0, "unreadable", "The API answered with a sealed upload whose key could not be read."), { cause: e });
|
|
106
|
+
}
|
|
107
|
+
if (raw.length !== 32)
|
|
108
|
+
throw new FileFreakError(0, "unreadable", `The API answered with a key of ${raw.length} bytes, where a file's key is 32.`);
|
|
109
|
+
return { raw, shards };
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* The key and the cut of an upload sealed in version 1, from what
|
|
113
|
+
* uploads.get says, with every fragment it has not recorded waiting to be
|
|
114
|
+
* confirmed: one sent before, by this process or another, may be in storage
|
|
115
|
+
* already.
|
|
116
|
+
*/
|
|
117
|
+
async function sealingOf(state, size) {
|
|
118
|
+
const { raw, shards } = keyOf(state);
|
|
119
|
+
let layout;
|
|
120
|
+
try {
|
|
121
|
+
layout = new ShardLayout(size, shards.fragmentBytes);
|
|
122
|
+
}
|
|
123
|
+
catch (e) {
|
|
124
|
+
throw Object.assign(new FileFreakError(0, "unreadable", "The API answered with a sealed upload whose cut could not be read."), { cause: e });
|
|
84
125
|
}
|
|
85
|
-
if (layout.count !== shards.count || layout.count !== state.upload.partCount
|
|
126
|
+
if (layout.count !== shards.count || layout.count !== state.upload.partCount) {
|
|
86
127
|
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}.`);
|
|
87
128
|
}
|
|
129
|
+
const key = await shardKey(raw);
|
|
130
|
+
const contentId = shards.contentId;
|
|
88
131
|
const recorded = new Set(state.uploadedParts);
|
|
89
132
|
const waiting = new Set();
|
|
90
133
|
for (let n = 0; n < layout.count; n++)
|
|
91
134
|
if (!recorded.has(n))
|
|
92
135
|
waiting.add(n);
|
|
93
|
-
return {
|
|
136
|
+
return {
|
|
137
|
+
layout,
|
|
138
|
+
seal: async (place, plain) => {
|
|
139
|
+
try {
|
|
140
|
+
return await sealFragment(key, { contentId, layout, place }, plain);
|
|
141
|
+
}
|
|
142
|
+
catch (e) {
|
|
143
|
+
if (e instanceof ShardError)
|
|
144
|
+
throw Object.assign(new FileFreakError(0, "layout", e.message), { cause: e });
|
|
145
|
+
throw e;
|
|
146
|
+
}
|
|
147
|
+
},
|
|
148
|
+
outline: null,
|
|
149
|
+
recorded,
|
|
150
|
+
waiting,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
/** What a file in version 2 is sealed with: each block checked, as it is sealed, against the id declared for its place. */
|
|
154
|
+
async function blockSealing(keys, outline, recorded) {
|
|
155
|
+
let layout;
|
|
156
|
+
try {
|
|
157
|
+
layout = new BlockLayout(outline);
|
|
158
|
+
}
|
|
159
|
+
catch (e) {
|
|
160
|
+
if (e instanceof ShardError)
|
|
161
|
+
throw Object.assign(new FileFreakError(0, "layout", e.message), { cause: e });
|
|
162
|
+
throw e;
|
|
163
|
+
}
|
|
164
|
+
return {
|
|
165
|
+
layout,
|
|
166
|
+
seal: async (place, plain) => {
|
|
167
|
+
const { id, blob } = await sealBlock(keys, plain);
|
|
168
|
+
if (base64(id) !== base64(outline.blocks[place].id))
|
|
169
|
+
throw changed(place);
|
|
170
|
+
return blob;
|
|
171
|
+
},
|
|
172
|
+
outline: base64(await outlineMac(keys, outline)),
|
|
173
|
+
recorded,
|
|
174
|
+
waiting: new Set(),
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Version 2: the file cut with its own key's table, and its blocks declared,
|
|
179
|
+
* with what to seal them with. Null for a file not cut into two to ten
|
|
180
|
+
* thousand blocks, which version 2 does not take.
|
|
181
|
+
*/
|
|
182
|
+
async function declare(calls, state, source, size, rule, signal) {
|
|
183
|
+
const { raw, shards } = keyOf(state);
|
|
184
|
+
const keys = await fileKeys(raw);
|
|
185
|
+
const blocks = await cutSealed(cuttable(source, size), raw, keys, rule, signal);
|
|
186
|
+
if (!blocks)
|
|
187
|
+
return null;
|
|
188
|
+
const declared = await calls.blocks(state.upload.id, { blocks: blocks.map((b) => ({ size: b.length, id: base64(b.id) })) }, { signal });
|
|
189
|
+
const sealing = await blockSealing(keys, { contentId: shards.contentId, size, blocks }, new Set(declared.reused));
|
|
190
|
+
return { upload: declared.upload, parts: declared.parts, sealing };
|
|
94
191
|
}
|
|
95
192
|
/** The part size to ask for: 32 MiB, halved while that would leave fewer parts than are sent at once. */
|
|
96
193
|
export function partSizeFor(fileBytes, atOnce) {
|
|
@@ -109,44 +206,64 @@ async function bytesOf(source, from, to) {
|
|
|
109
206
|
return new Uint8Array(source.slice(from, to));
|
|
110
207
|
return source.slice(from, to);
|
|
111
208
|
}
|
|
112
|
-
|
|
209
|
+
/** Bytes of the file, or why they could not be read. */
|
|
210
|
+
async function read(source, from, to) {
|
|
211
|
+
try {
|
|
212
|
+
return await bytesOf(source, from, to);
|
|
213
|
+
}
|
|
214
|
+
catch (e) {
|
|
215
|
+
// Browsers and Node both refuse to read a file that changed after it was
|
|
216
|
+
// opened, which is what keeps one from going up half old and half new.
|
|
217
|
+
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 });
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
/** The file as the cutter reads it: where it is, when it is in memory, and a window at a time from a Blob. */
|
|
221
|
+
function cuttable(source, size) {
|
|
222
|
+
if (source instanceof Blob)
|
|
223
|
+
return { size, read: (from, to) => read(source, from, to) };
|
|
224
|
+
return source instanceof ArrayBuffer ? new Uint8Array(source) : source;
|
|
225
|
+
}
|
|
226
|
+
/** Parts of one size, the last whatever remains. */
|
|
227
|
+
function evenly(upload, size) {
|
|
228
|
+
return (n) => ({ start: n * upload.partBytes, end: Math.min(size, (n + 1) * upload.partBytes) });
|
|
229
|
+
}
|
|
230
|
+
/** Parts that are blocks of their own sizes. */
|
|
231
|
+
function blocksOf(sizes) {
|
|
232
|
+
const starts = [0];
|
|
233
|
+
for (const size of sizes)
|
|
234
|
+
starts.push(starts[starts.length - 1] + size);
|
|
235
|
+
return (n) => ({ start: starts[n], end: starts[n + 1] });
|
|
236
|
+
}
|
|
237
|
+
/** A job for an upload sealed as it is sent: each part is a fragment, where its layout says. */
|
|
238
|
+
function sealedJob(source, size, upload, sealing, addresses, done) {
|
|
239
|
+
return { source, size, upload, range: (n) => sealing.layout.range(n), hashes: null, addresses: new Map(addresses.map((p) => [p.number, p])), fetching: null, done: new Set(done), sentBytes: 0, sealing };
|
|
240
|
+
}
|
|
241
|
+
export async function upload(calls, fetcher, source, options = {}, rule = RULE) {
|
|
113
242
|
const signal = options.signal;
|
|
114
243
|
const atOnce = Math.max(1, Math.min(16, options.concurrency ?? AT_ONCE));
|
|
115
244
|
const size = sizeOf(source);
|
|
116
|
-
const call = { signal };
|
|
117
245
|
let job;
|
|
118
246
|
if (options.resume) {
|
|
119
|
-
const
|
|
120
|
-
if (
|
|
121
|
-
return
|
|
122
|
-
|
|
123
|
-
throw new FileFreakError(410, `upload_${state.upload.status}`, `That upload is ${state.upload.status}, so it cannot be carried on. Begin it again.`);
|
|
124
|
-
}
|
|
125
|
-
if (state.upload.sizeBytes !== size) {
|
|
126
|
-
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.`);
|
|
127
|
-
}
|
|
128
|
-
// Begun by a program that cut the file by its content, as filefreak does: its
|
|
129
|
-
// parts are blocks of different sizes, and this reads part n at n times
|
|
130
|
-
// partBytes, so every part would be sent from the wrong place and the
|
|
131
|
-
// upload could never complete.
|
|
132
|
-
if (state.blocks) {
|
|
133
|
-
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.");
|
|
134
|
-
}
|
|
135
|
-
const sealing = state.shards ? await sealingOf(state, size) : null;
|
|
136
|
-
job = { source, size, upload: state.upload, addresses: new Map(), fetching: null, done: new Set(state.uploadedParts), sentBytes: 0, sealing };
|
|
247
|
+
const carried = await carryOn(calls, source, size, options.resume, rule, signal);
|
|
248
|
+
if (!("upload" in carried))
|
|
249
|
+
return carried;
|
|
250
|
+
job = carried;
|
|
137
251
|
}
|
|
138
252
|
else {
|
|
139
253
|
const name = options.name ?? (typeof File !== "undefined" && source instanceof File ? source.name : undefined);
|
|
140
254
|
if (!name)
|
|
141
255
|
throw new TypeError("Give the upload a name: { name: \"photo.jpg\" }.");
|
|
142
256
|
const type = options.contentType ?? (source instanceof Blob && source.type ? source.type : undefined);
|
|
257
|
+
// Cut by its content first, as the CLI cuts a file, so that the blocks its next version still has are not sent again.
|
|
258
|
+
const blocks = await cutPlain(cuttable(source, size), rule, signal);
|
|
143
259
|
const body = {
|
|
144
260
|
name,
|
|
145
261
|
sizeBytes: size,
|
|
146
262
|
parentId: options.parentId ?? null,
|
|
147
|
-
partBytes: Math.max(LEAST_PART, Math.min(MOST_PART, options.partBytes ?? partSizeFor(size, atOnce))),
|
|
263
|
+
...(blocks ? { blocks } : { partBytes: Math.max(LEAST_PART, Math.min(MOST_PART, options.partBytes ?? partSizeFor(size, atOnce))) }),
|
|
148
264
|
// Into a protected place, the parts are fragments this sends sealed.
|
|
149
265
|
protectable: true,
|
|
266
|
+
shardFormats: WRITES_FORMATS,
|
|
150
267
|
};
|
|
151
268
|
if (options.onConflict)
|
|
152
269
|
body.onConflict = options.onConflict;
|
|
@@ -156,19 +273,7 @@ export async function upload(calls, fetcher, source, options = {}) {
|
|
|
156
273
|
body.contentType = type;
|
|
157
274
|
if (options.imageSizes)
|
|
158
275
|
body.imageSizes = options.imageSizes;
|
|
159
|
-
|
|
160
|
-
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 };
|
|
161
|
-
if (begun.shards) {
|
|
162
|
-
// Begin's answer is kept by the API under its Idempotency-Key, so the key is asked for here.
|
|
163
|
-
try {
|
|
164
|
-
job.sealing = await sealingOf(await calls.get(begun.upload.id, call), size);
|
|
165
|
-
}
|
|
166
|
-
catch (e) {
|
|
167
|
-
throw new UploadStopped(begun.upload.id, e);
|
|
168
|
-
}
|
|
169
|
-
// Nothing of this upload can have been sent yet.
|
|
170
|
-
job.sealing.waiting.clear();
|
|
171
|
-
}
|
|
276
|
+
job = await begin(calls, source, size, body, blocks, options.idempotencyKey, rule, signal);
|
|
172
277
|
}
|
|
173
278
|
options.onBegun?.(job.upload);
|
|
174
279
|
let item;
|
|
@@ -191,10 +296,93 @@ export async function upload(calls, fetcher, source, options = {}) {
|
|
|
191
296
|
}
|
|
192
297
|
return item;
|
|
193
298
|
}
|
|
299
|
+
/**
|
|
300
|
+
* Begin it. Sent as it is, its parts are its blocks when it was cut, and
|
|
301
|
+
* those the API has already are not sent. Sealed, its key is asked for, and
|
|
302
|
+
* in version 2 its blocks are cut and declared, or if it cannot be written
|
|
303
|
+
* in version 2 it is begun again in version 1.
|
|
304
|
+
*/
|
|
305
|
+
async function begin(calls, source, size, body, blocks, idempotencyKey, rule, signal) {
|
|
306
|
+
const begun = await calls.begin(body, { signal, ...(idempotencyKey ? { idempotencyKey } : {}) });
|
|
307
|
+
const addresses = new Map(begun.parts.map((p) => [p.number, p]));
|
|
308
|
+
if (!begun.shards) {
|
|
309
|
+
const range = blocks ? blocksOf(blocks.map((b) => b.size)) : evenly(begun.upload, size);
|
|
310
|
+
return { source, size, upload: begun.upload, range, hashes: blocks && blocks.map((b) => b.sha256), addresses, fetching: null, done: new Set(begun.reused), sentBytes: 0, sealing: null };
|
|
311
|
+
}
|
|
312
|
+
let job;
|
|
313
|
+
try {
|
|
314
|
+
// Begin's answer is kept by the API under its Idempotency-Key, so the key is asked for here.
|
|
315
|
+
const state = await calls.get(begun.upload.id, { signal });
|
|
316
|
+
if (state.shards?.format === 2) {
|
|
317
|
+
// Begun again in version 1 below, and answered in version 2 all the same: going on would begin again for ever.
|
|
318
|
+
if (!body.shardFormats?.includes(2))
|
|
319
|
+
throw new FileFreakError(0, "format_unsupported", "The API began this upload in version 2 of the shard format, which it was not offered.");
|
|
320
|
+
const declared = await declare(calls, state, source, size, rule, signal);
|
|
321
|
+
job = declared && sealedJob(source, size, declared.upload, declared.sealing, declared.parts, declared.sealing.recorded);
|
|
322
|
+
if (!job)
|
|
323
|
+
await calls.cancel(begun.upload.id, { signal });
|
|
324
|
+
}
|
|
325
|
+
else {
|
|
326
|
+
const sealing = await sealingOf(state, size);
|
|
327
|
+
// Nothing of this upload can have been sent yet.
|
|
328
|
+
sealing.waiting.clear();
|
|
329
|
+
job = sealedJob(source, size, begun.upload, sealing, begun.parts, []);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
catch (e) {
|
|
333
|
+
throw new UploadStopped(begun.upload.id, e);
|
|
334
|
+
}
|
|
335
|
+
if (job)
|
|
336
|
+
return job;
|
|
337
|
+
// Not two to ten thousand blocks, which version 2 does not take. The API
|
|
338
|
+
// refuses one Idempotency-Key for two bodies, so this begin goes with the
|
|
339
|
+
// transport's own.
|
|
340
|
+
return await begin(calls, source, size, { ...body, shardFormats: [1] }, blocks, undefined, rule, signal);
|
|
341
|
+
}
|
|
342
|
+
/** An upload begun before, ready to send what storage does not have: or its file, if it was finished. */
|
|
343
|
+
async function carryOn(calls, source, size, id, rule, signal) {
|
|
344
|
+
const state = await calls.get(id, { signal });
|
|
345
|
+
if (state.upload.status === "completed" && state.upload.fileId)
|
|
346
|
+
return (await calls.file(state.upload.fileId, { signal })).item;
|
|
347
|
+
if (state.upload.status !== "pending") {
|
|
348
|
+
throw new FileFreakError(410, `upload_${state.upload.status}`, `That upload is ${state.upload.status}, so it cannot be carried on. Begin it again.`);
|
|
349
|
+
}
|
|
350
|
+
if (state.upload.sizeBytes !== size) {
|
|
351
|
+
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.`);
|
|
352
|
+
}
|
|
353
|
+
if (state.shards?.format === 2) {
|
|
354
|
+
if (state.shards.blocks) {
|
|
355
|
+
// Declared before. What the file keeps from its last version, and what was
|
|
356
|
+
// recorded, are filled; anything else may be in storage, and is asked about first.
|
|
357
|
+
const blocks = state.shards.blocks.map((b) => ({ id: Uint8Array.from(atob(b.id), (c) => c.charCodeAt(0)), length: b.size }));
|
|
358
|
+
const sealing = await blockSealing(await fileKeys(keyOf(state).raw), { contentId: state.shards.contentId, size, blocks }, new Set(state.uploadedParts));
|
|
359
|
+
for (let n = 0; n < blocks.length; n++)
|
|
360
|
+
if (!sealing.recorded.has(n))
|
|
361
|
+
sealing.waiting.add(n);
|
|
362
|
+
return sealedJob(source, size, state.upload, sealing, [], state.uploadedParts);
|
|
363
|
+
}
|
|
364
|
+
// Begun and never declared: nothing of it can have been sent.
|
|
365
|
+
const declared = await declare(calls, state, source, size, rule, signal);
|
|
366
|
+
if (declared)
|
|
367
|
+
return sealedJob(source, size, declared.upload, declared.sealing, declared.parts, declared.sealing.recorded);
|
|
368
|
+
await calls.cancel(id, { signal });
|
|
369
|
+
throw new FileFreakError(410, "upload_aborted", "That upload cannot be carried on: the file is not cut into 2 to 10,000 blocks, which version 2 of the shard format needs, so it has been cancelled. Begin it again, and it is written in version 1.");
|
|
370
|
+
}
|
|
371
|
+
if (state.shards)
|
|
372
|
+
return sealedJob(source, size, state.upload, await sealingOf(state, size), [], state.uploadedParts);
|
|
373
|
+
// Begun cut by content: each part is its block, checked against its SHA-256 as it is sent.
|
|
374
|
+
const range = state.blocks ? blocksOf(state.blocks.map((b) => b.size)) : evenly(state.upload, size);
|
|
375
|
+
const hashes = state.blocks && state.blocks.map((b) => b.sha256.toLowerCase());
|
|
376
|
+
return { source, size, upload: state.upload, range, hashes, addresses: new Map(), fetching: null, done: new Set([...state.uploadedParts, ...state.reused]), sentBytes: 0, sealing: null };
|
|
377
|
+
}
|
|
194
378
|
function tally(job, options) {
|
|
195
379
|
let sent = 0;
|
|
196
|
-
for (const n of job.done)
|
|
197
|
-
|
|
380
|
+
for (const n of job.done) {
|
|
381
|
+
if (n < 0 || n >= job.upload.partCount)
|
|
382
|
+
continue;
|
|
383
|
+
const { start, end } = job.range(n);
|
|
384
|
+
sent += end - start;
|
|
385
|
+
}
|
|
198
386
|
job.sentBytes = sent;
|
|
199
387
|
options.onProgress?.(sent, job.size);
|
|
200
388
|
}
|
|
@@ -300,27 +488,12 @@ async function sendParts(calls, fetcher, job, atOnce, options) {
|
|
|
300
488
|
}
|
|
301
489
|
}
|
|
302
490
|
async function sendPart(calls, fetcher, job, n, signal) {
|
|
303
|
-
const
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
catch (e) {
|
|
310
|
-
// Browsers and Node both refuse to read a file that changed after it was
|
|
311
|
-
// opened, which is what keeps one from going up half old and half new.
|
|
312
|
-
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 });
|
|
313
|
-
}
|
|
314
|
-
if (sealing) {
|
|
315
|
-
try {
|
|
316
|
-
bytes = await sealFragment(sealing.key, { contentId: sealing.contentId, layout: sealing.layout, place: n }, bytes);
|
|
317
|
-
}
|
|
318
|
-
catch (e) {
|
|
319
|
-
if (e instanceof ShardError)
|
|
320
|
-
throw Object.assign(new FileFreakError(0, "layout", e.message), { cause: e });
|
|
321
|
-
throw e;
|
|
322
|
-
}
|
|
323
|
-
}
|
|
491
|
+
const { start: from, end: to } = job.range(n);
|
|
492
|
+
let bytes = await read(job.source, from, to);
|
|
493
|
+
if (job.hashes && hex(new Uint8Array(await crypto.subtle.digest("SHA-256", bytes))) !== job.hashes[n])
|
|
494
|
+
throw changed(n);
|
|
495
|
+
if (job.sealing)
|
|
496
|
+
bytes = await job.sealing.seal(n, bytes);
|
|
324
497
|
const md5 = base64(new Md5().update(bytes).digest());
|
|
325
498
|
for (let attempt = 1;; attempt++) {
|
|
326
499
|
const part = await address(calls, job, n, signal);
|
|
@@ -383,7 +556,11 @@ async function address(calls, job, n, signal) {
|
|
|
383
556
|
}
|
|
384
557
|
async function finish(calls, fetcher, job, atOnce, options) {
|
|
385
558
|
const started = Date.now();
|
|
386
|
-
const body =
|
|
559
|
+
const body = {};
|
|
560
|
+
if (options.sha256)
|
|
561
|
+
body.sha256 = options.sha256;
|
|
562
|
+
if (job.sealing?.outline)
|
|
563
|
+
body.outline = job.sealing.outline;
|
|
387
564
|
for (let round = 0;;) {
|
|
388
565
|
try {
|
|
389
566
|
const result = await calls.complete(job.upload.id, body, { signal: options.signal });
|
|
@@ -396,7 +573,8 @@ async function finish(calls, fetcher, job, atOnce, options) {
|
|
|
396
573
|
await sleep(Math.max(1, result.retryAfterSeconds) * 1000, options.signal);
|
|
397
574
|
}
|
|
398
575
|
catch (e) {
|
|
399
|
-
// Storage does not have every part after all
|
|
576
|
+
// Storage does not have every part after all, or in version 2 a block the
|
|
577
|
+
// file kept went with the last version that had it. Send what is missing.
|
|
400
578
|
if (e instanceof FileFreakError && e.code === "upload_incomplete" && round++ < 2) {
|
|
401
579
|
const have = Array.isArray(e.body?.uploadedParts) ? e.body.uploadedParts.filter((n) => typeof n === "number") : [];
|
|
402
580
|
job.done = new Set(have);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@filefreak.io/sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.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",
|