@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 CHANGED
@@ -1,3 +1,190 @@
1
- # Temporary Holding Version
1
+ # @filefreak.io/sdk
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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, files under Shard Protection read through their maps, what a
10
+ share link holds saved with no key, and requests sent again only when that
11
+ cannot make anything twice. The reference is at
12
+ https://filefreak.io/developers/reference.
13
+
14
+ ## On a server
15
+
16
+ ```ts
17
+ import { FileFreak } from "@filefreak.io/sdk";
18
+ import { fromPath, toPath } from "@filefreak.io/sdk/node";
19
+
20
+ const ff = new FileFreak({ apiKey: process.env.FILEFREAK_KEY });
21
+
22
+ const { item: folder } = await ff.folders.create({ name: "uploads", ifExists: "use" });
23
+ const file = await ff.upload(await fromPath("film.mp4"), {
24
+ parentId: folder.id,
25
+ onProgress: (sent, total) => console.log(`${Math.round((sent / total) * 100)}%`),
26
+ });
27
+
28
+ await ff.download(file.id, await toPath("copy.mp4"));
29
+ const bytes = await ff.bytes(file.id); // for a file small enough to hold in memory
30
+ ```
31
+
32
+ `fromPath` reads the file a part at a time as it is sent, so a file larger
33
+ than memory is fine. `toPath` writes beside the file and gives it its name
34
+ only once every byte is there: a download that fails leaves nothing behind.
35
+ A file under Shard Protection has no address, and `download` and `bytes`
36
+ read it through its map instead, each fragment checked as it arrives.
37
+
38
+ Keep keys on a server. A key sent from a web page is refused.
39
+
40
+ ## In a web page
41
+
42
+ A page is given a token by your server instead. Your server makes it with its
43
+ key:
44
+
45
+ ```ts
46
+ // On your server, behind your own sign-in.
47
+ const { token } = await ff.tokens.create({
48
+ scopes: ["files:read", "files:write"],
49
+ folderId: customerFolderId,
50
+ expiresInSeconds: 900,
51
+ });
52
+ ```
53
+
54
+ and the page uses it as it would a key. Given a function rather than a
55
+ string, the SDK asks your server for a new token when the last has run out:
56
+
57
+ ```ts
58
+ import { FileFreak } from "@filefreak.io/sdk";
59
+
60
+ const ff = new FileFreak({
61
+ token: async () => (await fetch("/my-server/filefreak-token")).text(),
62
+ });
63
+
64
+ const input = document.querySelector<HTMLInputElement>("input[type=file]")!;
65
+ const file = await ff.upload(input.files![0]!); // into the token's folder
66
+ ```
67
+
68
+ A token may do what its key may, or less: the scopes asked for, inside the
69
+ folder asked for, for a minute to an hour. It is checked against its key on
70
+ every request, so revoking the key stops its tokens at once. A token cannot
71
+ make another.
72
+
73
+ ## Saving from a link
74
+
75
+ `get` saves what a share link or a public page holds. It needs no key, and
76
+ sends nothing that says who is asking: no key, and from a browser no
77
+ cookies. A file goes into one sink; a folder needs somewhere for each of its
78
+ files, which `toFolder` makes on disk.
79
+
80
+ ```ts
81
+ import { get } from "@filefreak.io/sdk";
82
+ import { toFolder, toPath } from "@filefreak.io/sdk/node";
83
+
84
+ await get("https://filefreak.link/share/...", await toPath("report.pdf"));
85
+
86
+ const got = await get("https://filefreak.link/share/...", toFolder("downloads"), {
87
+ password: "open sesame", // for a link that has one
88
+ onProgress: (written, total) => console.log(`${written} of ${total} bytes`),
89
+ });
90
+ console.log(`${got.files.length} files, ${got.sizeBytes} bytes`);
91
+ ```
92
+
93
+ It takes a share link, a public file's or folder's page, and a file's direct
94
+ address. A folder comes whole: each file goes to the sink the function makes
95
+ for it, given its name and the folders it is in. Each file saved is one of
96
+ the link's downloads, as on its page, and a link with fewer left than there
97
+ are files saves nothing (`not_enough_downloads`). A file under Shard
98
+ Protection is read through the link's map, and counted once.
99
+
100
+ Every name a link gives is its sharer's, and can be anything: `../../.bashrc`,
101
+ or one that reads backwards. `toFolder` makes each one safe with `safeName`
102
+ before writing, and never writes outside the folder it is given. A program
103
+ that names files from a link itself should use `safeName` too.
104
+
105
+ ## Every endpoint
106
+
107
+ The functions are grouped as the reference groups the endpoints:
108
+ `ff.files`, `ff.folders`, `ff.tree`, `ff.changes`, `ff.uploads`, `ff.usage`,
109
+ `ff.me`, `ff.tokens` and `ff.shares` (share links, which only a device signed
110
+ in with `filefreak login` may make). Ids in the address come first, then the body or
111
+ the query, then options:
112
+
113
+ ```ts
114
+ const { items, nextCursor } = await ff.files.list({ parent: folder.id, limit: 50 });
115
+ await ff.files.update(file.id, { name: "renamed.mp4" });
116
+ await ff.files.delete(file.id, { permanent: true });
117
+ ```
118
+
119
+ `ff.call("files.get", { params: { id } })` reaches any endpoint by its name.
120
+
121
+ ## Reading to the end
122
+
123
+ ```ts
124
+ for await (const item of ff.files.each({ parent: folder.id })) console.log(item.name);
125
+
126
+ // Everything under a folder, then only what changes.
127
+ const walk = ff.tree.walk({ parent: folder.id });
128
+ for await (const item of walk) keep(item);
129
+ for await (const answer of ff.changes.follow(walk.changesCursor)) {
130
+ for (const change of answer.changes) apply(change);
131
+ save(answer.cursor); // to carry on from after a restart
132
+ }
133
+ ```
134
+
135
+ `follow` waits on the API for the next change, a minute at a time, and goes
136
+ on until the loop is left or its `signal` is aborted.
137
+
138
+ A copy that also sends its own changes back names the file as it read it,
139
+ so that it never writes over, or deletes, a change it has not seen. Either
140
+ is refused with 409 `revision_changed` if the file has moved on:
141
+
142
+ ```ts
143
+ await ff.upload(source, { name: file.name, parentId: file.parentId, onConflict: "replace", replacing: { id: file.id, revision: file.revision } });
144
+ await ff.files.delete(file.id, { ifRevision: file.revision });
145
+ ```
146
+
147
+ ## When an upload stops
148
+
149
+ What reached storage is kept until the upload expires. Keep the upload's id
150
+ from `onBegun`, and carry on with the same file: only the parts storage does
151
+ not have are sent.
152
+
153
+ ```ts
154
+ import { UploadStopped } from "@filefreak.io/sdk";
155
+
156
+ try {
157
+ await ff.upload(source, { name: "big.iso", onBegun: (u) => remember(u.id) });
158
+ } catch (e) {
159
+ if (e instanceof UploadStopped) await ff.upload(source, { resume: e.uploadId });
160
+ else throw e;
161
+ }
162
+ ```
163
+
164
+ ## When something is refused
165
+
166
+ A refusal is a `FileFreakError`, with the API's `status`, a `code` that does
167
+ not change and is what to branch on, and a `message` for a person.
168
+
169
+ ```ts
170
+ import { FileFreakError } from "@filefreak.io/sdk";
171
+
172
+ try {
173
+ await ff.folders.create({ name: "reports" });
174
+ } catch (e) {
175
+ if (e instanceof FileFreakError && e.code === "name_taken") {
176
+ // already there
177
+ } else throw e;
178
+ }
179
+ ```
180
+
181
+ The SDK sends a request again only when that cannot make anything twice: a
182
+ read; a request with an Idempotency-Key, which it makes for every endpoint
183
+ that honours one unless you give your own (`{ idempotencyKey }` in the
184
+ options); completing an upload or making a token; or an answer saying nothing
185
+ was done, such as 429. While the service is waking up after a quiet spell it
186
+ waits, for up to a few minutes. `Retry-After` is honoured.
187
+
188
+ ## Licence
189
+
190
+ Apache-2.0.
@@ -0,0 +1,78 @@
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. A file under Shard Protection
70
+ * has no address, and is read through its map instead (files.shards), each
71
+ * fragment checked as it arrives. Resolves to the file.
72
+ */
73
+ download(id: string, sink: Sink, options?: DownloadOptions): Promise<Item>;
74
+ /** A file's bytes, in memory: for a file small enough to hold. */
75
+ bytes(id: string, options?: DownloadOptions): Promise<Uint8Array<ArrayBuffer>>;
76
+ private fetchInto;
77
+ }
78
+ export {};
package/dist/client.js ADDED
@@ -0,0 +1,168 @@
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 { asItIsKept, readProtected } from "./reader.js";
8
+ import { FileFreakError, sleep, Transport } from "./transport.js";
9
+ import { upload } from "./uploads.js";
10
+ /** How long a follow of the changes waits for one before asking again, unless told. */
11
+ const FOLLOW_WAIT_SECONDS = 60;
12
+ /** A follow asks at most once a second while nothing is changing. */
13
+ const MIN_ASK_MS = 1000;
14
+ /**
15
+ * Everything under a folder, read to the end a page at a time. Once the first
16
+ * page has arrived, changesCursor holds the place in the changes from before
17
+ * the walk began: follow the changes from there after the last item, and
18
+ * nothing that changed during the walk is missed.
19
+ */
20
+ export class Walk {
21
+ list;
22
+ query;
23
+ options;
24
+ changesCursor = null;
25
+ constructor(list, query, options) {
26
+ this.list = list;
27
+ this.query = query;
28
+ this.options = options;
29
+ }
30
+ async *[Symbol.asyncIterator]() {
31
+ let cursor = null;
32
+ let first = true;
33
+ do {
34
+ const page = await this.list(cursor ? { ...this.query, cursor } : this.query, this.options);
35
+ if (first)
36
+ this.changesCursor = page.changesCursor;
37
+ first = false;
38
+ yield* page.items;
39
+ cursor = page.nextCursor;
40
+ } while (cursor);
41
+ }
42
+ }
43
+ export class FileFreak {
44
+ transport;
45
+ files;
46
+ folders;
47
+ tree;
48
+ changes;
49
+ uploads;
50
+ usage;
51
+ tokens;
52
+ constructor(options) {
53
+ this.transport = new Transport(options);
54
+ const api = bind((operation, input, callOptions) => this.call(operation, input, callOptions));
55
+ this.files = { ...api.files, each: (query = {}, callOptions) => pages(api.files.list, query, callOptions) };
56
+ this.folders = api.folders;
57
+ this.tree = { ...api.tree, walk: (query = {}, callOptions) => new Walk(api.tree.list, query, callOptions) };
58
+ this.changes = { ...api.changes, follow: (cursor, followOptions = {}) => follow(api.changes.list, cursor ?? null, followOptions) };
59
+ this.uploads = api.uploads;
60
+ this.usage = api.usage;
61
+ this.tokens = api.tokens;
62
+ }
63
+ /** Any endpoint, by its operation name. The functions above are this, typed. */
64
+ async call(operation, input = {}, options = {}) {
65
+ const op = OPERATIONS[operation];
66
+ const path = op.path.replace(/:(\w+)/g, (_, name) => {
67
+ const value = input.params?.[name];
68
+ if (!value)
69
+ throw new TypeError(`${operation} needs ${name}.`);
70
+ return encodeURIComponent(value);
71
+ });
72
+ const res = await this.transport.send({
73
+ method: op.method,
74
+ path,
75
+ query: input.query,
76
+ body: input.body,
77
+ idempotent: op.idempotent,
78
+ repeatable: op.repeatable,
79
+ options,
80
+ });
81
+ return res.body;
82
+ }
83
+ /**
84
+ * Send a file straight to storage in parts, and make it a file. The API is
85
+ * told what is coming and when it has all arrived; the bytes go from here
86
+ * to storage. See UploadOptions, and UploadStopped for carrying on.
87
+ */
88
+ upload(source, options = {}) {
89
+ return upload({ ...this.uploads, file: this.files.get }, this.transport.fetch, source, options);
90
+ }
91
+ /**
92
+ * Fetch a file straight from storage into `sink`: as up to six ranges at
93
+ * once for a large one. One download address is asked for, so the file is
94
+ * counted toward the month's downloads once. A file under Shard Protection
95
+ * has no address, and is read through its map instead (files.shards), each
96
+ * fragment checked as it arrives. Resolves to the file.
97
+ */
98
+ async download(id, sink, options = {}) {
99
+ try {
100
+ const { item } = await this.files.get(id, { signal: options.signal });
101
+ await this.fetchInto(item, sink, options);
102
+ await sink.close?.();
103
+ return item;
104
+ }
105
+ catch (e) {
106
+ await sink.abort?.(e).catch(() => undefined);
107
+ throw e;
108
+ }
109
+ }
110
+ /** A file's bytes, in memory: for a file small enough to hold. */
111
+ async bytes(id, options = {}) {
112
+ const { item } = await this.files.get(id, { signal: options.signal });
113
+ const bytes = new Uint8Array(item.sizeBytes);
114
+ await this.fetchInto(item, { write: async (position, data) => bytes.set(data, position) }, options);
115
+ return bytes;
116
+ }
117
+ async fetchInto(item, sink, options) {
118
+ if (item.kind !== "file")
119
+ throw new FileFreakError(400, "not_a_file", "A folder cannot be downloaded as one file.");
120
+ // Nothing to fetch, and an address would count a download of nothing.
121
+ if (item.sizeBytes === 0)
122
+ return;
123
+ const { signal, onProgress } = options;
124
+ const progress = onProgress && ((written) => onProgress(written, item.sizeBytes));
125
+ await asItIsKept(item.protected, async () => {
126
+ const { url } = await this.files.downloadUrl(item.id, {}, { signal });
127
+ await fetchRanges({ url, sizeBytes: item.sizeBytes, sink, signal, progress, ranges: options.ranges, fetch: this.transport.fetch, sleep: (ms) => sleep(ms, signal) });
128
+ }, () => readProtected({
129
+ pages: (from, pageSignal) => this.files.shards(item.id, { from }, { signal: pageSignal }),
130
+ sink,
131
+ sizeBytes: item.sizeBytes,
132
+ signal,
133
+ progress,
134
+ fetch: this.transport.fetch,
135
+ }));
136
+ }
137
+ }
138
+ /** Every item in a listing, a page at a time. */
139
+ async function* pages(list, query, options) {
140
+ let cursor = null;
141
+ do {
142
+ const page = await list(cursor ? { ...query, cursor } : query, options);
143
+ yield* page.items;
144
+ cursor = page.nextCursor;
145
+ } while (cursor);
146
+ }
147
+ async function* follow(list, from, options) {
148
+ const { wait = FOLLOW_WAIT_SECONDS, limit, ...callOptions } = options;
149
+ let cursor = from;
150
+ for (;;) {
151
+ const query = { wait };
152
+ if (cursor)
153
+ query.cursor = cursor;
154
+ if (limit)
155
+ query.limit = limit;
156
+ const asked = Date.now();
157
+ const answer = await list(query, callOptions);
158
+ if (answer.changes.length > 0)
159
+ yield answer;
160
+ cursor = answer.cursor;
161
+ // Nothing, at once: asked with no wait, or the API cut the wait short
162
+ // (it does when it is stopping). Asking again at once would go round as
163
+ // fast as the line allows.
164
+ const early = MIN_ASK_MS - (Date.now() - asked);
165
+ if (answer.changes.length === 0 && !answer.more && early > 0)
166
+ await sleep(early, callOptions.signal);
167
+ }
168
+ }