@filefreak.io/sdk 0.1.0 → 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.
package/dist/reader.js ADDED
@@ -0,0 +1,510 @@
1
+ // Reading a file under Shard Protection (docs/reference/shard-format.md): its
2
+ // map a page at a time, each fragment fetched whole from its storage lane,
3
+ // checked and opened with the file's key (shards.ts), and written at its place
4
+ // in the sink. The CLI's reader (cli/crates/transfer/src/read.rs), by the same
5
+ // rules, with the same numbers and the same sentences, so a file reads the
6
+ // same whichever program reads it.
7
+ //
8
+ // The bytes never pass through the API: a page of the map names an address in
9
+ // a lane for each fragment, and they are fetched from there. A page needs
10
+ // nothing from the one before, so one is asked for wherever the reader lacks
11
+ // addresses: the first before anything is written, the next in the background
12
+ // once a place close ahead has none, and a fresh one from a place whose
13
+ // address has run out or is about to. Asking from place 0 is what counts the
14
+ // file as downloaded, and place 0 is fetched first, so it is asked for again
15
+ // only if fragment 0's own address runs out before it arrives.
16
+ //
17
+ // What is in flight is bounded twice. Each storage host is sent at most six
18
+ // requests at once, and the whole program holds at most MOST_IN_FLIGHT_BYTES
19
+ // of fragments being fetched or not yet written (the budget), however many
20
+ // files it reads at once.
21
+ //
22
+ // A fragment whose connection breaks, goes quiet or meets a busy storage
23
+ // account is asked for again, a little later each time, TRIES times. One that
24
+ // arrives and fails its check is not: it would fail every time, and the read
25
+ // stops with NOT_WHAT_WAS_STORED. The caller aborts the sink either way: a
26
+ // file with holes in it, or with bytes that are not the file's, is worse than
27
+ // none.
28
+ //
29
+ // The key is a WebCrypto key that cannot be exported, held inside one read
30
+ // and let go of with it. No error here carries it.
31
+ import { openFragment, ShardError, shardKey, ShardLayout, SHARD_OVERHEAD } from "./shards.js";
32
+ import { backoff, FileFreakError, sleep } from "./transport.js";
33
+ const MIB = 1024 * 1024;
34
+ /** The most bytes of fragments being fetched, or fetched and not yet written, at once in this program: five of the largest. */
35
+ export const MOST_IN_FLIGHT_BYTES = 320 * MIB;
36
+ /** Fragments fetched at once from one storage host. */
37
+ export const PER_HOST = 6;
38
+ /** Once a place this close ahead of the next fragment started has no address, the page holding it is asked for. */
39
+ const ASK_AHEAD = 200;
40
+ /** A fragment that fails this many times in a row stops the read. */
41
+ const TRIES = 5;
42
+ /** An address this close to running out is not used: a fresh page is asked for instead. */
43
+ const EXPIRY_MARGIN_MS = 60_000;
44
+ /** A request with nothing arriving for this long is given up and asked for again. */
45
+ const QUIET_MS = 30_000;
46
+ /**
47
+ * At most this many fragments started and not yet written. The budget bounds
48
+ * their bytes, and this their number, which a map of a great many small
49
+ * fragments would otherwise not be.
50
+ */
51
+ const MOST_STARTED = 64;
52
+ /** Said when a fragment arrives and fails its check. */
53
+ export const NOT_WHAT_WAS_STORED = "Part of this file is not what was stored. Nothing was saved.";
54
+ /** Said when a fragment cannot be had from storage. */
55
+ export const NOT_FETCHED = "Part of this file could not be fetched from storage. Nothing was saved; try again later.";
56
+ /** Said when a later page of the map is of another content than the first. */
57
+ const CHANGED = "This file changed while it was being read. Nothing was saved; run it again to fetch it as it is now.";
58
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
59
+ /**
60
+ * A number of units, taken and given back, first come first served: a
61
+ * budget of bytes, or turns at a host. A taker who asks for more than there
62
+ * ever is waits for all of it, and goes alone.
63
+ */
64
+ export class Gate {
65
+ most;
66
+ free;
67
+ queue = [];
68
+ constructor(most) {
69
+ this.most = most;
70
+ this.most = Math.max(1, Math.floor(most));
71
+ this.free = this.most;
72
+ }
73
+ /** Wait for `units`. Answers the way to give them back, which may be called more than once. */
74
+ take(units, signal) {
75
+ const n = Math.min(Math.max(1, Math.ceil(units)), this.most);
76
+ if (signal.aborted)
77
+ return Promise.reject(signal.reason);
78
+ return new Promise((resolve, reject) => {
79
+ const entry = {
80
+ units: n,
81
+ go: () => {
82
+ signal.removeEventListener("abort", left);
83
+ resolve(this.giver(n));
84
+ },
85
+ };
86
+ const left = () => {
87
+ const at = this.queue.indexOf(entry);
88
+ if (at < 0)
89
+ return;
90
+ this.queue.splice(at, 1);
91
+ reject(signal.reason);
92
+ // Whoever waited behind it may fit now.
93
+ this.next();
94
+ };
95
+ this.queue.push(entry);
96
+ signal.addEventListener("abort", left, { once: true });
97
+ this.next();
98
+ });
99
+ }
100
+ giver(n) {
101
+ let given = false;
102
+ return () => {
103
+ if (given)
104
+ return;
105
+ given = true;
106
+ this.free += n;
107
+ this.next();
108
+ };
109
+ }
110
+ next() {
111
+ while (this.queue.length > 0 && this.queue[0].units <= this.free) {
112
+ const entry = this.queue.shift();
113
+ this.free -= entry.units;
114
+ entry.go();
115
+ }
116
+ }
117
+ }
118
+ let processBudget = null;
119
+ /** The budget every read in this program shares, unless it is given its own. */
120
+ function shared() {
121
+ processBudget ??= new Gate(MOST_IN_FLIGHT_BYTES);
122
+ return processBudget;
123
+ }
124
+ function stopped(code, message, cause) {
125
+ const error = new FileFreakError(0, code, message);
126
+ return cause === undefined ? error : Object.assign(error, { cause });
127
+ }
128
+ function unreadable(what) {
129
+ return stopped("unreadable", `The API answered with a map whose ${what} could not be read.`);
130
+ }
131
+ const damaged = (cause) => stopped("fragment_damaged", NOT_WHAT_WAS_STORED, cause);
132
+ const notFetched = (cause) => stopped("fragment_unavailable", NOT_FETCHED, cause);
133
+ /**
134
+ * A page whose count, or one of whose places, the file's size and fragment
135
+ * size do not allow: a map that does not add up is not read from.
136
+ */
137
+ function agrees(page, layout) {
138
+ if (page.count !== layout.count) {
139
+ throw stopped("layout", `This file's map does not add up: it says ${page.count} fragments, where ${layout.size} bytes in fragments of ${layout.fragmentSize} make ${layout.count}. Nothing was saved.`);
140
+ }
141
+ const wrong = page.fragments.find((f) => !Number.isInteger(f.place) || f.place < 0 || f.place >= layout.count);
142
+ if (wrong) {
143
+ throw stopped("layout", `This file's map names fragment ${wrong.place}, and the file has ${layout.count}, numbered from 0. Nothing was saved.`);
144
+ }
145
+ }
146
+ async function keyOf(text) {
147
+ let raw;
148
+ try {
149
+ raw = Uint8Array.from(atob(text), (c) => c.charCodeAt(0));
150
+ }
151
+ catch {
152
+ throw unreadable("key");
153
+ }
154
+ if (raw.length !== 32)
155
+ throw unreadable("key");
156
+ return await shardKey(raw);
157
+ }
158
+ /**
159
+ * Read the protected file whose map `pages` gives into `sink`. Resolves once
160
+ * every fragment has been written; the caller closes the sink, or aborts it
161
+ * when this rejects, as with fetchRanges. Rejects only once every fragment
162
+ * still in flight has stopped, so nothing is written to the sink after.
163
+ */
164
+ export async function readProtected(options) {
165
+ const stop = new AbortController();
166
+ const outer = options.signal;
167
+ const passOn = () => stop.abort(outer?.reason);
168
+ if (outer?.aborted)
169
+ passOn();
170
+ outer?.addEventListener("abort", passOn);
171
+ try {
172
+ const first = await options.pages(0, stop.signal);
173
+ if (options.sizeBytes !== undefined && first.sizeBytes !== options.sizeBytes)
174
+ throw stopped("content_changed", CHANGED);
175
+ let layout;
176
+ try {
177
+ layout = new ShardLayout(first.sizeBytes, first.fragmentBytes);
178
+ }
179
+ catch (e) {
180
+ if (e instanceof ShardError)
181
+ throw stopped("layout", `${e.message} Nothing was saved.`);
182
+ throw e;
183
+ }
184
+ agrees(first, layout);
185
+ if (!UUID.test(first.contentId))
186
+ throw unreadable("content id");
187
+ const reading = new Reading(options, stop, layout, first.contentId, await keyOf(first.key));
188
+ reading.hold(first);
189
+ await reading.fill();
190
+ }
191
+ finally {
192
+ outer?.removeEventListener("abort", passOn);
193
+ // A page asked for ahead of need is not waited for.
194
+ if (!stop.signal.aborted)
195
+ stop.abort(new DOMException("The read is over.", "AbortError"));
196
+ }
197
+ }
198
+ /**
199
+ * Fetch a file the way it is kept: through its map when it is protected,
200
+ * from its address when not. One converted since it was listed is refused
201
+ * the way the listing chose (409 not_protected, or shard_protected) before
202
+ * anything is written, and is fetched the other way, once.
203
+ */
204
+ export async function asItIsKept(isProtected, plain, mapped) {
205
+ const refused = isProtected ? "not_protected" : "shard_protected";
206
+ try {
207
+ await (isProtected ? mapped() : plain());
208
+ }
209
+ catch (e) {
210
+ if (!(e instanceof FileFreakError && e.code === refused))
211
+ throw e;
212
+ await (isProtected ? plain() : mapped());
213
+ }
214
+ }
215
+ /** One read: what every fragment of it shares. */
216
+ class Reading {
217
+ options;
218
+ stop;
219
+ layout;
220
+ contentId;
221
+ key;
222
+ addresses = new Map();
223
+ /** Held while a page is asked for, so that fragments waiting on the same page ask once. */
224
+ asking = new Gate(1);
225
+ /** A page is being asked for ahead of need. */
226
+ looking = false;
227
+ hosts = new Map();
228
+ budget;
229
+ fetcher;
230
+ wait;
231
+ now;
232
+ quietMs;
233
+ written = 0;
234
+ constructor(options, stop, layout, contentId, key) {
235
+ this.options = options;
236
+ this.stop = stop;
237
+ this.layout = layout;
238
+ this.contentId = contentId;
239
+ this.key = key;
240
+ this.budget = options.budget ?? shared();
241
+ this.fetcher = options.fetch ?? fetch.bind(globalThis);
242
+ this.wait = options.sleep ?? sleep;
243
+ this.now = options.now ?? Date.now;
244
+ this.quietMs = options.quietMs ?? QUIET_MS;
245
+ }
246
+ get signal() {
247
+ return this.stop.signal;
248
+ }
249
+ /** Keep a page's addresses, once it is known to be a page of this file's map. */
250
+ hold(page) {
251
+ if (page.contentId !== this.contentId || page.sizeBytes !== this.layout.size || page.fragmentBytes !== this.layout.fragmentSize) {
252
+ throw stopped("content_changed", CHANGED);
253
+ }
254
+ agrees(page, this.layout);
255
+ const expiresAt = Date.parse(page.expiresAt);
256
+ if (!Number.isFinite(expiresAt))
257
+ throw unreadable("time its addresses run out");
258
+ for (const f of page.fragments)
259
+ this.addresses.set(f.place, { url: f.url, expiresAt });
260
+ }
261
+ /** The address for `place`, if there is one that will not run out in the next minute. */
262
+ usable(place) {
263
+ const a = this.addresses.get(place);
264
+ return a && a.expiresAt > this.now() + EXPIRY_MARGIN_MS ? a.url : null;
265
+ }
266
+ /** The first place from `from` on, and within ASK_AHEAD of it, with no address at all. */
267
+ missingAhead(from) {
268
+ const until = Math.min(from + ASK_AHEAD, this.layout.count);
269
+ for (let place = from; place < until; place++)
270
+ if (!this.addresses.has(place))
271
+ return place;
272
+ return null;
273
+ }
274
+ async addressFor(place) {
275
+ const ready = this.usable(place);
276
+ if (ready)
277
+ return ready;
278
+ const done = await this.asking.take(1, this.signal);
279
+ try {
280
+ // A page asked for while this one waited its turn may have brought it.
281
+ const now = this.usable(place);
282
+ if (now)
283
+ return now;
284
+ this.hold(await this.options.pages(place, this.signal));
285
+ // Used as given, however soon this computer's clock says it runs out:
286
+ // a page is as fresh as the API makes them, and a clock that disagrees
287
+ // with storage's is found out by storage refusing the address, not by
288
+ // asking for the page again for every fragment.
289
+ const given = this.addresses.get(place);
290
+ if (!given)
291
+ throw notFetched();
292
+ return given.url;
293
+ }
294
+ finally {
295
+ done();
296
+ }
297
+ }
298
+ /** Storage refused `url`, so it has run out, and so has every address given with it or before it. */
299
+ forget(url) {
300
+ let at = null;
301
+ for (const a of this.addresses.values()) {
302
+ if (a.url === url) {
303
+ at = a.expiresAt;
304
+ break;
305
+ }
306
+ }
307
+ if (at === null)
308
+ return;
309
+ for (const [place, a] of this.addresses)
310
+ if (a.expiresAt <= at)
311
+ this.addresses.delete(place);
312
+ }
313
+ /** A turn at the host `url` is on. Each storage lane is an account of its own, on a host of its own. */
314
+ turnAt(url) {
315
+ let host = "";
316
+ try {
317
+ host = new URL(url).host;
318
+ }
319
+ catch {
320
+ // An address that is not one is refused by fetch, and counted as a failure there.
321
+ }
322
+ let turns = this.hosts.get(host);
323
+ if (!turns) {
324
+ turns = new Gate(Math.max(1, this.options.perHost ?? PER_HOST));
325
+ this.hosts.set(host, turns);
326
+ }
327
+ return turns.take(1, this.signal);
328
+ }
329
+ /** One request for a stored fragment, held whole and at most `stored` bytes, so a longer one is caught as it arrives. */
330
+ async fetchOne(url, stored) {
331
+ const asking = new AbortController();
332
+ const stop = () => asking.abort(this.signal.reason);
333
+ this.signal.addEventListener("abort", stop);
334
+ let timer;
335
+ const listen = () => {
336
+ clearTimeout(timer);
337
+ timer = setTimeout(() => asking.abort(new DOMException("Nothing arrived for a while.", "TimeoutError")), this.quietMs);
338
+ };
339
+ try {
340
+ listen();
341
+ let res;
342
+ try {
343
+ res = await this.fetcher(url, { signal: asking.signal });
344
+ }
345
+ catch {
346
+ if (this.signal.aborted)
347
+ throw this.signal.reason;
348
+ return "passing";
349
+ }
350
+ if (!res.ok || !res.body) {
351
+ await res.body?.cancel().catch(() => undefined);
352
+ if (res.status === 403)
353
+ return "refused";
354
+ if (res.status === 408 || res.status === 429 || res.status >= 500)
355
+ return "passing";
356
+ return "failed";
357
+ }
358
+ const blob = new Uint8Array(stored);
359
+ let at = 0;
360
+ const reader = res.body.getReader();
361
+ for (;;) {
362
+ listen();
363
+ let chunk;
364
+ try {
365
+ chunk = await reader.read();
366
+ }
367
+ catch {
368
+ if (this.signal.aborted)
369
+ throw this.signal.reason;
370
+ return "passing";
371
+ }
372
+ if (chunk.done)
373
+ return blob.subarray(0, at);
374
+ if (at + chunk.value.byteLength > stored) {
375
+ await reader.cancel().catch(() => undefined);
376
+ return "too_long";
377
+ }
378
+ blob.set(chunk.value, at);
379
+ at += chunk.value.byteLength;
380
+ }
381
+ }
382
+ finally {
383
+ clearTimeout(timer);
384
+ this.signal.removeEventListener("abort", stop);
385
+ }
386
+ }
387
+ /** Fetch, check, open and write the fragment at `place`. */
388
+ async fragment(place) {
389
+ const { start } = this.layout.range(place);
390
+ const stored = this.layout.length(place) + SHARD_OVERHEAD;
391
+ let tries = 0;
392
+ let refreshed = false;
393
+ let blob;
394
+ for (;;) {
395
+ const url = await this.addressFor(place);
396
+ const done = await this.turnAt(url);
397
+ let fetched;
398
+ try {
399
+ fetched = await this.fetchOne(url, stored);
400
+ }
401
+ finally {
402
+ done();
403
+ }
404
+ if (fetched instanceof Uint8Array) {
405
+ blob = fetched;
406
+ break;
407
+ }
408
+ if (fetched === "refused" && !refreshed) {
409
+ // The address ran out: a fresh page from this place, once.
410
+ refreshed = true;
411
+ this.forget(url);
412
+ continue;
413
+ }
414
+ if (fetched === "passing") {
415
+ tries += 1;
416
+ if (tries >= TRIES)
417
+ throw notFetched();
418
+ await this.wait(backoff(tries), this.signal);
419
+ continue;
420
+ }
421
+ if (fetched === "too_long")
422
+ throw damaged();
423
+ throw notFetched();
424
+ }
425
+ let plain;
426
+ try {
427
+ plain = await openFragment(this.key, { contentId: this.contentId, layout: this.layout, place }, blob);
428
+ }
429
+ catch (e) {
430
+ throw damaged(e);
431
+ }
432
+ if (this.signal.aborted)
433
+ throw this.signal.reason;
434
+ await this.options.sink.write(start, plain);
435
+ this.written += plain.byteLength;
436
+ this.options.progress?.(this.written);
437
+ }
438
+ /**
439
+ * Every fragment, started in place order as the budget allows, and waited
440
+ * for. The first that cannot be had stops the others: the file cannot be
441
+ * finished without it. It is what the read rejects with, once every other
442
+ * fragment has stopped.
443
+ */
444
+ async fill() {
445
+ const started = new Gate(MOST_STARTED);
446
+ const flying = new Set();
447
+ // The first reason the read stopped, which it rejects with.
448
+ const failures = [];
449
+ try {
450
+ for (let place = 0; place < this.layout.count; place++) {
451
+ const slot = await started.take(1, this.signal);
452
+ let held;
453
+ try {
454
+ held = await this.budget.take(this.layout.length(place) + SHARD_OVERHEAD, this.signal);
455
+ }
456
+ catch (e) {
457
+ slot();
458
+ throw e;
459
+ }
460
+ this.lookAhead(place);
461
+ const task = this.fragment(place)
462
+ .catch((e) => {
463
+ failures.push(this.signal.aborted ? this.signal.reason : e);
464
+ if (!this.signal.aborted)
465
+ this.stop.abort(e);
466
+ })
467
+ .finally(() => {
468
+ held();
469
+ slot();
470
+ flying.delete(task);
471
+ });
472
+ flying.add(task);
473
+ }
474
+ await Promise.all([...flying]);
475
+ }
476
+ catch (e) {
477
+ // The signal stopped a wait above: the caller's, or a fragment's failure.
478
+ failures.push(e);
479
+ }
480
+ await Promise.allSettled([...flying]);
481
+ if (failures.length > 0)
482
+ throw failures[0];
483
+ }
484
+ /** Ask, in the background, for the page holding the first place close ahead of `from` with no address, unless one is being asked for already. */
485
+ lookAhead(from) {
486
+ const missing = this.missingAhead(from);
487
+ if (missing === null || this.looking)
488
+ return;
489
+ this.looking = true;
490
+ void (async () => {
491
+ try {
492
+ const done = await this.asking.take(1, this.signal);
493
+ try {
494
+ if (!this.addresses.has(missing))
495
+ this.hold(await this.options.pages(missing, this.signal));
496
+ }
497
+ finally {
498
+ done();
499
+ }
500
+ }
501
+ catch {
502
+ // A page that cannot be had, or does not belong, is left to the
503
+ // fragment that needs it, which asks again and says why.
504
+ }
505
+ finally {
506
+ this.looking = false;
507
+ }
508
+ })();
509
+ }
510
+ }
package/dist/shards.d.ts CHANGED
@@ -56,4 +56,4 @@ export declare function sealWithNonce(key: ShardKey, at: FragmentAt, plaintext:
56
56
  * fragment at this place, unchanged. Nothing of a fragment that fails is
57
57
  * returned: WebCrypto gives no plaintext until the tag has been checked.
58
58
  */
59
- export declare function openFragment(key: ShardKey, at: FragmentAt, blob: Uint8Array): Promise<Uint8Array>;
59
+ export declare function openFragment(key: ShardKey, at: FragmentAt, blob: Uint8Array): Promise<Uint8Array<ArrayBuffer>>;
package/dist/uploads.js CHANGED
@@ -74,7 +74,7 @@ export async function upload(calls, fetcher, source, options = {}) {
74
74
  if (state.upload.sizeBytes !== size) {
75
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
76
  }
77
- // Begun by a program that cut the file by its content, as ff does: its
77
+ // Begun by a program that cut the file by its content, as filefreak does: its
78
78
  // parts are blocks of different sizes, and this reads part n at n times
79
79
  // partBytes, so every part would be sent from the wrong place and the
80
80
  // upload could never complete.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@filefreak.io/sdk",
3
- "version": "0.1.0",
3
+ "version": "0.2.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",