@filefreak.io/sdk
The FileFreak developer API, from TypeScript or JavaScript, in Node 20 and later and in browsers. No dependencies.
Every endpoint is a function, typed from the same table the API is built from. Around them: uploads sent straight to storage in parts and carried on after an interruption, a new version of a large file sending only what changed, downloads fetched in ranges and checked against the file's SHA-256, listings and changes read to the end, files under Shard Protection sealed as they are sent and read through their maps, what a share link holds saved with no key, and requests sent again only when that cannot make anything twice. The reference is at https://filefreak.io/developers/reference.
On a server
import { FileFreak } from "@filefreak.io/sdk";
import { fromPath, toPath } from "@filefreak.io/sdk/node";
const ff = new FileFreak({ apiKey: process.env.FILEFREAK_KEY });
const { item: folder } = await ff.folders.create({ name: "uploads", ifExists: "use" });
const file = await ff.upload(await fromPath("film.mp4"), {
parentId: folder.id,
onProgress: (sent, total) => console.log(`${Math.round((sent / total) * 100)}%`),
});
await ff.download(file.id, await toPath("copy.mp4"));
const bytes = await ff.bytes(file.id); // for a file small enough to hold in memory
fromPath reads the file a part at a time as it is sent, so a file larger
than memory is fine. toPath writes beside the file and gives it its name
only once every byte is there: a download that fails leaves nothing behind.
A file under Shard Protection has no address, and download and bytes
read it through its map instead, each fragment checked as it arrives. One
written in version 2 of the shard format, in blocks cut by its content, is
read the same way, its outline checked before any block is fetched.
Into a folder under Shard Protection, upload seals each part with the
file's own key before it is sent, so none of the file is ever plain in
storage. The key is asked of the API for the upload and kept nowhere, and
an upload carried on asks which fragments storage has before it sends any.
A file kept compressed in storage is put back together as it is read, from
0.7.0. download, bytes and get ask for its address saying they can,
fetch each of its places as a range of that address, and inflate the ones
stored compressed, each checked to give exactly the bytes the API's layout
says it holds. A file that does not add up is refused and nothing is kept:
layout when the layout itself does not, blob_size when storage holds
less than it says, place_damaged when a place is not what was stored, and
place_unavailable when storage will not give one after several tries.
A file uploaded into a public folder, or in place of a public file, is
never stored compressed, and nor is an archive, a picture, a video or an
installer (an AppImage, an msi or a pkg among them). Its publicUrl gives
its bytes, for a download link or an <img>. One stored compressed before
it was made public has packed: true, and its publicUrl opens its page,
which puts it back together in the browser: upload it again into the public
folder for an address that gives the bytes.
Keep keys on a server. A key sent from a web page is refused.
Every download is checked
Every upload works out its file's SHA-256 from the bytes it sends and
records it with the file, from 0.15.0, whether or not sha256 is given; one
given is checked against it. Every download, bytes and get hashes what it
writes and compares it with the SHA-256 the API gives for the file, never
one read from storage, so a file changed in storage or on its way is caught.
One that does not match throws IntegrityError and its sink is aborted, so
toPath leaves nothing behind.
import { IntegrityError } from "@filefreak.io/sdk";
const got = await ff.download(file.id, await toPath("copy.mp4"));
got.verification; // { verified: true, by: "sha256", sha256: "1a2b..." }
verification says how it was checked: by: "sha256"; by: "seals" for a
file under Shard Protection, every piece of which is checked against its seal
as it arrives; or verified: false with reason: "no_checksum" for a file
uploaded without one, as files sent from the website before November 2026
were. get gives each file it saved a verification of its own.
A large file is fetched as several ranges at once, which arrive out of
order. What arrives in order is hashed as it comes, and the rest is read back
from the sink once every byte is written, reported to onVerifying. That
needs the sink to have read, which toPath and bytes have; a sink of your
own without it is given the file in order, as one range, which is slower.
{ verify: false } writes a download as it arrives, unchecked.
In a web page, @filefreak.io/sdk/badge shows it: "Verified" with a shield,
and, when it is opened, what that means.
import { verifiedBadge } from "@filefreak.io/sdk/badge";
row.append(verifiedBadge(got.verification));
// After an IntegrityError: verifiedBadge(null, true), "Did not match".
It draws inside its own shadow root and has no dependencies. Its colours are
--filefreak-verified-good, --filefreak-verified-bad and
--filefreak-verified-muted. A checksum says the file is the one that was
uploaded; it does not say the file is safe to open.
A new version of a large file
A file of 16 MiB or more is read through before it is sent and cut where
its content says, and each part is one of its blocks. A new version of
it, sent with onConflict: "replace", sends only the blocks the file does
not have already: those it has are put together with the new ones inside
storage. The SDK and the CLI cut a file alike, so the next version of a file
either one sent keeps its blocks whichever sends it.
await ff.upload(await fromPath("disk.img"), { parentId: folder.id, onConflict: "replace" });
Into a folder under Shard Protection the same holds, with every block
encrypted before it leaves. A protected file of more than 16 MiB is cut by
its own key, so that nobody without the key can cut a copy of it the same
way and compare, and its next version keeps the blocks the last one has
when the key may read files; a key that may only write starts a new file
key and sends every block. Each block is read again as it is sent and
checked against what the upload began with, so a file changed while it is
sent stops the upload (source_changed) rather than storing a mix of two.
In a web page
A page is given a token by your server instead. Your server makes it with its key:
// On your server, behind your own sign-in.
const { token } = await ff.tokens.create({
scopes: ["files:read", "files:write"],
folderId: customerFolderId,
expiresInSeconds: 900,
});
and the page uses it as it would a key. Given a function rather than a string, the SDK asks your server for a new token when the last has run out:
import { FileFreak } from "@filefreak.io/sdk";
const ff = new FileFreak({
token: async () => (await fetch("/my-server/filefreak-token")).text(),
});
const input = document.querySelector<HTMLInputElement>("input[type=file]")!;
const file = await ff.upload(input.files![0]!); // into the token's folder
A token may do what its key may, or less: the scopes asked for, inside the folder asked for, for a minute to an hour. It is checked against its key on every request, so revoking the key stops its tokens at once. A token cannot make another.
Previews
Once the service has made them, within a few minutes of an upload, a file
has thumbnailUrl, a small WebP picture of an image, a photo from an
iPhone, a PDF's first page, the cover in an audio file's tags or a frame of
a video; or previewTextUrl, the first lines of a text or code file or a
document, as text. Both are addresses on storage itself that need no key.
Each is the same all day, so a browser keeps what it fetched, and fetching
one counts as nothing. They are null until made, and for a file with none.
const { items } = await ff.files.list({ parent: folder.id });
for (const item of items) {
if (item.thumbnailUrl) gallery.append(Object.assign(new Image(), { src: item.thumbnailUrl, alt: item.name }));
}
files.preview(item) fetches one and gives it back ready to use, as
{ kind: "picture", bytes, contentType: "image/webp" } or
{ kind: "text", text }, or null for a file with none. files.previewsOf(items)
does the same for many at once, as a Map by id that leaves out the files
with none.
const previews = await ff.files.previewsOf(items);
for (const [id, preview] of previews) {
if (preview.kind === "text") console.log(id, preview.text.split("\n")[0]);
}
The service never reads a video, so a video's picture is a frame of it that
you send. In a web page, upload draws one about a second in from the file
the page has, and sends it once the file is stored, without the upload
waiting for it; frame: false sends none. On a server, make a JPEG of a
frame with anything that can, and send that:
import { execFile } from "node:child_process";
import { readFile } from "node:fs/promises";
import { promisify } from "node:util";
// The frame a second in, 960 pixels wide, as a JPEG.
await promisify(execFile)("ffmpeg", ["-ss", "1", "-i", "film.mp4", "-frames:v", "1", "-vf", "scale=960:-2", "-y", "frame.jpg"]);
await ff.files.sendFrame(file.id, new Blob([await readFile("frame.jpg")]));
A video's picture is a JPEG of at most 2 MB, sent once. A file under Shard Protection has no previews.
An image at a smaller size
files.image(id, { size }) gives an address for a copy of an image no more
than size pixels on its longest side, as WebP with the camera's metadata
removed: 64, 128, 256, 512, 1024 or 2048. Each size is made once, the first
time it is asked for, and kept. Until it has been made the answer is
{ status: "pending", retryAfterSeconds }, so ask again after that long:
async function sized(id: string, size: number) {
for (;;) {
const answer = await ff.files.image(id, { size });
if (answer.status === "ready") return answer; // { url, width, height, expiresAt }
await new Promise((r) => setTimeout(r, answer.retryAfterSeconds * 1000));
}
}
Name the sizes as the image is uploaded and they are ready by the time they
are wanted: ff.upload(file, { imageSizes: [512, 2048] }). A public file
needs none of this: add ?size=512 to its publicUrl.
Saving from a link
get saves what a share link or a public page holds. It needs no key, and
sends nothing that says who is asking: no key, and from a browser no
cookies. A file goes into one sink; a folder needs somewhere for each of its
files, which toFolder makes on disk.
import { get } from "@filefreak.io/sdk";
import { toFolder, toPath } from "@filefreak.io/sdk/node";
await get("https://filefreak.link/share/...", await toPath("report.pdf"));
const got = await get("https://filefreak.link/share/...", toFolder("downloads"), {
password: "open sesame", // for a link that has one
onProgress: (written, total) => console.log(`${written} of ${total} bytes`),
});
console.log(`${got.files.length} files, ${got.sizeBytes} bytes`);
It takes a share link, a public file's or folder's page, and a file's direct
address. A folder comes whole: each file goes to the sink the function makes
for it, given its name and the folders it is in. Each file saved is one of
the link's downloads, as on its page, and a link with fewer left than there
are files saves nothing (not_enough_downloads). A file under Shard
Protection is read through the link's map, and counted once. Every file is
checked as download checks one, against the SHA-256 the link's own site
gives for it.
Every name a link gives is its sharer's, and can be anything: ../../.bashrc,
or one that reads backwards. toFolder makes each one safe with safeName
before writing, and never writes outside the folder it is given. A program
that names files from a link itself should use safeName too.
Every endpoint
The functions are grouped as the reference groups the endpoints:
ff.files, ff.folders, ff.tree, ff.changes, ff.uploads, ff.usage,
ff.me, ff.tokens and ff.shares (share links, which only a device signed
in with filefreak login may make), and ff.sites for an app's sites, with
a key allowed sites:deploy. Ids in the address come first, then the body or
the query, then options:
const { items, nextCursor } = await ff.files.list({ parent: folder.id, limit: 50 });
await ff.files.update(file.id, { name: "renamed.mp4" });
await ff.files.delete(file.id, { permanent: true });
await ff.sites.update(site.id, { singlePage: false }); // missing pages are 404s again
ff.call("files.get", { params: { id } }) reaches any endpoint by its name.
Reading to the end
for await (const item of ff.files.each({ parent: folder.id })) console.log(item.name);
// Everything under a folder, then only what changes.
const walk = ff.tree.walk({ parent: folder.id });
for await (const item of walk) keep(item);
for await (const answer of ff.changes.follow(walk.changesCursor)) {
for (const change of answer.changes) apply(change);
save(answer.cursor); // to carry on from after a restart
}
follow waits on the API for the next change, a minute at a time, and goes
on until the loop is left or its signal is aborted.
A copy that also sends its own changes back names the file as it read it,
so that it never writes over, or deletes, a change it has not seen. Either
is refused with 409 revision_changed if the file has moved on:
await ff.upload(source, { name: file.name, parentId: file.parentId, onConflict: "replace", replacing: { id: file.id, revision: file.revision } });
await ff.files.delete(file.id, { ifRevision: file.revision });
When an upload stops
What reached storage is kept until the upload expires. Keep the upload's id
from onBegun, and carry on with the same file: only the parts storage does
not have are sent. A file cut by its content is carried on block by block,
each checked against what the upload began with, so carrying on with another
file is refused at its first block.
import { UploadStopped } from "@filefreak.io/sdk";
try {
await ff.upload(source, { name: "big.iso", onBegun: (u) => remember(u.id) });
} catch (e) {
if (e instanceof UploadStopped) await ff.upload(source, { resume: e.uploadId });
else throw e;
}
When something is refused
A refusal is a FileFreakError, with the API's status, a code that does
not change and is what to branch on, and a message for a person.
import { FileFreakError } from "@filefreak.io/sdk";
try {
await ff.folders.create({ name: "reports" });
} catch (e) {
if (e instanceof FileFreakError && e.code === "name_taken") {
// already there
} else throw e;
}
The SDK sends a request again only when that cannot make anything twice: a
read; a request with an Idempotency-Key, which it makes for every endpoint
that honours one unless you give your own ({ idempotencyKey } in the
options); completing an upload or making a token; or an answer saying nothing
was done, such as 429. While the service is waking up after a quiet spell it
waits, for up to a few minutes. Retry-After is honoured.
Licence
Apache-2.0.