@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.
- package/README.md +189 -2
- package/dist/client.d.ts +78 -0
- package/dist/client.js +168 -0
- package/dist/generated.d.ts +1060 -0
- package/dist/generated.js +329 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +17 -0
- package/dist/links.d.ts +72 -0
- package/dist/links.js +355 -0
- package/dist/md5.d.ts +18 -0
- package/dist/md5.js +130 -0
- package/dist/node.d.ts +36 -0
- package/dist/node.js +124 -0
- package/dist/ranges.d.ts +34 -0
- package/dist/ranges.js +303 -0
- package/dist/reader.d.ts +76 -0
- package/dist/reader.js +510 -0
- package/dist/shards.d.ts +59 -0
- package/dist/shards.js +140 -0
- package/dist/transport.d.ts +61 -0
- package/dist/transport.js +173 -0
- package/dist/uploads.d.ts +53 -0
- package/dist/uploads.js +259 -0
- package/package.json +27 -4
package/dist/shards.js
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// The shard format, version 1: how a file under Shard Protection is kept, as
|
|
2
|
+
// fragments each encrypted on its own. docs/reference/shard-format.md is the
|
|
3
|
+
// definition, and docs/reference/shard-format-v1.json holds the test files
|
|
4
|
+
// this is checked against (test/shards.test.ts), as the CLI's copy is: a
|
|
5
|
+
// writer here whose bytes differ by one from the CLI's makes files the CLI
|
|
6
|
+
// cannot read, and the reverse.
|
|
7
|
+
//
|
|
8
|
+
// WebCrypto, so the same code runs in a page, in a worker and in Node.
|
|
9
|
+
export const SHARD_VERSION = 1;
|
|
10
|
+
/** What a stored fragment adds to the bytes of the file it holds: the version byte, the nonce and the tag. */
|
|
11
|
+
export const SHARD_OVERHEAD = 29;
|
|
12
|
+
const NONCE_BYTES = 12;
|
|
13
|
+
const HEADER_BYTES = 1 + NONCE_BYTES;
|
|
14
|
+
const LABEL = new TextEncoder().encode("filefreak-shard-v1");
|
|
15
|
+
/** The associated data numbers places and the count in 32 bits. */
|
|
16
|
+
const MOST_FRAGMENTS = 0xffff_ffff;
|
|
17
|
+
export class ShardError extends Error {
|
|
18
|
+
code;
|
|
19
|
+
constructor(code, message) {
|
|
20
|
+
super(message);
|
|
21
|
+
this.code = code;
|
|
22
|
+
this.name = "ShardError";
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
/** How a file of `size` bytes is cut into fragments of `fragmentSize`, which the API chooses. */
|
|
26
|
+
export class ShardLayout {
|
|
27
|
+
size;
|
|
28
|
+
fragmentSize;
|
|
29
|
+
count;
|
|
30
|
+
constructor(size, fragmentSize) {
|
|
31
|
+
this.size = size;
|
|
32
|
+
this.fragmentSize = fragmentSize;
|
|
33
|
+
if (!Number.isSafeInteger(size) || size < 0)
|
|
34
|
+
throw new ShardError("layout", `A file's size is a whole number of bytes, not ${size}.`);
|
|
35
|
+
if (!Number.isSafeInteger(fragmentSize) || fragmentSize < 1) {
|
|
36
|
+
throw new ShardError("layout", `A fragment holds at least one byte of the file, not ${fragmentSize}.`);
|
|
37
|
+
}
|
|
38
|
+
// BigInt, so a size past 2^32 is divided exactly; an empty file is one
|
|
39
|
+
// empty fragment, so that even it has something to authenticate.
|
|
40
|
+
const count = size === 0 ? 1 : Number((BigInt(size) + BigInt(fragmentSize) - 1n) / BigInt(fragmentSize));
|
|
41
|
+
if (count > MOST_FRAGMENTS) {
|
|
42
|
+
throw new ShardError("layout", `A file of ${size} bytes in fragments of ${fragmentSize} is ${count} fragments, more than the format can number.`);
|
|
43
|
+
}
|
|
44
|
+
this.count = count;
|
|
45
|
+
}
|
|
46
|
+
/** Where in the file fragment `place` begins, and where it ends (exclusive). */
|
|
47
|
+
range(place) {
|
|
48
|
+
if (!Number.isInteger(place) || place < 0 || place >= this.count) {
|
|
49
|
+
throw new RangeError(`There is no fragment ${place}: this file has ${this.count}, numbered from 0.`);
|
|
50
|
+
}
|
|
51
|
+
const start = place * this.fragmentSize;
|
|
52
|
+
return { start, end: Math.min(start + this.fragmentSize, this.size) };
|
|
53
|
+
}
|
|
54
|
+
/** How many bytes of the file fragment `place` holds. */
|
|
55
|
+
length(place) {
|
|
56
|
+
const { start, end } = this.range(place);
|
|
57
|
+
return end - start;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
/** A content's key, 32 bytes, made usable for its fragments and nothing else. */
|
|
61
|
+
export function shardKey(raw) {
|
|
62
|
+
if (raw.length !== 32)
|
|
63
|
+
throw new RangeError(`A shard key is 32 bytes, not ${raw.length}.`);
|
|
64
|
+
return crypto.subtle.importKey("raw", new Uint8Array(raw), { name: "AES-GCM" }, false, ["encrypt", "decrypt"]);
|
|
65
|
+
}
|
|
66
|
+
/** The bytes a fragment's tag also covers: the label, the content id, the place and the count. */
|
|
67
|
+
export function associatedData(at) {
|
|
68
|
+
const out = new Uint8Array(LABEL.length + 16 + 8);
|
|
69
|
+
out.set(LABEL, 0);
|
|
70
|
+
out.set(uuidBytes(at.contentId), LABEL.length);
|
|
71
|
+
const view = new DataView(out.buffer);
|
|
72
|
+
view.setUint32(LABEL.length + 16, at.place, false);
|
|
73
|
+
view.setUint32(LABEL.length + 20, at.layout.count, false);
|
|
74
|
+
return out;
|
|
75
|
+
}
|
|
76
|
+
/** One fragment of a file, encrypted and ready to store, under a nonce of its own. */
|
|
77
|
+
export function sealFragment(key, at, plaintext) {
|
|
78
|
+
return sealWithNonce(key, at, plaintext, crypto.getRandomValues(new Uint8Array(NONCE_BYTES)));
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* sealFragment with the nonce chosen by the caller, which only the test
|
|
82
|
+
* files may do: two fragments sealed under one key with one nonce give away
|
|
83
|
+
* the difference between them, and the key's authentication with it. It is
|
|
84
|
+
* not exported from the package.
|
|
85
|
+
*/
|
|
86
|
+
export async function sealWithNonce(key, at, plaintext, nonce) {
|
|
87
|
+
const expected = at.layout.length(at.place);
|
|
88
|
+
if (plaintext.length !== expected) {
|
|
89
|
+
throw new ShardError("length", `Fragment ${at.place} holds ${expected} bytes of the file, not ${plaintext.length}.`);
|
|
90
|
+
}
|
|
91
|
+
if (nonce.length !== NONCE_BYTES)
|
|
92
|
+
throw new RangeError(`A nonce is ${NONCE_BYTES} bytes, not ${nonce.length}.`);
|
|
93
|
+
const iv = new Uint8Array(nonce);
|
|
94
|
+
const sealed = await crypto.subtle.encrypt({ name: "AES-GCM", iv, additionalData: associatedData(at), tagLength: 128 }, key, new Uint8Array(plaintext));
|
|
95
|
+
const out = new Uint8Array(HEADER_BYTES + sealed.byteLength);
|
|
96
|
+
out[0] = SHARD_VERSION;
|
|
97
|
+
out.set(iv, 1);
|
|
98
|
+
out.set(new Uint8Array(sealed), HEADER_BYTES);
|
|
99
|
+
return out;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* A stored fragment's bytes of the file, once it is known to be this file's
|
|
103
|
+
* fragment at this place, unchanged. Nothing of a fragment that fails is
|
|
104
|
+
* returned: WebCrypto gives no plaintext until the tag has been checked.
|
|
105
|
+
*/
|
|
106
|
+
export async function openFragment(key, at, blob) {
|
|
107
|
+
const expected = at.layout.length(at.place);
|
|
108
|
+
if (blob.length < SHARD_OVERHEAD) {
|
|
109
|
+
throw new ShardError("short", `Fragment ${at.place} is ${blob.length} bytes, shorter than any fragment can be (${SHARD_OVERHEAD}).`);
|
|
110
|
+
}
|
|
111
|
+
if (blob[0] !== SHARD_VERSION) {
|
|
112
|
+
throw new ShardError("version", `Fragment ${at.place} is in version ${blob[0]} of the format, which this version of the SDK cannot read.`);
|
|
113
|
+
}
|
|
114
|
+
if (blob.length !== expected + SHARD_OVERHEAD) {
|
|
115
|
+
throw new ShardError("length", `Fragment ${at.place} is ${blob.length} bytes, and holding ${expected} bytes of the file it would be ${expected + SHARD_OVERHEAD}.`);
|
|
116
|
+
}
|
|
117
|
+
let plain;
|
|
118
|
+
try {
|
|
119
|
+
plain = await crypto.subtle.decrypt({ name: "AES-GCM", iv: new Uint8Array(blob.subarray(1, HEADER_BYTES)), additionalData: associatedData(at), tagLength: 128 }, key, new Uint8Array(blob.subarray(HEADER_BYTES)));
|
|
120
|
+
}
|
|
121
|
+
catch (e) {
|
|
122
|
+
// OperationError is WebCrypto's one answer to a tag that does not match.
|
|
123
|
+
// Anything else (a key made for something else) is not the fragment's fault.
|
|
124
|
+
if (e instanceof DOMException && e.name === "OperationError") {
|
|
125
|
+
throw new ShardError("authentication", `Fragment ${at.place} is not what was written as this file's fragment ${at.place}: it has been changed, or it belongs to another file or place.`);
|
|
126
|
+
}
|
|
127
|
+
throw e;
|
|
128
|
+
}
|
|
129
|
+
return new Uint8Array(plain);
|
|
130
|
+
}
|
|
131
|
+
function uuidBytes(id) {
|
|
132
|
+
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id)) {
|
|
133
|
+
throw new RangeError(`A content id is a UUID, and ${JSON.stringify(id)} is not one.`);
|
|
134
|
+
}
|
|
135
|
+
const hex = id.replaceAll("-", "");
|
|
136
|
+
const out = new Uint8Array(16);
|
|
137
|
+
for (let i = 0; i < 16; i++)
|
|
138
|
+
out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
|
|
139
|
+
return out;
|
|
140
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/** A refusal from the API, or a request that could not be answered at all. Branch on `code`, which does not change; `message` is for a person. */
|
|
2
|
+
export declare class FileFreakError extends Error {
|
|
3
|
+
readonly status: number;
|
|
4
|
+
readonly code: string;
|
|
5
|
+
readonly body: Record<string, unknown> | null;
|
|
6
|
+
readonly retryAfterSeconds: number | null;
|
|
7
|
+
constructor(status: number, code: string, message: string, body?: Record<string, unknown> | null, retryAfterSeconds?: number | null);
|
|
8
|
+
}
|
|
9
|
+
/** A token as a string, or a function that fetches a fresh one from the developer's own server. */
|
|
10
|
+
export type TokenSource = string | (() => Promise<string>);
|
|
11
|
+
export interface ClientOptions {
|
|
12
|
+
/** An app's key, for a program on a server. Never put one in a web page: the API refuses a key sent from one. */
|
|
13
|
+
apiKey?: string;
|
|
14
|
+
/** For a web page: a token the developer's server made with POST /v1/tokens, or a function that asks the server for one. */
|
|
15
|
+
token?: TokenSource;
|
|
16
|
+
/** Where the API is. */
|
|
17
|
+
baseUrl?: string;
|
|
18
|
+
/** Another fetch, for a runtime without one or for tests. */
|
|
19
|
+
fetch?: typeof fetch;
|
|
20
|
+
/** How many times a request that failed for a passing reason is sent again. */
|
|
21
|
+
retries?: number;
|
|
22
|
+
}
|
|
23
|
+
export interface CallOptions {
|
|
24
|
+
signal?: AbortSignal;
|
|
25
|
+
/** For an endpoint that makes something: the caller's own key, so that the same request sent later, even from another process, makes nothing twice. */
|
|
26
|
+
idempotencyKey?: string;
|
|
27
|
+
}
|
|
28
|
+
export interface Request {
|
|
29
|
+
method: "GET" | "POST" | "PATCH" | "DELETE";
|
|
30
|
+
path: string;
|
|
31
|
+
query?: Record<string, unknown> | undefined;
|
|
32
|
+
body?: unknown;
|
|
33
|
+
/** The endpoint honours an Idempotency-Key. */
|
|
34
|
+
idempotent?: boolean;
|
|
35
|
+
/** The endpoint says it is safe to call again (finishing an upload). */
|
|
36
|
+
repeatable?: boolean;
|
|
37
|
+
options?: CallOptions;
|
|
38
|
+
}
|
|
39
|
+
export interface Response<T> {
|
|
40
|
+
status: number;
|
|
41
|
+
body: T;
|
|
42
|
+
headers: Headers;
|
|
43
|
+
}
|
|
44
|
+
export declare const DEFAULT_BASE_URL = "https://api.filefreak.io";
|
|
45
|
+
/** Waits, or stops waiting when the signal is aborted. */
|
|
46
|
+
export declare function sleep(ms: number, signal?: AbortSignal): Promise<void>;
|
|
47
|
+
/** A wait that doubles with each try, with some spread so that many clients do not come back at once. */
|
|
48
|
+
export declare function backoff(attempt: number): number;
|
|
49
|
+
export declare class Transport {
|
|
50
|
+
readonly baseUrl: string;
|
|
51
|
+
private readonly fetcher;
|
|
52
|
+
private readonly retries;
|
|
53
|
+
private readonly apiKey;
|
|
54
|
+
private readonly tokenSource;
|
|
55
|
+
private token;
|
|
56
|
+
constructor(options: ClientOptions);
|
|
57
|
+
/** Another fetch, for reaching storage with the same retries and runtime. */
|
|
58
|
+
get fetch(): typeof fetch;
|
|
59
|
+
private credential;
|
|
60
|
+
send<T>(request: Request): Promise<Response<T>>;
|
|
61
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
// How every call reaches the API: the address, the key or token, and what is
|
|
2
|
+
// done when an answer is not the one wanted.
|
|
3
|
+
//
|
|
4
|
+
// Sending a request again is where a client makes something twice, so the
|
|
5
|
+
// rule is narrow. A request is sent again only when that cannot do anything
|
|
6
|
+
// twice: a read; a request carrying an Idempotency-Key, which the API answers
|
|
7
|
+
// once (every endpoint that makes something takes one, and one is made here
|
|
8
|
+
// when the caller gave none); or an answer that says the API did nothing,
|
|
9
|
+
// 429 (too many) and 503 waking_up (the database is starting). Retry-After is
|
|
10
|
+
// honoured.
|
|
11
|
+
/** A refusal from the API, or a request that could not be answered at all. Branch on `code`, which does not change; `message` is for a person. */
|
|
12
|
+
export class FileFreakError extends Error {
|
|
13
|
+
status;
|
|
14
|
+
code;
|
|
15
|
+
body;
|
|
16
|
+
retryAfterSeconds;
|
|
17
|
+
constructor(status, code, message, body = null, retryAfterSeconds = null) {
|
|
18
|
+
super(message);
|
|
19
|
+
this.status = status;
|
|
20
|
+
this.code = code;
|
|
21
|
+
this.body = body;
|
|
22
|
+
this.retryAfterSeconds = retryAfterSeconds;
|
|
23
|
+
this.name = "FileFreakError";
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
export const DEFAULT_BASE_URL = "https://api.filefreak.io";
|
|
27
|
+
const DEFAULT_RETRIES = 4;
|
|
28
|
+
/** The longest wait between tries, whatever Retry-After says. */
|
|
29
|
+
const MOST_WAIT_MS = 60_000;
|
|
30
|
+
/** A sleeping database takes about a minute to start. Five minutes of asking is plenty. */
|
|
31
|
+
const WAKING_TRIES = 60;
|
|
32
|
+
/** Waits, or stops waiting when the signal is aborted. */
|
|
33
|
+
export function sleep(ms, signal) {
|
|
34
|
+
return new Promise((resolve, reject) => {
|
|
35
|
+
if (signal?.aborted)
|
|
36
|
+
return reject(signal.reason);
|
|
37
|
+
const timer = setTimeout(done, ms);
|
|
38
|
+
function done() {
|
|
39
|
+
signal?.removeEventListener("abort", stop);
|
|
40
|
+
resolve();
|
|
41
|
+
}
|
|
42
|
+
function stop() {
|
|
43
|
+
clearTimeout(timer);
|
|
44
|
+
reject(signal.reason);
|
|
45
|
+
}
|
|
46
|
+
signal?.addEventListener("abort", stop, { once: true });
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
/** A wait that doubles with each try, with some spread so that many clients do not come back at once. */
|
|
50
|
+
export function backoff(attempt) {
|
|
51
|
+
return Math.min(MOST_WAIT_MS, 500 * 2 ** attempt) * (0.5 + Math.random() / 2);
|
|
52
|
+
}
|
|
53
|
+
function randomKey() {
|
|
54
|
+
const bytes = new Uint8Array(16);
|
|
55
|
+
crypto.getRandomValues(bytes);
|
|
56
|
+
return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
|
|
57
|
+
}
|
|
58
|
+
function search(query) {
|
|
59
|
+
if (!query)
|
|
60
|
+
return "";
|
|
61
|
+
const params = new URLSearchParams();
|
|
62
|
+
for (const [name, value] of Object.entries(query)) {
|
|
63
|
+
if (value === undefined || value === null)
|
|
64
|
+
continue;
|
|
65
|
+
params.set(name, String(value));
|
|
66
|
+
}
|
|
67
|
+
const text = params.toString();
|
|
68
|
+
return text ? `?${text}` : "";
|
|
69
|
+
}
|
|
70
|
+
export class Transport {
|
|
71
|
+
baseUrl;
|
|
72
|
+
fetcher;
|
|
73
|
+
retries;
|
|
74
|
+
apiKey;
|
|
75
|
+
tokenSource;
|
|
76
|
+
token = null;
|
|
77
|
+
constructor(options) {
|
|
78
|
+
if (!options.apiKey === !options.token)
|
|
79
|
+
throw new Error("Give the client an apiKey (on a server) or a token (in a web page), and not both.");
|
|
80
|
+
this.apiKey = options.apiKey ?? null;
|
|
81
|
+
this.tokenSource = options.token ?? null;
|
|
82
|
+
this.baseUrl = (options.baseUrl || DEFAULT_BASE_URL).replace(/\/+$/, "");
|
|
83
|
+
// Bound, because a fetch taken off globalThis and called on its own loses `this` in some browsers.
|
|
84
|
+
this.fetcher = options.fetch ?? fetch.bind(globalThis);
|
|
85
|
+
this.retries = options.retries ?? DEFAULT_RETRIES;
|
|
86
|
+
}
|
|
87
|
+
/** Another fetch, for reaching storage with the same retries and runtime. */
|
|
88
|
+
get fetch() {
|
|
89
|
+
return this.fetcher;
|
|
90
|
+
}
|
|
91
|
+
async credential(fresh) {
|
|
92
|
+
if (this.apiKey)
|
|
93
|
+
return this.apiKey;
|
|
94
|
+
const source = this.tokenSource;
|
|
95
|
+
if (typeof source === "string")
|
|
96
|
+
return source;
|
|
97
|
+
if (fresh || !this.token) {
|
|
98
|
+
this.token = source();
|
|
99
|
+
// A failed fetch of a token is not kept, so the next call asks again.
|
|
100
|
+
this.token.catch(() => (this.token = null));
|
|
101
|
+
}
|
|
102
|
+
return await this.token;
|
|
103
|
+
}
|
|
104
|
+
async send(request) {
|
|
105
|
+
const options = request.options ?? {};
|
|
106
|
+
const key = request.idempotent ? options.idempotencyKey || randomKey() : undefined;
|
|
107
|
+
const safe = request.method === "GET" || key !== undefined || request.repeatable === true;
|
|
108
|
+
const url = `${this.baseUrl}${request.path}${search(request.query)}`;
|
|
109
|
+
let refreshed = false;
|
|
110
|
+
let waking = 0;
|
|
111
|
+
for (let attempt = 0;; attempt++) {
|
|
112
|
+
const headers = { Authorization: `Bearer ${await this.credential(false)}`, Accept: "application/json" };
|
|
113
|
+
if (key)
|
|
114
|
+
headers["Idempotency-Key"] = key;
|
|
115
|
+
if (request.body !== undefined)
|
|
116
|
+
headers["Content-Type"] = "application/json";
|
|
117
|
+
let res;
|
|
118
|
+
try {
|
|
119
|
+
res = await this.fetcher(url, {
|
|
120
|
+
method: request.method,
|
|
121
|
+
headers,
|
|
122
|
+
body: request.body === undefined ? undefined : JSON.stringify(request.body),
|
|
123
|
+
signal: options.signal,
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
catch (e) {
|
|
127
|
+
if (options.signal?.aborted)
|
|
128
|
+
throw options.signal.reason;
|
|
129
|
+
// Nothing came back. Whether the API did anything is unknown, so only
|
|
130
|
+
// a request that cannot do anything twice is sent again.
|
|
131
|
+
if (!safe || attempt >= this.retries)
|
|
132
|
+
throw new FileFreakError(0, "network_error", `The API could not be reached: ${e instanceof Error ? e.message : String(e)}`);
|
|
133
|
+
await sleep(backoff(attempt), options.signal);
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
const text = await res.text();
|
|
137
|
+
let body = null;
|
|
138
|
+
try {
|
|
139
|
+
body = text ? JSON.parse(text) : null;
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
body = text;
|
|
143
|
+
}
|
|
144
|
+
if (res.ok)
|
|
145
|
+
return { status: res.status, body: body, headers: res.headers };
|
|
146
|
+
const error = body && typeof body === "object" ? body : null;
|
|
147
|
+
const code = typeof error?.error === "string" ? error.error : `http_${res.status}`;
|
|
148
|
+
const message = typeof error?.message === "string" ? error.message : `The API answered ${res.status}.`;
|
|
149
|
+
// Not Number(header) alone: a missing header is null, and Number(null) is 0.
|
|
150
|
+
const header = res.headers.get("retry-after");
|
|
151
|
+
const after = header === null ? NaN : Number(header);
|
|
152
|
+
const retryAfter = Number.isFinite(after) && after >= 0 ? after : null;
|
|
153
|
+
const refusal = new FileFreakError(res.status, code, message, error, retryAfter);
|
|
154
|
+
if (res.status === 401 && code === "token_expired" && typeof this.tokenSource === "function" && !refreshed) {
|
|
155
|
+
refreshed = true;
|
|
156
|
+
await this.credential(true);
|
|
157
|
+
attempt--;
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
if (res.status === 503 && code === "waking_up" && waking < WAKING_TRIES) {
|
|
161
|
+
waking++;
|
|
162
|
+
attempt--;
|
|
163
|
+
await sleep(Math.min(MOST_WAIT_MS, (retryAfter ?? 5) * 1000), options.signal);
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
// Refused before anything was done, or a server between here and the API that failed.
|
|
167
|
+
const passing = res.status === 429 || (safe && (res.status === 502 || res.status === 503 || res.status === 504));
|
|
168
|
+
if (!passing || attempt >= this.retries)
|
|
169
|
+
throw refusal;
|
|
170
|
+
await sleep(retryAfter !== null ? Math.min(MOST_WAIT_MS, retryAfter * 1000) : backoff(attempt), options.signal);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { FilesGetAnswer, Item, Upload, UploadsBeginAnswer, UploadsBeginBody, UploadsCompleteAnswer, UploadsCompleteBody, UploadsGetAnswer, UploadsPartsAnswer, UploadsPartsQuery } from "./generated.js";
|
|
2
|
+
import { type CallOptions } from "./transport.js";
|
|
3
|
+
/** What can be uploaded. A File's name and type are used when none are given. */
|
|
4
|
+
export type Source = Blob | ArrayBuffer | Uint8Array;
|
|
5
|
+
export interface UploadOptions {
|
|
6
|
+
/** The name to store it under. Left out, a File's own name. */
|
|
7
|
+
name?: string;
|
|
8
|
+
/** The folder to put it in. Null, or left out, for the top. */
|
|
9
|
+
parentId?: string | null;
|
|
10
|
+
/** What to do if the folder already has a file of that name. */
|
|
11
|
+
onConflict?: "rename" | "replace" | "fail";
|
|
12
|
+
/** With onConflict "replace": the file as it was read. Anything else there, or this file at another revision, refuses the upload with 409 revision_changed, so a copy kept in step never writes over a change it has not seen. */
|
|
13
|
+
replacing?: {
|
|
14
|
+
id: string;
|
|
15
|
+
revision: number;
|
|
16
|
+
};
|
|
17
|
+
/** Left out, a Blob's own type, else worked out from the name. */
|
|
18
|
+
contentType?: string;
|
|
19
|
+
/** Smaller copies to make of an image as soon as it is stored. */
|
|
20
|
+
imageSizes?: number[];
|
|
21
|
+
/** Bytes in each part, from 8 MiB to 64 MiB. Left out, 32 MiB, or less for a small file. */
|
|
22
|
+
partBytes?: number;
|
|
23
|
+
/** Parts sent at once. */
|
|
24
|
+
concurrency?: number;
|
|
25
|
+
/** The file's SHA-256 in lower-case hex, if it is known. Kept with the file as clientSha256. */
|
|
26
|
+
sha256?: string;
|
|
27
|
+
/** The id of an upload that stopped part way: only what storage does not have is sent. */
|
|
28
|
+
resume?: string;
|
|
29
|
+
/** Called once the upload exists, before any part is sent. Keep its id to carry on after a crash. */
|
|
30
|
+
onBegun?: (upload: Upload) => void;
|
|
31
|
+
/** Called as parts arrive, with the bytes storage has. */
|
|
32
|
+
onProgress?: (sentBytes: number, totalBytes: number) => void;
|
|
33
|
+
/** Stops the upload. What reached storage is kept, to carry on with `resume`. */
|
|
34
|
+
signal?: AbortSignal;
|
|
35
|
+
/** For beginning: the caller's own key, so that a begin sent again makes one upload. */
|
|
36
|
+
idempotencyKey?: string;
|
|
37
|
+
}
|
|
38
|
+
/** An upload that stopped part way. What arrived stays in storage until the upload expires: carry on with `resume: uploadId`, or cancel it. */
|
|
39
|
+
export declare class UploadStopped extends Error {
|
|
40
|
+
readonly uploadId: string;
|
|
41
|
+
constructor(uploadId: string, cause: unknown);
|
|
42
|
+
}
|
|
43
|
+
/** The calls an upload makes: the client's own, so that they are retried as any other call is. */
|
|
44
|
+
export interface UploadCalls {
|
|
45
|
+
begin(body: UploadsBeginBody, options?: CallOptions): Promise<UploadsBeginAnswer>;
|
|
46
|
+
get(id: string, options?: CallOptions): Promise<UploadsGetAnswer>;
|
|
47
|
+
parts(id: string, query?: UploadsPartsQuery, options?: CallOptions): Promise<UploadsPartsAnswer>;
|
|
48
|
+
complete(id: string, body?: UploadsCompleteBody, options?: CallOptions): Promise<UploadsCompleteAnswer>;
|
|
49
|
+
file(id: string, options?: CallOptions): Promise<FilesGetAnswer>;
|
|
50
|
+
}
|
|
51
|
+
/** The part size to ask for: 32 MiB, halved while that would leave fewer parts than are sent at once. */
|
|
52
|
+
export declare function partSizeFor(fileBytes: number, atOnce: number): number;
|
|
53
|
+
export declare function upload(calls: UploadCalls, fetcher: typeof fetch, source: Source, options?: UploadOptions): Promise<Item>;
|