@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.
@@ -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>;
@@ -0,0 +1,259 @@
1
+ // Uploading a file: begin, send the parts straight to storage, complete.
2
+ //
3
+ // The three steps the API defines (POST /v1/uploads, a PUT for each part to
4
+ // the address given for it, POST /v1/uploads/:id/complete). No byte of the
5
+ // file goes to the API. The rules are the website's (web/lib/uploads.ts):
6
+ //
7
+ // - Each part goes with a Content-MD5, so storage refuses a part that
8
+ // changed on the way, and that part is sent again.
9
+ // - A part that fails for a passing reason (no answer, storage busy, its
10
+ // address run out) is sent again, a little later each time, six times at
11
+ // most. An address that is about to run out is replaced before it is used.
12
+ // - Storage itself says which parts it has. Carrying on with `resume` sends
13
+ // only the rest, from this process or another; so does a complete that
14
+ // finds a part missing.
15
+ // - A very large file can take storage a while to put together. Complete
16
+ // answers 202 until it has, and is asked again when it says.
17
+ //
18
+ // A part is read into memory whole to be hashed and sent, so the memory an
19
+ // upload needs is about partBytes times the parts sent at once.
20
+ import { base64, Md5 } from "./md5.js";
21
+ import { FileFreakError, sleep } from "./transport.js";
22
+ const MIB = 1024 * 1024;
23
+ /** The API's bounds on a part. */
24
+ const LEAST_PART = 8 * MIB;
25
+ const MOST_PART = 64 * MIB;
26
+ /** Parts sent at once, unless the caller says. Four of 32 MiB went at about 700 Mbps from a fast line (plan 04, B1.1). */
27
+ const AT_ONCE = 4;
28
+ /** How many times one part is tried before the upload stops. */
29
+ const PART_ATTEMPTS = 6;
30
+ /** An address this close to running out is not used; a fresh one is asked for instead. */
31
+ const ADDRESS_MARGIN_MS = 60_000;
32
+ /** How long to go on asking while storage puts a very large file together. */
33
+ const ASSEMBLY_PATIENCE_MS = 30 * 60_000;
34
+ /** An upload that stopped part way. What arrived stays in storage until the upload expires: carry on with `resume: uploadId`, or cancel it. */
35
+ export class UploadStopped extends Error {
36
+ uploadId;
37
+ constructor(uploadId, cause) {
38
+ const why = (cause instanceof Error ? cause.message : String(cause)).replace(/[.\s]*$/, ".");
39
+ super(`The upload stopped. ${why} Carry on with resume: "${uploadId}", or cancel it.`, { cause });
40
+ this.uploadId = uploadId;
41
+ this.name = "UploadStopped";
42
+ }
43
+ }
44
+ /** The part size to ask for: 32 MiB, halved while that would leave fewer parts than are sent at once. */
45
+ export function partSizeFor(fileBytes, atOnce) {
46
+ let size = 32 * MIB;
47
+ while (size > LEAST_PART && size > fileBytes / atOnce)
48
+ size /= 2;
49
+ return size;
50
+ }
51
+ function sizeOf(source) {
52
+ return source instanceof Blob ? source.size : source.byteLength;
53
+ }
54
+ async function bytesOf(source, from, to) {
55
+ if (source instanceof Blob)
56
+ return new Uint8Array(await source.slice(from, to).arrayBuffer());
57
+ if (source instanceof ArrayBuffer)
58
+ return new Uint8Array(source.slice(from, to));
59
+ return source.slice(from, to);
60
+ }
61
+ export async function upload(calls, fetcher, source, options = {}) {
62
+ const signal = options.signal;
63
+ const atOnce = Math.max(1, Math.min(16, options.concurrency ?? AT_ONCE));
64
+ const size = sizeOf(source);
65
+ const call = { signal };
66
+ let job;
67
+ if (options.resume) {
68
+ const state = await calls.get(options.resume, call);
69
+ if (state.upload.status === "completed" && state.upload.fileId)
70
+ return (await calls.file(state.upload.fileId, call)).item;
71
+ if (state.upload.status !== "pending") {
72
+ throw new FileFreakError(410, `upload_${state.upload.status}`, `That upload is ${state.upload.status}, so it cannot be carried on. Begin it again.`);
73
+ }
74
+ if (state.upload.sizeBytes !== size) {
75
+ throw new FileFreakError(400, "size_mismatch", `That upload is for ${state.upload.sizeBytes} bytes, and this source has ${size}. Carry it on with the same file it began with.`);
76
+ }
77
+ // Begun by a program that cut the file by its content, as ff does: its
78
+ // parts are blocks of different sizes, and this reads part n at n times
79
+ // partBytes, so every part would be sent from the wrong place and the
80
+ // upload could never complete.
81
+ if (state.blocks) {
82
+ 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
+ }
84
+ job = { source, size, upload: state.upload, addresses: new Map(), fetching: null, done: new Set(state.uploadedParts), sentBytes: 0 };
85
+ }
86
+ else {
87
+ const name = options.name ?? (typeof File !== "undefined" && source instanceof File ? source.name : undefined);
88
+ if (!name)
89
+ throw new TypeError("Give the upload a name: { name: \"photo.jpg\" }.");
90
+ const type = options.contentType ?? (source instanceof Blob && source.type ? source.type : undefined);
91
+ const body = {
92
+ name,
93
+ sizeBytes: size,
94
+ parentId: options.parentId ?? null,
95
+ partBytes: Math.max(LEAST_PART, Math.min(MOST_PART, options.partBytes ?? partSizeFor(size, atOnce))),
96
+ };
97
+ if (options.onConflict)
98
+ body.onConflict = options.onConflict;
99
+ if (options.replacing)
100
+ body.replacing = options.replacing;
101
+ if (type)
102
+ body.contentType = type;
103
+ if (options.imageSizes)
104
+ body.imageSizes = options.imageSizes;
105
+ 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 };
107
+ }
108
+ options.onBegun?.(job.upload);
109
+ try {
110
+ tally(job, options);
111
+ await sendParts(calls, fetcher, job, atOnce, options);
112
+ return await finish(calls, fetcher, job, atOnce, options);
113
+ }
114
+ catch (e) {
115
+ if (signal?.aborted)
116
+ throw signal.reason;
117
+ throw new UploadStopped(job.upload.id, e);
118
+ }
119
+ }
120
+ function tally(job, options) {
121
+ let sent = 0;
122
+ for (const n of job.done)
123
+ sent += Math.min(job.size, (n + 1) * job.upload.partBytes) - n * job.upload.partBytes;
124
+ job.sentBytes = sent;
125
+ options.onProgress?.(sent, job.size);
126
+ }
127
+ async function sendParts(calls, fetcher, job, atOnce, options) {
128
+ const todo = [];
129
+ for (let n = 0; n < job.upload.partCount; n++)
130
+ if (!job.done.has(n))
131
+ todo.push(n);
132
+ let next = 0;
133
+ // The first part that cannot be sent stops the others: the file cannot be finished without it.
134
+ const stop = new AbortController();
135
+ const outer = options.signal;
136
+ const passOn = () => stop.abort(outer?.reason);
137
+ if (outer?.aborted)
138
+ passOn();
139
+ outer?.addEventListener("abort", passOn);
140
+ const worker = async () => {
141
+ for (;;) {
142
+ if (stop.signal.aborted)
143
+ throw stop.signal.reason;
144
+ const n = todo[next++];
145
+ if (n === undefined)
146
+ return;
147
+ await sendPart(calls, fetcher, job, n, stop.signal);
148
+ job.done.add(n);
149
+ tally(job, options);
150
+ }
151
+ };
152
+ try {
153
+ await Promise.all(Array.from({ length: Math.min(atOnce, todo.length) }, () => worker().catch((e) => {
154
+ stop.abort(e);
155
+ throw e;
156
+ })));
157
+ }
158
+ finally {
159
+ outer?.removeEventListener("abort", passOn);
160
+ }
161
+ }
162
+ async function sendPart(calls, fetcher, job, n, signal) {
163
+ const from = n * job.upload.partBytes;
164
+ let bytes;
165
+ try {
166
+ bytes = await bytesOf(job.source, from, Math.min(job.size, from + job.upload.partBytes));
167
+ }
168
+ catch (e) {
169
+ // Browsers and Node both refuse to read a file that changed after it was
170
+ // opened, which is what keeps one from going up half old and half new.
171
+ 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
+ }
173
+ const md5 = base64(new Md5().update(bytes).digest());
174
+ for (let attempt = 1;; attempt++) {
175
+ const part = await address(calls, job, n, signal);
176
+ let status = 0;
177
+ let damaged = false;
178
+ try {
179
+ const res = await fetcher(part.url, { method: part.method, headers: { ...part.headers, "Content-MD5": md5 }, body: bytes, signal });
180
+ if (res.ok) {
181
+ await res.body?.cancel().catch(() => undefined);
182
+ return;
183
+ }
184
+ status = res.status;
185
+ // Storage hashed what arrived, and it was not what was hashed here.
186
+ damaged = status === 400 && /Md5Mismatch/.test(await res.text().catch(() => ""));
187
+ }
188
+ catch (e) {
189
+ if (signal.aborted)
190
+ throw signal.reason;
191
+ // No answer: the connection dropped. Sent again below.
192
+ status = 0;
193
+ if (attempt >= PART_ATTEMPTS)
194
+ throw new FileFreakError(0, "network_error", `Storage could not be reached: ${e instanceof Error ? e.message : String(e)}.`);
195
+ }
196
+ // 403: the address ran out, or was for an upload that has since ended. A fresh address tells which.
197
+ if (status === 403)
198
+ job.addresses.delete(n);
199
+ const passing = damaged || status === 0 || status === 403 || status === 408 || status === 429 || status >= 500;
200
+ if (!passing)
201
+ throw new FileFreakError(status, "storage_refused", `Storage refused part ${n} of the file with ${status}.`);
202
+ if (attempt >= PART_ATTEMPTS) {
203
+ throw new FileFreakError(status, damaged ? "part_damaged" : "storage_unreachable", `Part ${n} could not be sent after ${PART_ATTEMPTS} tries.`);
204
+ }
205
+ // 1, 2, 4, 8, 16 seconds.
206
+ await sleep(Math.min(16_000, 1000 * 2 ** (attempt - 1)), signal);
207
+ }
208
+ }
209
+ async function address(calls, job, n, signal) {
210
+ for (;;) {
211
+ const known = job.addresses.get(n);
212
+ // Addresses last half an hour and a long upload outlives them. One that is
213
+ // about to run out is replaced before it is used, not after it has failed.
214
+ if (known && Date.parse(known.expiresAt) - Date.now() > ADDRESS_MARGIN_MS)
215
+ return known;
216
+ if (job.fetching) {
217
+ await job.fetching;
218
+ if (job.addresses.has(n) && Date.parse(job.addresses.get(n).expiresAt) - Date.now() > ADDRESS_MARGIN_MS)
219
+ continue;
220
+ }
221
+ const fetching = calls.parts(job.upload.id, { from: n, count: 100 }, { signal }).then(({ parts }) => {
222
+ for (const p of parts)
223
+ job.addresses.set(p.number, p);
224
+ });
225
+ job.fetching = fetching.finally(() => (job.fetching = null)).catch(() => undefined);
226
+ await fetching;
227
+ const part = job.addresses.get(n);
228
+ if (!part)
229
+ throw new FileFreakError(0, "no_address", `The API gave no address for part ${n}.`);
230
+ return part;
231
+ }
232
+ }
233
+ async function finish(calls, fetcher, job, atOnce, options) {
234
+ const started = Date.now();
235
+ const body = options.sha256 ? { sha256: options.sha256 } : {};
236
+ for (let round = 0;;) {
237
+ try {
238
+ const result = await calls.complete(job.upload.id, body, { signal: options.signal });
239
+ if ("item" in result)
240
+ return result.item;
241
+ // Storage is still putting a very large file together. Ask again.
242
+ if (Date.now() - started > ASSEMBLY_PATIENCE_MS) {
243
+ throw new FileFreakError(202, "still_assembling", "Storage is taking a long time to put this file together. Nothing is lost: complete it again in a few minutes.");
244
+ }
245
+ await sleep(Math.max(1, result.retryAfterSeconds) * 1000, options.signal);
246
+ }
247
+ catch (e) {
248
+ // Storage does not have every part after all. Send what is missing.
249
+ if (e instanceof FileFreakError && e.code === "upload_incomplete" && round++ < 2) {
250
+ const have = Array.isArray(e.body?.uploadedParts) ? e.body.uploadedParts.filter((n) => typeof n === "number") : [];
251
+ job.done = new Set(have);
252
+ tally(job, options);
253
+ await sendParts(calls, fetcher, job, atOnce, options);
254
+ continue;
255
+ }
256
+ throw e;
257
+ }
258
+ }
259
+ }
package/package.json CHANGED
@@ -1,6 +1,29 @@
1
1
  {
2
2
  "name": "@filefreak.io/sdk",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
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
+ "license": "Apache-2.0",
6
+ "homepage": "https://filefreak.io/developers/reference",
7
+ "type": "module",
8
+ "sideEffects": false,
9
+ "engines": {
10
+ "node": ">=20"
11
+ },
12
+ "exports": {
13
+ ".": {
14
+ "types": "./dist/index.d.ts",
15
+ "default": "./dist/index.js"
16
+ },
17
+ "./node": {
18
+ "types": "./dist/node.d.ts",
19
+ "default": "./dist/node.js"
20
+ }
21
+ },
22
+ "files": [
23
+ "dist",
24
+ "README.md"
25
+ ],
26
+ "publishConfig": {
27
+ "access": "public"
28
+ }
29
+ }