@filefreak.io/sdk 0.2.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 +6 -2
- package/dist/generated.d.ts +59 -3
- package/dist/generated.js +14 -3
- package/dist/shards.d.ts +2 -2
- package/dist/uploads.d.ts +2 -1
- package/dist/uploads.js +152 -6
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -6,8 +6,8 @@ later and in browsers. No dependencies.
|
|
|
6
6
|
Every endpoint is a function, typed from the same table the API is built
|
|
7
7
|
from. Around them: uploads sent straight to storage in parts and carried on
|
|
8
8
|
after an interruption, downloads fetched in ranges, listings and changes read
|
|
9
|
-
to the end, files under Shard Protection
|
|
10
|
-
share link holds saved with no key, and requests sent again only when that
|
|
9
|
+
to the end, files under Shard Protection sealed as they are sent and read
|
|
10
|
+
through their maps, what a share link holds saved with no key, and requests sent again only when that
|
|
11
11
|
cannot make anything twice. The reference is at
|
|
12
12
|
https://filefreak.io/developers/reference.
|
|
13
13
|
|
|
@@ -34,6 +34,10 @@ than memory is fine. `toPath` writes beside the file and gives it its name
|
|
|
34
34
|
only once every byte is there: a download that fails leaves nothing behind.
|
|
35
35
|
A file under Shard Protection has no address, and `download` and `bytes`
|
|
36
36
|
read it through its map instead, each fragment checked as it arrives.
|
|
37
|
+
Into a folder under Shard Protection, `upload` seals each part with the
|
|
38
|
+
file's own key before it is sent, so none of the file is ever plain in
|
|
39
|
+
storage. The key is asked of the API for the upload and kept nowhere, and
|
|
40
|
+
an upload carried on asks which fragments storage has before it sends any.
|
|
37
41
|
|
|
38
42
|
Keep keys on a server. A key sent from a web page is refused.
|
|
39
43
|
|
package/dist/generated.d.ts
CHANGED
|
@@ -25,6 +25,8 @@ export interface Item {
|
|
|
25
25
|
clientSha256: string | null;
|
|
26
26
|
/** Whether the file is under Shard Protection: kept as sealed fragments, and read through its map (files.shards) rather than from an address. */
|
|
27
27
|
protected: boolean;
|
|
28
|
+
/** Its own Shard Protection setting. inherit follows the nearest folder above it that has one, and with none the drive's default: the app's, which its owner turns on from the app's page, or for a device the account's. The nearest setting wins. A file is converted within a few minutes of a change, and protected says when it has been. */
|
|
29
|
+
protection: "on" | "off" | "inherit";
|
|
28
30
|
createdAt: string;
|
|
29
31
|
updatedAt: string;
|
|
30
32
|
}
|
|
@@ -214,6 +216,8 @@ export type FilesUpdateBody = {
|
|
|
214
216
|
parentId?: string | null;
|
|
215
217
|
/** public, private, or inherit to follow the folder it is in. */
|
|
216
218
|
visibility?: "private" | "public" | "inherit";
|
|
219
|
+
/** on, off, or inherit to follow the folder it is in. */
|
|
220
|
+
protection?: "on" | "off" | "inherit";
|
|
217
221
|
};
|
|
218
222
|
/** What PATCH /v1/files/:id answers with. */
|
|
219
223
|
export type FilesUpdateAnswer = {
|
|
@@ -304,6 +308,8 @@ export type UploadsBeginBody = {
|
|
|
304
308
|
/** The block's SHA-256 in hex. */
|
|
305
309
|
sha256: string;
|
|
306
310
|
}>;
|
|
311
|
+
/** true when this program can seal fragments as it sends them, as the SDK and the CLI do. Into a protected place the answer's shards is then set, and each part is a fragment. */
|
|
312
|
+
protectable?: boolean;
|
|
307
313
|
};
|
|
308
314
|
/** What POST /v1/uploads answers with. */
|
|
309
315
|
export type UploadsBeginAnswer = {
|
|
@@ -312,6 +318,15 @@ export type UploadsBeginAnswer = {
|
|
|
312
318
|
parts: Array<Part>;
|
|
313
319
|
/** For an upload begun with blocks: the parts not to send, because storage has them already, from the file this replaces, or because they repeat a block an earlier part sends. Empty otherwise. Nothing is taken from the file this replaces when the key may not read it, when the file has not been opened or changed in 90 days, or when copying it would take the account past 1000 GB copied this way in an hour: every block is sent then. */
|
|
314
320
|
reused: Array<number>;
|
|
321
|
+
/** Set when the upload is sealed as it is sent: how the file is cut into fragments. The key to seal them with is given by uploads.get. Null for an upload sent as it is. */
|
|
322
|
+
shards: {
|
|
323
|
+
/** The content's own id, which each fragment's seal is bound to with its place and the count. */
|
|
324
|
+
contentId: string;
|
|
325
|
+
/** How many bytes of the file each fragment holds, except the last, which holds what remains. */
|
|
326
|
+
fragmentBytes: number;
|
|
327
|
+
/** How many fragments, one to each part. */
|
|
328
|
+
count: number;
|
|
329
|
+
} | null;
|
|
315
330
|
};
|
|
316
331
|
/** What GET /v1/uploads/:id answers with. */
|
|
317
332
|
export type UploadsGetAnswer = {
|
|
@@ -325,6 +340,17 @@ export type UploadsGetAnswer = {
|
|
|
325
340
|
}> | null;
|
|
326
341
|
/** As in the answer that began the upload: the parts not to send. */
|
|
327
342
|
reused: Array<number>;
|
|
343
|
+
/** For an upload sealed as it is sent: how the file is cut, and the key to seal each fragment with. Null for an upload sent as it is, and for one that has ended. */
|
|
344
|
+
shards: {
|
|
345
|
+
/** The content's own id, which each fragment's seal is bound to with its place and the count. */
|
|
346
|
+
contentId: string;
|
|
347
|
+
/** The file's own key, in base64: 32 bytes, which open its fragments. In this answer and nowhere else. */
|
|
348
|
+
key: string;
|
|
349
|
+
/** How many bytes of the file each fragment holds, except the last, which holds what remains. */
|
|
350
|
+
fragmentBytes: number;
|
|
351
|
+
/** How many fragments, one to each part. */
|
|
352
|
+
count: number;
|
|
353
|
+
} | null;
|
|
328
354
|
};
|
|
329
355
|
/** What GET /v1/uploads/:id/parts takes in its query. */
|
|
330
356
|
export type UploadsPartsQuery = {
|
|
@@ -336,6 +362,20 @@ export type UploadsPartsQuery = {
|
|
|
336
362
|
export type UploadsPartsAnswer = {
|
|
337
363
|
parts: Array<Part>;
|
|
338
364
|
};
|
|
365
|
+
/** What POST /v1/uploads/:id/fragments takes as its body. */
|
|
366
|
+
export type UploadsFragmentsBody = {
|
|
367
|
+
/** The places of fragments that have been sent: up to 100 at a time. */
|
|
368
|
+
places: Array<number>;
|
|
369
|
+
};
|
|
370
|
+
/** What POST /v1/uploads/:id/fragments answers with. */
|
|
371
|
+
export type UploadsFragmentsAnswer = {
|
|
372
|
+
/** At their own names, the size their places hold, and recorded. Never send them again. */
|
|
373
|
+
recorded: Array<number>;
|
|
374
|
+
/** Still being copied, or being confirmed by another request. Ask again shortly. */
|
|
375
|
+
pending: Array<number>;
|
|
376
|
+
/** Not in storage, or not their size and removed. Seal each again, under a new nonce, and send and confirm it. */
|
|
377
|
+
resend: Array<number>;
|
|
378
|
+
};
|
|
339
379
|
/** What POST /v1/uploads/:id/complete takes as its body. */
|
|
340
380
|
export type UploadsCompleteBody = {
|
|
341
381
|
/** The file's SHA-256 in hex, as you worked it out. Kept with the file as your word, since this service never sees the bytes, and given back as clientSha256, so a copy can be checked later without reading the file again. */
|
|
@@ -642,6 +682,12 @@ export declare const OPERATIONS: {
|
|
|
642
682
|
readonly idempotent: false;
|
|
643
683
|
readonly repeatable: false;
|
|
644
684
|
};
|
|
685
|
+
readonly "uploads.fragments": {
|
|
686
|
+
readonly method: "POST";
|
|
687
|
+
readonly path: "/v1/uploads/:id/fragments";
|
|
688
|
+
readonly idempotent: false;
|
|
689
|
+
readonly repeatable: true;
|
|
690
|
+
};
|
|
645
691
|
readonly "uploads.complete": {
|
|
646
692
|
readonly method: "POST";
|
|
647
693
|
readonly path: "/v1/uploads/:id/complete";
|
|
@@ -803,6 +849,8 @@ export declare function bind(call: Call): {
|
|
|
803
849
|
*
|
|
804
850
|
* Any of the three, in one request. They are applied in that order, each as its own change: if the second is refused the first has still happened, and the answer says which was refused. Moving a folder moves everything in it and is one change however much that is. Making a folder public makes everything in it public, except what has a setting of its own.
|
|
805
851
|
*
|
|
852
|
+
* protection is a fourth, applied last: on puts the file or folder under Shard Protection, off takes it out, and inherit has it follow the folder it is in. A folder's setting reaches everything in it that has none of its own. Turning it on is refused while the account's plan does not include it (402 shard_protection_needs_pro); turning it off never is. Nothing is converted in this request: the service converts each file within a few minutes, and its protected says when. A key confined to a folder decides about what is in it, and not about the folder.
|
|
853
|
+
*
|
|
806
854
|
* PATCH /v1/files/:id. Needs the scope files:write.
|
|
807
855
|
*/
|
|
808
856
|
update: (id: string, body: FilesUpdateBody, options?: CallOptions) => Promise<FilesUpdateAnswer>;
|
|
@@ -889,7 +937,7 @@ export declare function bind(call: Call): {
|
|
|
889
937
|
*
|
|
890
938
|
* The room is set aside now, so an upload that would not fit is refused before a byte is sent. The parts can be sent by anything that can make a PUT request, including a browser that has never seen the API key: hand it the addresses.
|
|
891
939
|
*
|
|
892
|
-
* Into a
|
|
940
|
+
* Into a place under Shard Protection, an upload begun with protectable true is sealed as it is sent. A place is protected by the setting nearest it (files.update's protection), or with none by the drive's default: an app's, which its owner turns on from the app's page, or for a device the account's. The answer's shards then says how the file is cut, and uploads.get gives its key. Each part is a fragment: the file's bytes from its number times fragmentBytes, sealed with that key as the SDK's sealFragment does, under a nonce of its own, and sent to the part's address. uploads.fragments confirms them, and completing makes the file, protected from its first byte, with a null clientSha256. A sealed upload sets blocks aside: none is kept, and none reused. Begun without protectable, a file lands as an ordinary file and the service protects it within a few minutes; from then on it is read through its map (files.shards).
|
|
893
941
|
*
|
|
894
942
|
* POST /v1/uploads. Needs the scope files:write. Honours an Idempotency-Key.
|
|
895
943
|
*/
|
|
@@ -897,7 +945,7 @@ export declare function bind(call: Call): {
|
|
|
897
945
|
/**
|
|
898
946
|
* An upload, and which parts have arrived.
|
|
899
947
|
*
|
|
900
|
-
* For carrying on after an interruption: storage itself says which parts it has, so nothing the client remembered is needed.
|
|
948
|
+
* For carrying on after an interruption: storage itself says which parts it has, so nothing the client remembered is needed. For an upload sealed as it is sent, uploadedParts are the fragments confirmed and recorded, and shards gives the key, which this API keeps nowhere else. Confirm any fragment that may have been sent before sending it again. This answer is never kept, by this API or anything between it and the caller.
|
|
901
949
|
*
|
|
902
950
|
* GET /v1/uploads/:id. Needs the scope files:write.
|
|
903
951
|
*/
|
|
@@ -905,11 +953,19 @@ export declare function bind(call: Call): {
|
|
|
905
953
|
/**
|
|
906
954
|
* Addresses for more parts.
|
|
907
955
|
*
|
|
908
|
-
* Addresses are given out a window at a time and each lasts half an hour. Ask again for any that have run out.
|
|
956
|
+
* Addresses are given out a window at a time and each lasts half an hour. Ask again for any that have run out. For an upload sealed as it is sent, each number is a fragment's place, and its address takes the sealed fragment, with its Content-MD5. Confirm fragments with uploads.fragments once they are sent.
|
|
909
957
|
*
|
|
910
958
|
* GET /v1/uploads/:id/parts. Needs the scope files:write.
|
|
911
959
|
*/
|
|
912
960
|
parts: (id: string, query?: UploadsPartsQuery, options?: CallOptions) => Promise<UploadsPartsAnswer>;
|
|
961
|
+
/**
|
|
962
|
+
* Confirm sealed fragments.
|
|
963
|
+
*
|
|
964
|
+
* Confirms fragments of a sealed upload that have been sent. Each is copied inside storage to its own name, measured, and recorded once it is the size its place holds. recorded: done, never send it again. pending: storage is still copying it, or another confirmation of it is under way, so ask again shortly. resend: not there, or not its size and removed, so seal it again, under a new nonce, send it and confirm it. Safe to send again.
|
|
965
|
+
*
|
|
966
|
+
* POST /v1/uploads/:id/fragments. Needs the scope files:write. Safe to send again.
|
|
967
|
+
*/
|
|
968
|
+
fragments: (id: string, body: UploadsFragmentsBody, options?: CallOptions) => Promise<UploadsFragmentsAnswer>;
|
|
913
969
|
/**
|
|
914
970
|
* Finish an upload.
|
|
915
971
|
*
|
package/dist/generated.js
CHANGED
|
@@ -17,6 +17,7 @@ export const OPERATIONS = {
|
|
|
17
17
|
"uploads.begin": { method: "POST", path: "/v1/uploads", idempotent: true, repeatable: false },
|
|
18
18
|
"uploads.get": { method: "GET", path: "/v1/uploads/:id", idempotent: false, repeatable: false },
|
|
19
19
|
"uploads.parts": { method: "GET", path: "/v1/uploads/:id/parts", idempotent: false, repeatable: false },
|
|
20
|
+
"uploads.fragments": { method: "POST", path: "/v1/uploads/:id/fragments", idempotent: false, repeatable: true },
|
|
20
21
|
"uploads.complete": { method: "POST", path: "/v1/uploads/:id/complete", idempotent: false, repeatable: true },
|
|
21
22
|
"uploads.cancel": { method: "DELETE", path: "/v1/uploads/:id", idempotent: false, repeatable: false },
|
|
22
23
|
"files.downloadUrl": { method: "GET", path: "/v1/files/:id/download", idempotent: false, repeatable: false },
|
|
@@ -71,6 +72,8 @@ export function bind(call) {
|
|
|
71
72
|
*
|
|
72
73
|
* Any of the three, in one request. They are applied in that order, each as its own change: if the second is refused the first has still happened, and the answer says which was refused. Moving a folder moves everything in it and is one change however much that is. Making a folder public makes everything in it public, except what has a setting of its own.
|
|
73
74
|
*
|
|
75
|
+
* protection is a fourth, applied last: on puts the file or folder under Shard Protection, off takes it out, and inherit has it follow the folder it is in. A folder's setting reaches everything in it that has none of its own. Turning it on is refused while the account's plan does not include it (402 shard_protection_needs_pro); turning it off never is. Nothing is converted in this request: the service converts each file within a few minutes, and its protected says when. A key confined to a folder decides about what is in it, and not about the folder.
|
|
76
|
+
*
|
|
74
77
|
* PATCH /v1/files/:id. Needs the scope files:write.
|
|
75
78
|
*/
|
|
76
79
|
update: (id, body, options) => call("files.update", { params: { id }, body }, options),
|
|
@@ -157,7 +160,7 @@ export function bind(call) {
|
|
|
157
160
|
*
|
|
158
161
|
* The room is set aside now, so an upload that would not fit is refused before a byte is sent. The parts can be sent by anything that can make a PUT request, including a browser that has never seen the API key: hand it the addresses.
|
|
159
162
|
*
|
|
160
|
-
* Into a
|
|
163
|
+
* Into a place under Shard Protection, an upload begun with protectable true is sealed as it is sent. A place is protected by the setting nearest it (files.update's protection), or with none by the drive's default: an app's, which its owner turns on from the app's page, or for a device the account's. The answer's shards then says how the file is cut, and uploads.get gives its key. Each part is a fragment: the file's bytes from its number times fragmentBytes, sealed with that key as the SDK's sealFragment does, under a nonce of its own, and sent to the part's address. uploads.fragments confirms them, and completing makes the file, protected from its first byte, with a null clientSha256. A sealed upload sets blocks aside: none is kept, and none reused. Begun without protectable, a file lands as an ordinary file and the service protects it within a few minutes; from then on it is read through its map (files.shards).
|
|
161
164
|
*
|
|
162
165
|
* POST /v1/uploads. Needs the scope files:write. Honours an Idempotency-Key.
|
|
163
166
|
*/
|
|
@@ -165,7 +168,7 @@ export function bind(call) {
|
|
|
165
168
|
/**
|
|
166
169
|
* An upload, and which parts have arrived.
|
|
167
170
|
*
|
|
168
|
-
* For carrying on after an interruption: storage itself says which parts it has, so nothing the client remembered is needed.
|
|
171
|
+
* For carrying on after an interruption: storage itself says which parts it has, so nothing the client remembered is needed. For an upload sealed as it is sent, uploadedParts are the fragments confirmed and recorded, and shards gives the key, which this API keeps nowhere else. Confirm any fragment that may have been sent before sending it again. This answer is never kept, by this API or anything between it and the caller.
|
|
169
172
|
*
|
|
170
173
|
* GET /v1/uploads/:id. Needs the scope files:write.
|
|
171
174
|
*/
|
|
@@ -173,11 +176,19 @@ export function bind(call) {
|
|
|
173
176
|
/**
|
|
174
177
|
* Addresses for more parts.
|
|
175
178
|
*
|
|
176
|
-
* Addresses are given out a window at a time and each lasts half an hour. Ask again for any that have run out.
|
|
179
|
+
* Addresses are given out a window at a time and each lasts half an hour. Ask again for any that have run out. For an upload sealed as it is sent, each number is a fragment's place, and its address takes the sealed fragment, with its Content-MD5. Confirm fragments with uploads.fragments once they are sent.
|
|
177
180
|
*
|
|
178
181
|
* GET /v1/uploads/:id/parts. Needs the scope files:write.
|
|
179
182
|
*/
|
|
180
183
|
parts: (id, query, options) => call("uploads.parts", { params: { id }, query }, options),
|
|
184
|
+
/**
|
|
185
|
+
* Confirm sealed fragments.
|
|
186
|
+
*
|
|
187
|
+
* Confirms fragments of a sealed upload that have been sent. Each is copied inside storage to its own name, measured, and recorded once it is the size its place holds. recorded: done, never send it again. pending: storage is still copying it, or another confirmation of it is under way, so ask again shortly. resend: not there, or not its size and removed, so seal it again, under a new nonce, send it and confirm it. Safe to send again.
|
|
188
|
+
*
|
|
189
|
+
* POST /v1/uploads/:id/fragments. Needs the scope files:write. Safe to send again.
|
|
190
|
+
*/
|
|
191
|
+
fragments: (id, body, options) => call("uploads.fragments", { params: { id }, body }, options),
|
|
181
192
|
/**
|
|
182
193
|
* Finish an upload.
|
|
183
194
|
*
|
package/dist/shards.d.ts
CHANGED
|
@@ -43,14 +43,14 @@ export declare function shardKey(raw: Uint8Array): Promise<ShardKey>;
|
|
|
43
43
|
/** The bytes a fragment's tag also covers: the label, the content id, the place and the count. */
|
|
44
44
|
export declare function associatedData(at: FragmentAt): Uint8Array<ArrayBuffer>;
|
|
45
45
|
/** One fragment of a file, encrypted and ready to store, under a nonce of its own. */
|
|
46
|
-
export declare function sealFragment(key: ShardKey, at: FragmentAt, plaintext: Uint8Array): Promise<Uint8Array
|
|
46
|
+
export declare function sealFragment(key: ShardKey, at: FragmentAt, plaintext: Uint8Array): Promise<Uint8Array<ArrayBuffer>>;
|
|
47
47
|
/**
|
|
48
48
|
* sealFragment with the nonce chosen by the caller, which only the test
|
|
49
49
|
* files may do: two fragments sealed under one key with one nonce give away
|
|
50
50
|
* the difference between them, and the key's authentication with it. It is
|
|
51
51
|
* not exported from the package.
|
|
52
52
|
*/
|
|
53
|
-
export declare function sealWithNonce(key: ShardKey, at: FragmentAt, plaintext: Uint8Array, nonce: Uint8Array): Promise<Uint8Array
|
|
53
|
+
export declare function sealWithNonce(key: ShardKey, at: FragmentAt, plaintext: Uint8Array, nonce: Uint8Array): Promise<Uint8Array<ArrayBuffer>>;
|
|
54
54
|
/**
|
|
55
55
|
* A stored fragment's bytes of the file, once it is known to be this file's
|
|
56
56
|
* fragment at this place, unchanged. Nothing of a fragment that fails is
|
package/dist/uploads.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { FilesGetAnswer, Item, Upload, UploadsBeginAnswer, UploadsBeginBody, UploadsCompleteAnswer, UploadsCompleteBody, UploadsGetAnswer, UploadsPartsAnswer, UploadsPartsQuery } from "./generated.js";
|
|
1
|
+
import type { FilesGetAnswer, Item, Upload, UploadsBeginAnswer, UploadsBeginBody, UploadsCompleteAnswer, UploadsCompleteBody, UploadsFragmentsAnswer, UploadsFragmentsBody, UploadsGetAnswer, UploadsPartsAnswer, UploadsPartsQuery } from "./generated.js";
|
|
2
2
|
import { type CallOptions } from "./transport.js";
|
|
3
3
|
/** What can be uploaded. A File's name and type are used when none are given. */
|
|
4
4
|
export type Source = Blob | ArrayBuffer | Uint8Array;
|
|
@@ -45,6 +45,7 @@ export interface UploadCalls {
|
|
|
45
45
|
begin(body: UploadsBeginBody, options?: CallOptions): Promise<UploadsBeginAnswer>;
|
|
46
46
|
get(id: string, options?: CallOptions): Promise<UploadsGetAnswer>;
|
|
47
47
|
parts(id: string, query?: UploadsPartsQuery, options?: CallOptions): Promise<UploadsPartsAnswer>;
|
|
48
|
+
fragments(id: string, body: UploadsFragmentsBody, options?: CallOptions): Promise<UploadsFragmentsAnswer>;
|
|
48
49
|
complete(id: string, body?: UploadsCompleteBody, options?: CallOptions): Promise<UploadsCompleteAnswer>;
|
|
49
50
|
file(id: string, options?: CallOptions): Promise<FilesGetAnswer>;
|
|
50
51
|
}
|
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;
|
|
@@ -81,7 +131,8 @@ export async function upload(calls, fetcher, source, options = {}) {
|
|
|
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",
|