@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/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 ff does: its
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
- job = { source, size, upload: state.upload, addresses: new Map(), fetching: null, done: new Set(state.uploadedParts), sentBytes: 0 };
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 sendParts(calls, fetcher, job, atOnce, options);
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 from = n * job.upload.partBytes;
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, Math.min(job.size, from + job.upload.partBytes));
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 sendParts(calls, fetcher, job, atOnce, options);
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.1.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",