@filefreak.io/sdk 0.0.0-stage → 0.1.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 +153 -2
- package/dist/client.d.ts +76 -0
- package/dist/client.js +164 -0
- package/dist/generated.d.ts +1005 -0
- package/dist/generated.js +318 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +12 -0
- package/dist/md5.d.ts +18 -0
- package/dist/md5.js +130 -0
- package/dist/node.d.ts +16 -0
- package/dist/node.js +51 -0
- package/dist/ranges.d.ts +34 -0
- package/dist/ranges.js +303 -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/README.md
CHANGED
|
@@ -1,3 +1,154 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @filefreak.io/sdk
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The FileFreak developer API, from TypeScript or JavaScript, in Node 20 and
|
|
4
|
+
later and in browsers. No dependencies.
|
|
5
|
+
|
|
6
|
+
Every endpoint is a function, typed from the same table the API is built
|
|
7
|
+
from. Around them: uploads sent straight to storage in parts and carried on
|
|
8
|
+
after an interruption, downloads fetched in ranges, listings and changes read
|
|
9
|
+
to the end, and requests sent again only when that cannot make anything
|
|
10
|
+
twice. The reference is at https://filefreak.io/developers/reference.
|
|
11
|
+
|
|
12
|
+
## On a server
|
|
13
|
+
|
|
14
|
+
```ts
|
|
15
|
+
import { FileFreak } from "@filefreak.io/sdk";
|
|
16
|
+
import { fromPath, toPath } from "@filefreak.io/sdk/node";
|
|
17
|
+
|
|
18
|
+
const ff = new FileFreak({ apiKey: process.env.FILEFREAK_KEY });
|
|
19
|
+
|
|
20
|
+
const { item: folder } = await ff.folders.create({ name: "uploads", ifExists: "use" });
|
|
21
|
+
const file = await ff.upload(await fromPath("film.mp4"), {
|
|
22
|
+
parentId: folder.id,
|
|
23
|
+
onProgress: (sent, total) => console.log(`${Math.round((sent / total) * 100)}%`),
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
await ff.download(file.id, await toPath("copy.mp4"));
|
|
27
|
+
const bytes = await ff.bytes(file.id); // for a file small enough to hold in memory
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
`fromPath` reads the file a part at a time as it is sent, so a file larger
|
|
31
|
+
than memory is fine. `toPath` writes beside the file and gives it its name
|
|
32
|
+
only once every byte is there: a download that fails leaves nothing behind.
|
|
33
|
+
|
|
34
|
+
Keep keys on a server. A key sent from a web page is refused.
|
|
35
|
+
|
|
36
|
+
## In a web page
|
|
37
|
+
|
|
38
|
+
A page is given a token by your server instead. Your server makes it with its
|
|
39
|
+
key:
|
|
40
|
+
|
|
41
|
+
```ts
|
|
42
|
+
// On your server, behind your own sign-in.
|
|
43
|
+
const { token } = await ff.tokens.create({
|
|
44
|
+
scopes: ["files:read", "files:write"],
|
|
45
|
+
folderId: customerFolderId,
|
|
46
|
+
expiresInSeconds: 900,
|
|
47
|
+
});
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
and the page uses it as it would a key. Given a function rather than a
|
|
51
|
+
string, the SDK asks your server for a new token when the last has run out:
|
|
52
|
+
|
|
53
|
+
```ts
|
|
54
|
+
import { FileFreak } from "@filefreak.io/sdk";
|
|
55
|
+
|
|
56
|
+
const ff = new FileFreak({
|
|
57
|
+
token: async () => (await fetch("/my-server/filefreak-token")).text(),
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
const input = document.querySelector<HTMLInputElement>("input[type=file]")!;
|
|
61
|
+
const file = await ff.upload(input.files![0]!); // into the token's folder
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
A token may do what its key may, or less: the scopes asked for, inside the
|
|
65
|
+
folder asked for, for a minute to an hour. It is checked against its key on
|
|
66
|
+
every request, so revoking the key stops its tokens at once. A token cannot
|
|
67
|
+
make another.
|
|
68
|
+
|
|
69
|
+
## Every endpoint
|
|
70
|
+
|
|
71
|
+
The functions are grouped as the reference groups the endpoints:
|
|
72
|
+
`ff.files`, `ff.folders`, `ff.tree`, `ff.changes`, `ff.uploads`, `ff.usage`,
|
|
73
|
+
`ff.me`, `ff.tokens` and `ff.shares` (share links, which only a device signed
|
|
74
|
+
in with `ff login` may make). Ids in the address come first, then the body or
|
|
75
|
+
the query, then options:
|
|
76
|
+
|
|
77
|
+
```ts
|
|
78
|
+
const { items, nextCursor } = await ff.files.list({ parent: folder.id, limit: 50 });
|
|
79
|
+
await ff.files.update(file.id, { name: "renamed.mp4" });
|
|
80
|
+
await ff.files.delete(file.id, { permanent: true });
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
`ff.call("files.get", { params: { id } })` reaches any endpoint by its name.
|
|
84
|
+
|
|
85
|
+
## Reading to the end
|
|
86
|
+
|
|
87
|
+
```ts
|
|
88
|
+
for await (const item of ff.files.each({ parent: folder.id })) console.log(item.name);
|
|
89
|
+
|
|
90
|
+
// Everything under a folder, then only what changes.
|
|
91
|
+
const walk = ff.tree.walk({ parent: folder.id });
|
|
92
|
+
for await (const item of walk) keep(item);
|
|
93
|
+
for await (const answer of ff.changes.follow(walk.changesCursor)) {
|
|
94
|
+
for (const change of answer.changes) apply(change);
|
|
95
|
+
save(answer.cursor); // to carry on from after a restart
|
|
96
|
+
}
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
`follow` waits on the API for the next change, a minute at a time, and goes
|
|
100
|
+
on until the loop is left or its `signal` is aborted.
|
|
101
|
+
|
|
102
|
+
A copy that also sends its own changes back names the file as it read it,
|
|
103
|
+
so that it never writes over, or deletes, a change it has not seen. Either
|
|
104
|
+
is refused with 409 `revision_changed` if the file has moved on:
|
|
105
|
+
|
|
106
|
+
```ts
|
|
107
|
+
await ff.upload(source, { name: file.name, parentId: file.parentId, onConflict: "replace", replacing: { id: file.id, revision: file.revision } });
|
|
108
|
+
await ff.files.delete(file.id, { ifRevision: file.revision });
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
## When an upload stops
|
|
112
|
+
|
|
113
|
+
What reached storage is kept until the upload expires. Keep the upload's id
|
|
114
|
+
from `onBegun`, and carry on with the same file: only the parts storage does
|
|
115
|
+
not have are sent.
|
|
116
|
+
|
|
117
|
+
```ts
|
|
118
|
+
import { UploadStopped } from "@filefreak.io/sdk";
|
|
119
|
+
|
|
120
|
+
try {
|
|
121
|
+
await ff.upload(source, { name: "big.iso", onBegun: (u) => remember(u.id) });
|
|
122
|
+
} catch (e) {
|
|
123
|
+
if (e instanceof UploadStopped) await ff.upload(source, { resume: e.uploadId });
|
|
124
|
+
else throw e;
|
|
125
|
+
}
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
## When something is refused
|
|
129
|
+
|
|
130
|
+
A refusal is a `FileFreakError`, with the API's `status`, a `code` that does
|
|
131
|
+
not change and is what to branch on, and a `message` for a person.
|
|
132
|
+
|
|
133
|
+
```ts
|
|
134
|
+
import { FileFreakError } from "@filefreak.io/sdk";
|
|
135
|
+
|
|
136
|
+
try {
|
|
137
|
+
await ff.folders.create({ name: "reports" });
|
|
138
|
+
} catch (e) {
|
|
139
|
+
if (e instanceof FileFreakError && e.code === "name_taken") {
|
|
140
|
+
// already there
|
|
141
|
+
} else throw e;
|
|
142
|
+
}
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
The SDK sends a request again only when that cannot make anything twice: a
|
|
146
|
+
read; a request with an Idempotency-Key, which it makes for every endpoint
|
|
147
|
+
that honours one unless you give your own (`{ idempotencyKey }` in the
|
|
148
|
+
options); completing an upload or making a token; or an answer saying nothing
|
|
149
|
+
was done, such as 429. While the service is waking up after a quiet spell it
|
|
150
|
+
waits, for up to a few minutes. `Retry-After` is honoured.
|
|
151
|
+
|
|
152
|
+
## Licence
|
|
153
|
+
|
|
154
|
+
Apache-2.0.
|
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { bind, type ChangesListAnswer, type FilesListQuery, type Input, type Item, type OperationName, type TreeListQuery } from "./generated.js";
|
|
2
|
+
import { type Sink } from "./ranges.js";
|
|
3
|
+
import { type CallOptions, type ClientOptions } from "./transport.js";
|
|
4
|
+
import { type Source, type UploadOptions } from "./uploads.js";
|
|
5
|
+
type Api = ReturnType<typeof bind>;
|
|
6
|
+
export interface DownloadOptions {
|
|
7
|
+
/** Stops the download, and aborts the sink. */
|
|
8
|
+
signal?: AbortSignal;
|
|
9
|
+
/** Called as bytes are written, with how many have been and the file's size. */
|
|
10
|
+
onProgress?: (writtenBytes: number, totalBytes: number) => void;
|
|
11
|
+
/** At most this many ranges at once, up to six. */
|
|
12
|
+
ranges?: number;
|
|
13
|
+
}
|
|
14
|
+
export interface FollowOptions extends CallOptions {
|
|
15
|
+
/** Seconds each request waits for a change before asking again, 0 to 200. */
|
|
16
|
+
wait?: number;
|
|
17
|
+
/** At most this many changes in one answer. */
|
|
18
|
+
limit?: number;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Everything under a folder, read to the end a page at a time. Once the first
|
|
22
|
+
* page has arrived, changesCursor holds the place in the changes from before
|
|
23
|
+
* the walk began: follow the changes from there after the last item, and
|
|
24
|
+
* nothing that changed during the walk is missed.
|
|
25
|
+
*/
|
|
26
|
+
export declare class Walk implements AsyncIterable<Item> {
|
|
27
|
+
private readonly list;
|
|
28
|
+
private readonly query;
|
|
29
|
+
private readonly options;
|
|
30
|
+
changesCursor: string | null;
|
|
31
|
+
constructor(list: Api["tree"]["list"], query: Omit<TreeListQuery, "cursor">, options: CallOptions | undefined);
|
|
32
|
+
[Symbol.asyncIterator](): AsyncGenerator<Item>;
|
|
33
|
+
}
|
|
34
|
+
export declare class FileFreak {
|
|
35
|
+
private readonly transport;
|
|
36
|
+
readonly files: Api["files"] & {
|
|
37
|
+
/** Everything in a folder, read to the end a page at a time. */
|
|
38
|
+
each(query?: Omit<FilesListQuery, "cursor">, options?: CallOptions): AsyncIterable<Item>;
|
|
39
|
+
};
|
|
40
|
+
readonly folders: Api["folders"];
|
|
41
|
+
readonly tree: Api["tree"] & {
|
|
42
|
+
/** Everything under a folder, read to the end. See Walk. */
|
|
43
|
+
walk(query?: Omit<TreeListQuery, "cursor">, options?: CallOptions): Walk;
|
|
44
|
+
};
|
|
45
|
+
readonly changes: Api["changes"] & {
|
|
46
|
+
/**
|
|
47
|
+
* The changes after `cursor` (or after now, without one), for as long as
|
|
48
|
+
* the loop reading them goes on: each answer that has any, oldest first.
|
|
49
|
+
* Apply its changes, then keep its cursor, to carry on from after a
|
|
50
|
+
* restart. Stopped by options.signal, or by leaving the loop.
|
|
51
|
+
*/
|
|
52
|
+
follow(cursor?: string | null, options?: FollowOptions): AsyncIterable<ChangesListAnswer>;
|
|
53
|
+
};
|
|
54
|
+
readonly uploads: Api["uploads"];
|
|
55
|
+
readonly usage: Api["usage"];
|
|
56
|
+
readonly tokens: Api["tokens"];
|
|
57
|
+
constructor(options: ClientOptions);
|
|
58
|
+
/** Any endpoint, by its operation name. The functions above are this, typed. */
|
|
59
|
+
call(operation: OperationName, input?: Input, options?: CallOptions): Promise<unknown>;
|
|
60
|
+
/**
|
|
61
|
+
* Send a file straight to storage in parts, and make it a file. The API is
|
|
62
|
+
* told what is coming and when it has all arrived; the bytes go from here
|
|
63
|
+
* to storage. See UploadOptions, and UploadStopped for carrying on.
|
|
64
|
+
*/
|
|
65
|
+
upload(source: Source, options?: UploadOptions): Promise<Item>;
|
|
66
|
+
/**
|
|
67
|
+
* Fetch a file straight from storage into `sink`: as up to six ranges at
|
|
68
|
+
* once for a large one. One download address is asked for, so the file is
|
|
69
|
+
* counted toward the month's downloads once. Resolves to the file.
|
|
70
|
+
*/
|
|
71
|
+
download(id: string, sink: Sink, options?: DownloadOptions): Promise<Item>;
|
|
72
|
+
/** A file's bytes, in memory: for a file small enough to hold. */
|
|
73
|
+
bytes(id: string, options?: DownloadOptions): Promise<Uint8Array<ArrayBuffer>>;
|
|
74
|
+
private fetchInto;
|
|
75
|
+
}
|
|
76
|
+
export {};
|
package/dist/client.js
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
// The client: every endpoint of the developer API as a function, made from
|
|
2
|
+
// the API's own table (generated.ts), and the pieces a program would
|
|
3
|
+
// otherwise write around them: sending a file in parts, fetching one in
|
|
4
|
+
// ranges, and reading a listing or the changes to the end.
|
|
5
|
+
import { bind, OPERATIONS, } from "./generated.js";
|
|
6
|
+
import { fetchRanges } from "./ranges.js";
|
|
7
|
+
import { FileFreakError, sleep, Transport } from "./transport.js";
|
|
8
|
+
import { upload } from "./uploads.js";
|
|
9
|
+
/** How long a follow of the changes waits for one before asking again, unless told. */
|
|
10
|
+
const FOLLOW_WAIT_SECONDS = 60;
|
|
11
|
+
/** A follow asks at most once a second while nothing is changing. */
|
|
12
|
+
const MIN_ASK_MS = 1000;
|
|
13
|
+
/**
|
|
14
|
+
* Everything under a folder, read to the end a page at a time. Once the first
|
|
15
|
+
* page has arrived, changesCursor holds the place in the changes from before
|
|
16
|
+
* the walk began: follow the changes from there after the last item, and
|
|
17
|
+
* nothing that changed during the walk is missed.
|
|
18
|
+
*/
|
|
19
|
+
export class Walk {
|
|
20
|
+
list;
|
|
21
|
+
query;
|
|
22
|
+
options;
|
|
23
|
+
changesCursor = null;
|
|
24
|
+
constructor(list, query, options) {
|
|
25
|
+
this.list = list;
|
|
26
|
+
this.query = query;
|
|
27
|
+
this.options = options;
|
|
28
|
+
}
|
|
29
|
+
async *[Symbol.asyncIterator]() {
|
|
30
|
+
let cursor = null;
|
|
31
|
+
let first = true;
|
|
32
|
+
do {
|
|
33
|
+
const page = await this.list(cursor ? { ...this.query, cursor } : this.query, this.options);
|
|
34
|
+
if (first)
|
|
35
|
+
this.changesCursor = page.changesCursor;
|
|
36
|
+
first = false;
|
|
37
|
+
yield* page.items;
|
|
38
|
+
cursor = page.nextCursor;
|
|
39
|
+
} while (cursor);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
export class FileFreak {
|
|
43
|
+
transport;
|
|
44
|
+
files;
|
|
45
|
+
folders;
|
|
46
|
+
tree;
|
|
47
|
+
changes;
|
|
48
|
+
uploads;
|
|
49
|
+
usage;
|
|
50
|
+
tokens;
|
|
51
|
+
constructor(options) {
|
|
52
|
+
this.transport = new Transport(options);
|
|
53
|
+
const api = bind((operation, input, callOptions) => this.call(operation, input, callOptions));
|
|
54
|
+
this.files = { ...api.files, each: (query = {}, callOptions) => pages(api.files.list, query, callOptions) };
|
|
55
|
+
this.folders = api.folders;
|
|
56
|
+
this.tree = { ...api.tree, walk: (query = {}, callOptions) => new Walk(api.tree.list, query, callOptions) };
|
|
57
|
+
this.changes = { ...api.changes, follow: (cursor, followOptions = {}) => follow(api.changes.list, cursor ?? null, followOptions) };
|
|
58
|
+
this.uploads = api.uploads;
|
|
59
|
+
this.usage = api.usage;
|
|
60
|
+
this.tokens = api.tokens;
|
|
61
|
+
}
|
|
62
|
+
/** Any endpoint, by its operation name. The functions above are this, typed. */
|
|
63
|
+
async call(operation, input = {}, options = {}) {
|
|
64
|
+
const op = OPERATIONS[operation];
|
|
65
|
+
const path = op.path.replace(/:(\w+)/g, (_, name) => {
|
|
66
|
+
const value = input.params?.[name];
|
|
67
|
+
if (!value)
|
|
68
|
+
throw new TypeError(`${operation} needs ${name}.`);
|
|
69
|
+
return encodeURIComponent(value);
|
|
70
|
+
});
|
|
71
|
+
const res = await this.transport.send({
|
|
72
|
+
method: op.method,
|
|
73
|
+
path,
|
|
74
|
+
query: input.query,
|
|
75
|
+
body: input.body,
|
|
76
|
+
idempotent: op.idempotent,
|
|
77
|
+
repeatable: op.repeatable,
|
|
78
|
+
options,
|
|
79
|
+
});
|
|
80
|
+
return res.body;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Send a file straight to storage in parts, and make it a file. The API is
|
|
84
|
+
* told what is coming and when it has all arrived; the bytes go from here
|
|
85
|
+
* to storage. See UploadOptions, and UploadStopped for carrying on.
|
|
86
|
+
*/
|
|
87
|
+
upload(source, options = {}) {
|
|
88
|
+
return upload({ ...this.uploads, file: this.files.get }, this.transport.fetch, source, options);
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Fetch a file straight from storage into `sink`: as up to six ranges at
|
|
92
|
+
* once for a large one. One download address is asked for, so the file is
|
|
93
|
+
* counted toward the month's downloads once. Resolves to the file.
|
|
94
|
+
*/
|
|
95
|
+
async download(id, sink, options = {}) {
|
|
96
|
+
try {
|
|
97
|
+
const { item } = await this.files.get(id, { signal: options.signal });
|
|
98
|
+
await this.fetchInto(item, sink, options);
|
|
99
|
+
await sink.close?.();
|
|
100
|
+
return item;
|
|
101
|
+
}
|
|
102
|
+
catch (e) {
|
|
103
|
+
await sink.abort?.(e).catch(() => undefined);
|
|
104
|
+
throw e;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
/** A file's bytes, in memory: for a file small enough to hold. */
|
|
108
|
+
async bytes(id, options = {}) {
|
|
109
|
+
const { item } = await this.files.get(id, { signal: options.signal });
|
|
110
|
+
const bytes = new Uint8Array(item.sizeBytes);
|
|
111
|
+
await this.fetchInto(item, { write: async (position, data) => bytes.set(data, position) }, options);
|
|
112
|
+
return bytes;
|
|
113
|
+
}
|
|
114
|
+
async fetchInto(item, sink, options) {
|
|
115
|
+
if (item.kind !== "file")
|
|
116
|
+
throw new FileFreakError(400, "not_a_file", "A folder cannot be downloaded as one file.");
|
|
117
|
+
// Nothing to fetch, and an address would count a download of nothing.
|
|
118
|
+
if (item.sizeBytes === 0)
|
|
119
|
+
return;
|
|
120
|
+
const { url } = await this.files.downloadUrl(item.id, {}, { signal: options.signal });
|
|
121
|
+
const progress = options.onProgress;
|
|
122
|
+
await fetchRanges({
|
|
123
|
+
url,
|
|
124
|
+
sizeBytes: item.sizeBytes,
|
|
125
|
+
sink,
|
|
126
|
+
signal: options.signal,
|
|
127
|
+
progress: progress && ((written) => progress(written, item.sizeBytes)),
|
|
128
|
+
ranges: options.ranges,
|
|
129
|
+
fetch: this.transport.fetch,
|
|
130
|
+
sleep: (ms) => sleep(ms, options.signal),
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
/** Every item in a listing, a page at a time. */
|
|
135
|
+
async function* pages(list, query, options) {
|
|
136
|
+
let cursor = null;
|
|
137
|
+
do {
|
|
138
|
+
const page = await list(cursor ? { ...query, cursor } : query, options);
|
|
139
|
+
yield* page.items;
|
|
140
|
+
cursor = page.nextCursor;
|
|
141
|
+
} while (cursor);
|
|
142
|
+
}
|
|
143
|
+
async function* follow(list, from, options) {
|
|
144
|
+
const { wait = FOLLOW_WAIT_SECONDS, limit, ...callOptions } = options;
|
|
145
|
+
let cursor = from;
|
|
146
|
+
for (;;) {
|
|
147
|
+
const query = { wait };
|
|
148
|
+
if (cursor)
|
|
149
|
+
query.cursor = cursor;
|
|
150
|
+
if (limit)
|
|
151
|
+
query.limit = limit;
|
|
152
|
+
const asked = Date.now();
|
|
153
|
+
const answer = await list(query, callOptions);
|
|
154
|
+
if (answer.changes.length > 0)
|
|
155
|
+
yield answer;
|
|
156
|
+
cursor = answer.cursor;
|
|
157
|
+
// Nothing, at once: asked with no wait, or the API cut the wait short
|
|
158
|
+
// (it does when it is stopping). Asking again at once would go round as
|
|
159
|
+
// fast as the line allows.
|
|
160
|
+
const early = MIN_ASK_MS - (Date.now() - asked);
|
|
161
|
+
if (answer.changes.length === 0 && !answer.more && early > 0)
|
|
162
|
+
await sleep(early, callOptions.signal);
|
|
163
|
+
}
|
|
164
|
+
}
|