@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,34 @@
1
+ /** At most this many ranges at once. Six filled the line measured; more only adds requests. */
2
+ export declare const RANGES_AT_ONCE = 6;
3
+ /** No range is cut smaller than this: each is a request, and a request costs a round trip before its first byte. */
4
+ export declare const LEAST_RANGE: number;
5
+ /** Where a download's bytes go. */
6
+ export interface Sink {
7
+ /** Write `data` at `position` in the file. Called for several positions at once. */
8
+ write(position: number, data: Uint8Array<ArrayBuffer>): Promise<void>;
9
+ /** Called once every byte has been written. */
10
+ close?(): Promise<void>;
11
+ /** Called instead of close when the download fails or is stopped: a file with holes in it is worse than none. */
12
+ abort?(reason: unknown): Promise<void>;
13
+ }
14
+ export interface RangesOptions {
15
+ url: string;
16
+ sizeBytes: number;
17
+ sink: Sink;
18
+ signal?: AbortSignal | undefined;
19
+ /** How many bytes have been written, after each write. */
20
+ progress?: ((writtenBytes: number) => void) | undefined;
21
+ /** At most this many ranges at once. */
22
+ ranges?: number | undefined;
23
+ fetch?: typeof fetch;
24
+ sleep?: (ms: number) => Promise<void>;
25
+ quietMs?: number;
26
+ /** LEAST_RANGE, unless a test makes it smaller rather than make a file of gigabytes. */
27
+ leastRange?: number;
28
+ }
29
+ /**
30
+ * Fetch `url` as ranges and write each where it belongs. Resolves when every
31
+ * byte has been written; rejects, with every request stopped, when one range
32
+ * cannot be finished.
33
+ */
34
+ export declare function fetchRanges(options: RangesOptions): Promise<void>;
package/dist/ranges.js ADDED
@@ -0,0 +1,303 @@
1
+ // Fetching one file from storage as several ranges at once, each written
2
+ // where it belongs.
3
+ //
4
+ // The website's engine for saving a large file (web/lib/rangedSave.ts),
5
+ // without the page: the same measurements, the same rules. One request from
6
+ // storage was measured at 360 to 420 Mbps on a line that carries about
7
+ // 1 Gbps, and six ranges at once at 560 to 660 (plan 04, B1.5), so a file
8
+ // large enough is fetched as up to six ranges, and a small one as one.
9
+ //
10
+ // One address, asked for once, because asking for another counts the file as
11
+ // downloaded again. An address works for fifteen minutes and a request that
12
+ // has started is not cut off when it passes, so each range is one long
13
+ // request, all of them made at the start. When one finishes early it takes
14
+ // the second half of the largest one left, as a new request. If that request
15
+ // is not answered (the address has passed, say), the half goes back to the
16
+ // range it was taken from, whose own request asked for it in the first place:
17
+ // that range waits at the point it was cut until the new request has been
18
+ // answered, so that it is still there to give back to. A range whose
19
+ // connection breaks, or goes quiet, is asked for again from where it stopped.
20
+ // After the address has passed that is refused, and the download stops with
21
+ // an error saying so.
22
+ import { FileFreakError } from "./transport.js";
23
+ const MIB = 1024 * 1024;
24
+ /** At most this many ranges at once. Six filled the line measured; more only adds requests. */
25
+ export const RANGES_AT_ONCE = 6;
26
+ /** No range is cut smaller than this: each is a request, and a request costs a round trip before its first byte. */
27
+ export const LEAST_RANGE = 16 * MIB;
28
+ /** Written to the sink this much at a time. Fewer writes, for this much memory for each range. */
29
+ const WRITE_BYTES = 4 * MIB;
30
+ /** A connection that breaks is asked for again this many times in a row, from where it stopped. */
31
+ const TRIES = 5;
32
+ /** A request with nothing arriving for this long is given up and asked for again. */
33
+ const QUIET_MS = 30_000;
34
+ /** A connection that broke or went quiet. The only failure worth asking again after. */
35
+ class Broke extends Error {
36
+ }
37
+ /**
38
+ * Fetch `url` as ranges and write each where it belongs. Resolves when every
39
+ * byte has been written; rejects, with every request stopped, when one range
40
+ * cannot be finished.
41
+ */
42
+ export async function fetchRanges(options) {
43
+ // The first range that fails stops the others: the file cannot be finished without it.
44
+ const stop = new AbortController();
45
+ const outer = options.signal;
46
+ const passOn = () => stop.abort(outer?.reason);
47
+ if (outer?.aborted)
48
+ passOn();
49
+ outer?.addEventListener("abort", passOn);
50
+ let written = 0;
51
+ const run = {
52
+ url: options.url,
53
+ sizeBytes: options.sizeBytes,
54
+ sink: options.sink,
55
+ signal: stop.signal,
56
+ fetch: options.fetch ?? globalThis.fetch.bind(globalThis),
57
+ sleep: options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms))),
58
+ quietMs: options.quietMs ?? QUIET_MS,
59
+ wrote: (bytes) => {
60
+ written += bytes;
61
+ options.progress?.(written);
62
+ },
63
+ };
64
+ const least = options.leastRange ?? LEAST_RANGE;
65
+ const most = Math.max(1, Math.min(RANGES_AT_ONCE, options.ranges ?? RANGES_AT_ONCE));
66
+ const count = Math.max(1, Math.min(most, Math.floor(options.sizeBytes / least)));
67
+ const ranges = [];
68
+ for (let i = 0; i < count; i++) {
69
+ ranges.push({ at: Math.floor((i * options.sizeBytes) / count), end: Math.floor(((i + 1) * options.sizeBytes) / count), giving: null });
70
+ }
71
+ // One cut at a time, so that a range has at most one half out that could come back.
72
+ let cut = null;
73
+ let cutting = most > 1;
74
+ const worker = async (first) => {
75
+ await read(first, run);
76
+ for (;;) {
77
+ while (cut)
78
+ await cut;
79
+ if (!cutting || stop.signal.aborted)
80
+ return;
81
+ const from = ranges.filter((r) => r.end - r.at >= 2 * least).sort((a, b) => b.end - b.at - (a.end - a.at))[0];
82
+ if (!from)
83
+ return;
84
+ const before = from.end;
85
+ const half = { at: from.at + Math.floor((from.end - from.at) / 2), end: before, giving: null };
86
+ let settle = () => { };
87
+ cut = from.giving = new Promise((resolve) => (settle = resolve));
88
+ const settled = () => {
89
+ from.giving = null;
90
+ cut = null;
91
+ settle();
92
+ };
93
+ from.end = half.at;
94
+ ranges.push(half);
95
+ try {
96
+ await read(half, run, settled);
97
+ }
98
+ catch (e) {
99
+ if (!from.giving)
100
+ throw e;
101
+ // Never answered, so nothing of it was written: the range it came
102
+ // from takes it back and carries on to where it was going. No more
103
+ // cuts, since whatever stopped this one would stop the next.
104
+ from.end = before;
105
+ ranges.splice(ranges.indexOf(half), 1);
106
+ cutting = false;
107
+ settled();
108
+ if (stop.signal.aborted)
109
+ throw e;
110
+ }
111
+ }
112
+ };
113
+ try {
114
+ await Promise.all(ranges.slice().map((r) => worker(r).catch((e) => {
115
+ stop.abort(e);
116
+ throw e;
117
+ })));
118
+ }
119
+ finally {
120
+ outer?.removeEventListener("abort", passOn);
121
+ }
122
+ }
123
+ /**
124
+ * Read one range to its end, asking again from where it stopped when its
125
+ * connection breaks. With `answered`, the range is a half just taken from
126
+ * another: until storage has answered for it, any failure is thrown at once
127
+ * (so that the half can go back) rather than asked again, and `answered` is
128
+ * called when it has.
129
+ */
130
+ async function read(range, run, answered) {
131
+ let tries = 0;
132
+ for (;;) {
133
+ // What is asked for is what the range holds now, which a cut that has
134
+ // not been answered could still change.
135
+ while (range.giving)
136
+ await range.giving;
137
+ if (range.at >= range.end)
138
+ return;
139
+ const startedAt = range.at;
140
+ const asking = new Asking(run);
141
+ try {
142
+ const res = await asking.fetch(range);
143
+ answered?.();
144
+ answered = undefined;
145
+ await drain(range, res, run, asking);
146
+ }
147
+ catch (e) {
148
+ if (run.signal.aborted || answered || !(e instanceof Broke))
149
+ throw e;
150
+ tries = await again(range.at > startedAt ? 0 : tries, run, e);
151
+ continue;
152
+ }
153
+ finally {
154
+ asking.done();
155
+ }
156
+ if (range.at >= range.end)
157
+ return;
158
+ tries = await again(range.at > startedAt ? 0 : tries, run, new Broke("the connection closed before the end of the range"));
159
+ }
160
+ }
161
+ /**
162
+ * One request, given up when nothing arrives on it for a while. Stopped with
163
+ * everything else when the download is.
164
+ */
165
+ class Asking {
166
+ run;
167
+ controller = new AbortController();
168
+ timer;
169
+ stop = () => this.controller.abort(this.run.signal.reason);
170
+ constructor(run) {
171
+ this.run = run;
172
+ run.signal.addEventListener("abort", this.stop);
173
+ }
174
+ /** Something arrived, or is about to be waited for. */
175
+ listen() {
176
+ clearTimeout(this.timer);
177
+ this.timer = setTimeout(() => this.controller.abort(new Broke("nothing arrived for a while")), this.run.quietMs);
178
+ }
179
+ /** Nothing is expected for now: this side is not reading. */
180
+ deaf() {
181
+ clearTimeout(this.timer);
182
+ }
183
+ done() {
184
+ this.deaf();
185
+ this.run.signal.removeEventListener("abort", this.stop);
186
+ }
187
+ /** Ask for the rest of `range`, and check that storage answered with it. */
188
+ async fetch(range) {
189
+ this.listen();
190
+ let res;
191
+ try {
192
+ res = await this.run.fetch(this.run.url, { headers: { Range: `bytes=${range.at}-${range.end - 1}` }, signal: this.controller.signal });
193
+ }
194
+ catch (e) {
195
+ throw this.run.signal.aborted ? e : new Broke("the request failed", { cause: e });
196
+ }
197
+ const refuse = async (error) => {
198
+ await res.body?.cancel().catch(() => undefined);
199
+ throw error;
200
+ };
201
+ if (res.status === 403) {
202
+ return refuse(new FileFreakError(403, "address_expired", "The download stopped: a connection broke after the download's address had run out, so the rest could not be asked for. Download it again."));
203
+ }
204
+ if (res.status === 408 || res.status === 429 || res.status >= 500)
205
+ return refuse(new Broke(`storage answered ${res.status}`));
206
+ if (res.status !== 206)
207
+ return refuse(new FileFreakError(res.status, "storage_refused", `Storage answered ${res.status} when asked for part of the file.`));
208
+ // Content-Range is one of the headers storage lets a page read. Without
209
+ // it, a part written at the wrong place would go unnoticed.
210
+ const sent = /^bytes (\d+)-(\d+)\/(\d+)$/.exec(res.headers.get("Content-Range") ?? "");
211
+ if (!sent || !res.body)
212
+ return refuse(new FileFreakError(res.status, "storage_refused", "Storage did not say which part of the file it sent."));
213
+ if (Number(sent[3]) !== this.run.sizeBytes) {
214
+ return refuse(new FileFreakError(res.status, "file_changed", "The file in storage is not the size the API gave. It may have been replaced since; download it again."));
215
+ }
216
+ if (Number(sent[1]) !== range.at)
217
+ return refuse(new FileFreakError(res.status, "storage_refused", "Storage sent another part of the file than the one asked for."));
218
+ return res;
219
+ }
220
+ /** The next piece of the body, or null at its end. */
221
+ async next(reader) {
222
+ this.listen();
223
+ try {
224
+ const { done, value } = await reader.read();
225
+ return done ? null : value;
226
+ }
227
+ catch (e) {
228
+ throw this.run.signal.aborted ? e : new Broke("the connection broke", { cause: e });
229
+ }
230
+ }
231
+ }
232
+ /** Write what arrives on `res` into the range, until the range ends or the body does. */
233
+ async function drain(range, res, run, asking) {
234
+ const reader = res.body.getReader();
235
+ const held = [];
236
+ let heldBytes = 0;
237
+ const flush = async () => {
238
+ if (heldBytes === 0)
239
+ return;
240
+ const data = join(held, heldBytes);
241
+ const position = range.at - heldBytes;
242
+ held.length = 0;
243
+ heldBytes = 0;
244
+ asking.deaf();
245
+ await run.sink.write(position, data);
246
+ run.wrote(data.byteLength);
247
+ };
248
+ try {
249
+ for (;;) {
250
+ let chunk = await asking.next(reader);
251
+ if (!chunk)
252
+ break;
253
+ while (chunk.byteLength > 0) {
254
+ if (range.at >= range.end) {
255
+ await flush();
256
+ if (range.giving) {
257
+ // Cut here. This request goes on covering the rest, so wait,
258
+ // reading nothing, to learn whether the rest comes back.
259
+ asking.deaf();
260
+ await range.giving;
261
+ continue;
262
+ }
263
+ await reader.cancel().catch(() => undefined);
264
+ return;
265
+ }
266
+ const take = chunk.subarray(0, Math.min(chunk.byteLength, range.end - range.at));
267
+ held.push(take);
268
+ heldBytes += take.byteLength;
269
+ range.at += take.byteLength;
270
+ chunk = chunk.subarray(take.byteLength);
271
+ if (heldBytes >= WRITE_BYTES)
272
+ await flush();
273
+ }
274
+ }
275
+ }
276
+ finally {
277
+ // What arrived before a break is right, so it is written, and the range
278
+ // is asked for again from after it.
279
+ if (!run.signal.aborted)
280
+ await flush();
281
+ }
282
+ }
283
+ /** Wait before asking again, a little longer each time; give up after TRIES in a row with nothing arriving. */
284
+ async function again(tries, run, why) {
285
+ if (tries + 1 >= TRIES) {
286
+ throw Object.assign(new FileFreakError(0, "storage_unreachable", "The download stopped: storage could not be reached after several tries. Download it again."), { cause: why });
287
+ }
288
+ await run.sleep(Math.min(8000, 500 * 2 ** tries));
289
+ if (run.signal.aborted)
290
+ throw run.signal.reason;
291
+ return tries + 1;
292
+ }
293
+ function join(parts, bytes) {
294
+ if (parts.length === 1)
295
+ return parts[0];
296
+ const out = new Uint8Array(bytes);
297
+ let at = 0;
298
+ for (const p of parts) {
299
+ out.set(p, at);
300
+ at += p.byteLength;
301
+ }
302
+ return out;
303
+ }
@@ -0,0 +1,76 @@
1
+ import type { Sink } from "./ranges.js";
2
+ /** The most bytes of fragments being fetched, or fetched and not yet written, at once in this program: five of the largest. */
3
+ export declare const MOST_IN_FLIGHT_BYTES: number;
4
+ /** Fragments fetched at once from one storage host. */
5
+ export declare const PER_HOST = 6;
6
+ /** Said when a fragment arrives and fails its check. */
7
+ export declare const NOT_WHAT_WAS_STORED = "Part of this file is not what was stored. Nothing was saved.";
8
+ /** Said when a fragment cannot be had from storage. */
9
+ export declare const NOT_FETCHED = "Part of this file could not be fetched from storage. Nothing was saved; try again later.";
10
+ /** A page of a protected file's map, as the reader uses it: files.shards's answer, or a link's. */
11
+ export interface MapPage {
12
+ contentId: string;
13
+ sizeBytes: number;
14
+ fragmentBytes: number;
15
+ count: number;
16
+ /** The file's key, in base64. */
17
+ key: string;
18
+ fragments: ReadonlyArray<{
19
+ place: number;
20
+ url: string;
21
+ }>;
22
+ /** When this page's addresses stop working. */
23
+ expiresAt: string;
24
+ }
25
+ /** Asks for the page of a map that begins at a place. A function, so that a map is read the same way whoever gives it. */
26
+ export type Pages = (from: number, signal: AbortSignal) => Promise<MapPage>;
27
+ /**
28
+ * A number of units, taken and given back, first come first served: a
29
+ * budget of bytes, or turns at a host. A taker who asks for more than there
30
+ * ever is waits for all of it, and goes alone.
31
+ */
32
+ export declare class Gate {
33
+ private readonly most;
34
+ private free;
35
+ private readonly queue;
36
+ constructor(most: number);
37
+ /** Wait for `units`. Answers the way to give them back, which may be called more than once. */
38
+ take(units: number, signal: AbortSignal): Promise<() => void>;
39
+ private giver;
40
+ private next;
41
+ }
42
+ export interface ReadOptions {
43
+ pages: Pages;
44
+ sink: Sink;
45
+ /**
46
+ * The file's size as it was listed. A map of another size is of a file
47
+ * replaced since, and is not read: a sink made for the size listed, as
48
+ * bytes() makes one, could not hold it.
49
+ */
50
+ sizeBytes?: number;
51
+ signal?: AbortSignal | undefined;
52
+ /** How many bytes of the file have been written, after each fragment. */
53
+ progress?: ((writtenBytes: number) => void) | undefined;
54
+ fetch?: typeof fetch;
55
+ sleep?: (ms: number, signal: AbortSignal) => Promise<void>;
56
+ now?: () => number;
57
+ /** Bytes of fragments in flight: the program's, unless a test gives a read its own. */
58
+ budget?: Gate;
59
+ /** Fragments fetched at once from one storage host. */
60
+ perHost?: number;
61
+ quietMs?: number;
62
+ }
63
+ /**
64
+ * Read the protected file whose map `pages` gives into `sink`. Resolves once
65
+ * every fragment has been written; the caller closes the sink, or aborts it
66
+ * when this rejects, as with fetchRanges. Rejects only once every fragment
67
+ * still in flight has stopped, so nothing is written to the sink after.
68
+ */
69
+ export declare function readProtected(options: ReadOptions): Promise<void>;
70
+ /**
71
+ * Fetch a file the way it is kept: through its map when it is protected,
72
+ * from its address when not. One converted since it was listed is refused
73
+ * the way the listing chose (409 not_protected, or shard_protected) before
74
+ * anything is written, and is fetched the other way, once.
75
+ */
76
+ export declare function asItIsKept(isProtected: boolean, plain: () => Promise<void>, mapped: () => Promise<void>): Promise<void>;