API reference

@thefinder/core

The server engine. It has no runtime dependencies and runs with the Web-standard Request → Response signature on Node.js (≥ 18.17), Bun, Deno and edge runtimes.

Entry points#

SubpathContentsRuntime
@thefinder/coreEngine, file server, errors, helpers, typesAnywhere
@thefinder/core/locallocalDriver, LocalDriverNode.js, Bun (node:fs)
@thefinder/core/s3s3Driver, S3DriverAnywhere (Web Crypto + fetch)
@thefinder/core/nodetoNodeHandler, toExpress, toFastify, toKoa, toWebRequest, sendWebResponseNode.js
@thefinder/core/sharpsharpThumbnailer, sharpImagesNode.js, Bun; requires sharp (≥ 0.33)
@thefinder/core/clientcreateClient, TheFinderClient, ApiError. See client.mdBrowser and server

createTheFinder(options)#

import { createTheFinder } from "@thefinder/core";
import { localDriver } from "@thefinder/core/local";

const finder = createTheFinder({
  volumes: [{ id: "files", name: "Files", driver: localDriver({ root: "./uploads" }), url: "/uploads" }],
});

Same as new TheFinder(options). Throws an Error right away on a bad configuration (no volumes, the same volume id twice, chunkSize < 5 MiB while an S3 volume exists).

TheFinderOptions#

OptionTypeDefaultDescription
volumesVolumeOptions[]—Required, at least one volume
chunkSizenumber5 MiBUpload chunk size. Must be at least 5 MiB if there is an S3 volume.
authorize(ctx) => AuthorizeResult \| Promise<…>—Runs before every command. See Authorization.
onBeforeCommand(ctx) => void \| Promise<void>—After authorization, before the command. If it throws, the command doesn't run.
onAfterCommand(ctx & { result }) => void \| Promise<void>—After the command succeeds. For file responses result is a Response.
onEntriesChanged(changes, ctx) => void \| Promise<void>—After storage changes that move, remove, restore or replace existing entries. See Keeping references in sync.
maxEditSizenumber5 MiBUpper limit for get / put (text)
searchLimitnumber500Result limit for search
thumbnailsThumbnailOptions—Server-side thumbnails. See Thumbnails.
imagesImageProcessor—Bulk image processing. See Image processing.
maxImageSizenumber60 MiBLargest input for transform
maxExtractSizenumber4 GiBTotal size extract may unpack (zip-bomb protection)
csrfProtectionbooleantrueRequires the x-thefinder header on POST requests

VolumeOptions#

OptionTypeDefaultDescription
idstring—Required. Letters, digits and dashes, at most 32 characters; becomes the prefix of ids.
namestringidDisplay name of the root folder
driverStorageDriver—Required
urlstring—Public base URL of the files ("/uploads", "https://cdn.example.com"). When set, every file gets a url field; the UI uses it for previews and in the picker.
readOnlybooleanfalseRejects every write with READ_ONLY. Trash and version history are off too.
showHiddenbooleanfalseShows files starting with a dot. When off, names starting with a dot (.htaccess, .env...) cannot be created, uploaded or extracted from zips. The internal folders (.tf-*) stay hidden in every case.
allowExtensionsstring[]—Only these extensions (lower case, no dot). Empty or missing means all.
denyExtensionsstring[]—Extensions rejected on upload, create, rename, paste and extract
maxUploadSizenumber—Byte limit for a single file (upload and binary put)
permission(action, path) => boolean—action: "read" \| "write" \| "delete". Returning false rejects the operation on that path with FORBIDDEN and sets the read/write flags in the UI. Folder operations apply to the contents too: a folder containing an item that can't be deleted can't be deleted, and unreadable subfolders are left out of search, size, zip download and archiving.
trashboolean \| { retentionDays? }true, 30 daysfalse: deleted items are removed right away
versionsboolean \| { maxPerFile?, retentionDays? }true, 20 versions, foreverretentionDays: 0 keeps forever. false: no versions are taken.

The TheFinder instance#

MemberDescription
handler(request): Promise<Response>Web-standard request handler. Use it directly in Bun, Deno, Hono, Next.js routes and Cloudflare Workers; for Node frameworks see the adapters. It is defined as an arrow function to keep its binding: Bun.serve({ fetch: finder.handler }) is safe.
execute<T>(cmd, params?, request?)Runs a command without HTTP; hooks and authorize still run. Throws TheFinderError on failure. Without request an empty Request is used.
getVolume(id)Returns the Volume instance (advanced use)
optionsThe options with defaults applied
// Example: removing versions older than 90 days in a cron job
await finder.execute("cleanup", { volume: "files", target: "versions", mode: "older", days: 90 });

Authorization#

type AuthorizeResult = boolean | void | { readOnly?: boolean };

interface CommandContext {
  cmd: string;                       // "ls", "upload"...
  request: Request;                  // the incoming request (cookies and headers can be read)
  params: Record<string, unknown>;   // merged query + body parameters
}
authorize resultEffect
true or undefinedThe request goes through
false403 FORBIDDEN
{ readOnly: true }The request goes through, but every volume is read-only for it. The init response carries readOnly: true, so the UI hides write actions by itself.
throw new TheFinderError("UNAUTHORIZED")401; the UI calls onUnauthorized

Keeping references in sync: onEntriesChanged#

Entry ids are made from the path (<volume>_<base64url(path)>), so a file gets a new id when it is renamed or moved. An application that refers to files by its own stable id (for example a CMS with a uuid → path table) can keep that table up to date with onEntriesChanged:

type EntryChange =
  | { type: "moved"; from: { volume: string; path: VolumePath }; to: { volume: string; path: VolumePath }; kind: EntryKind }
  | { type: "trashed"; volume: string; path: VolumePath; kind: EntryKind; trashId: string }
  | { type: "restored"; volume: string; trashId: string; path: VolumePath; kind: EntryKind }
  | { type: "deleted"; volume: string; path: VolumePath; kind: EntryKind } // removed for good, without the trash
  | { type: "purged"; volume: string; trashId: string } // removed from the trash for good
  | { type: "replaced"; volume: string; path: VolumePath }; // same file, same path, new content

const finder = createTheFinder({
  volumes: [/* ... */],
  async onEntriesChanged(changes, ctx) {
    for (const c of changes) {
      // A folder comes as one change: update its own path and every path below `from.path + "/"`.
      if (c.type === "moved") await db.files.movePath(c.from, c.to);
      if (c.type === "trashed") await db.files.markTrashed(c.volume, c.path, c.trashId);
      if (c.type === "restored") await db.files.restore(c.volume, c.trashId, c.path);
      if (c.type === "deleted") await db.files.removePath(c.volume, c.path);
      if (c.type === "purged") await db.files.removeTrashed(c.volume, c.trashId);
      if (c.type === "replaced") await db.files.touch(c.volume, c.path);
    }
  },
});

EntryChange and EntryKind ("file" | "dir") are exported from @thefinder/core.

When it is called

Events

CommandChange
renamemoved
paste with cut (also between volumes)moved. Between volumes it is reported once the source is removed.
paste with conflict: "overwrite" over an existing itemA file over a file: replaced for the target (then moved with cut). Anything else (a folder replaced, or a file replacing a folder): deleted for the item that was there, since what was inside it is gone.
paste copy without a clash, or with conflict: "rename" / "skip"none
rm (trash enabled)trashed
rm with permanent: true, or on a volume with trash: falsedeleted
restorerestored
purge with ids, or with all: truepurged for each item
trash / purge: items past retentionDays are removed when the trash is readpurged, in the call of that command
upload with conflict: "overwrite" over a filereplaced, when the last chunk lands
put with idreplaced
revert of an existing filereplaced (bringing a removed file back creates it: none)
transform in place (output: "overwrite", same format)replaced
transform writing a copy or another format with conflict: "overwrite" over a filereplaced for that file; the original is not changed
transform writing a new copy or another format, dryRunnone
mkdir, mkfile, duplicate, archive, extract (always into a new folder), upload / put of a new filenone

Errors. It behaves like onAfterCommand: when it throws, the request fails (500 INTERNAL unless it throws a TheFinderError) and onAfterCommand does not run. The storage change has already happened by then, so the client sees an error for something that was done. Catch inside the hook (and queue a retry, for example) when your table being out of date should not fail the user's request. When the command itself failed and the hook throws too, the command's error is returned and the hook's error is logged with console.error.


Drivers#

localDriver(options), @thefinder/core/local#

OptionTypeDefaultDescription
rootstring—Required. The volume root; a relative path is resolved against the working directory.
createbooleantrueCreates the root folder if missing
followSymlinksbooleantrueFollows symlinks. Links pointing outside the root are always hidden and give FORBIDDEN on access.

s3Driver(options), @thefinder/core/s3#

OptionTypeDefaultDescription
bucketstring—Required
accessKeyId, secretAccessKeystring—Required
sessionTokenstring—For temporary credentials
regionstring"us-east-1""auto" for Cloudflare R2
endpointstringAWSS3-compatible services: R2, MinIO, DigitalOcean Spaces, Backblaze B2…
prefixstringbucket rootKey prefix used as the volume root, e.g. "uploads/"
forcePathStylebooleantrue with a custom endpoint, false on AWSendpoint/bucket/key style URLs
delivery"presigned" \| "proxy""presigned"How files reach the browser when the volume has no url: a redirect to a short-lived presigned URL, or streaming through the server
presignExpiresInnumber3600Presigned URL lifetime (seconds)
partSizenumber8 MiBMultipart part size for streamed writes (archives, cross-volume copies); at least 5 MiB
fetchtypeof fetchglobal fetchA custom fetch (tests, proxies)

The StorageDriver interface#

To add a new storage (FTP, Azure Blob, Google Drive…) all you need is to implement this interface. The engine always hands the driver normalized paths (/, /a/b.txt); the driver never sees ...

MethodRequiredDescription
kind✔A short name ("local", "s3", "ftp"); the UI uses it to pick an icon
stat(path)✔A DriverStat, or null if it doesn't exist
list(path)✔Direct children of the folder
mkdir(path)✔Creates a single folder (the parent exists)
read(path, range?)✔ReadableStream<Uint8Array>; range is an inclusive byte range
write(path, data)✔data: ReadableStream, Uint8Array, ArrayBuffer or string. Overwrites if it exists.
remove(path)✔Deletes a file, or a folder recursively
copy(from, to)✔Recursive copy; the target doesn't exist
move(from, to)✔Move / rename; the target doesn't exist (except for a case-only change)
uploadChunk(chunk)✔Writes one upload chunk; returns { session, done }. session is driver-specific state that travels to the client and comes back with the next chunk.
abortUpload(path, session)Cleans up an unfinished upload
hasSubdirs(path)Whether the tree should show an expand arrow
search(path, match, limit)Fast recursive search; without it the engine walks the tree itself
signedUrl(path, { download?, filename?, expiresIn? })A direct (presigned) URL. If it returns a URL, the engine redirects with 302 instead of streaming the file.
capacity(){ total, free } or null
interface DriverStat { name: string; path: VolumePath; kind: "file" | "dir"; size: number; mtime: number /* ms */ }

interface UploadChunk {
  path: VolumePath;   // validated, conflict-free target on the first chunk
  session?: string;   // state returned by the previous chunk; undefined on the first chunk
  index: number; total: number;
  size: number;       // total size of the file
  offset: number;     // byte offset of the chunk
  data: Uint8Array;
}

Thumbnails#

import { sharpThumbnailer } from "@thefinder/core/sharp";

createTheFinder({ volumes, thumbnails: { generator: sharpThumbnailer(), sizes: [128, 256, 512] } });

ThumbnailOptions#

OptionTypeDefaultDescription
generatorThumbnailer—Required
sizesnumber[][128, 256, 512]Allowed sizes; requests are rounded to the nearest one
maxInputSizenumber40 MiBLarger files are not thumbnailed; the original is sent
concurrencynumber2Maximum number of generations at the same time

Thumbnails are kept in the volume's .tf-thumbs/ folder. They are regenerated when the source changes and cleaned up when the file is deleted, moved or renamed.

sharpThumbnailer(options?)#

OptionDefaultDescription
quality78WebP quality
maxPixels120,000,000Larger images are rejected (decompression bomb protection)

Reads: jpg, jpeg, jfif, png, webp, gif, avif, tif, tiff, heic, heif. Applies EXIF orientation, keeps the aspect ratio, never upscales. If sharp can't be loaded the API keeps working, a warning is logged once and original images are sent.

The Thumbnailer interface#

interface Thumbnailer {
  extensions: string[];      // extensions it can read (lower case)
  extension?: string;        // extension of generated files, default "webp"
  generate(input: Uint8Array, size: number): Promise<Uint8Array>; // fit into a size×size box
}

Image processing#

import { sharpImages } from "@thefinder/core/sharp";

createTheFinder({ volumes, images: sharpImages() });

sharpImages(options?)#

OptionDefaultDescription
maxPixels120,000,000Decompression bomb protection
effort4WebP/AVIF encoding effort (speed ↔ size)

Writes: jpeg (mozjpeg), png (palette when quality < 100), webp, avif, gif. Applies EXIF orientation, strips metadata, never upscales. Animation is kept when both the source and target formats support it. Throws INVALID_IMAGE on corrupt input and TOO_LARGE at the pixel limit. At most two images are processed at once; the rest wait in a queue.

The ImageProcessor interface#

interface ImageProcessor {
  extensions: string[];   // extensions it can read
  formats: ImageFormat[]; // formats it can write: "jpeg" | "png" | "webp" | "avif" | "gif"
  transform(input: Uint8Array, options: ImageTransformOptions): Promise<{ data: Uint8Array; width: number; height: number }>;
}

interface ImageTransformOptions { format: ImageFormat; width?: number; height?: number; quality?: number /* 1–100, default 80 */ }

Serving files#

createFileServer(options)#

A Web-standard handler that serves the files of a driver under a URL prefix.

Bun.serve({ routes: { "/uploads/*": createFileServer({ driver, prefix: "/uploads" }) } });
OptionTypeDefaultDescription
driverStorageDriver—Required
prefixstring""URL prefix
showHiddenbooleanfalseServes dotfiles (.tf-* is never served)
authorize(request, path) => boolean \| Promise<boolean>—false → 404 (private files don't even reveal that they exist)
cacheControlstring"public, max-age=0, must-revalidate"

Range, ETag/304, HEAD, the right Content-Type and ?download are supported. Active content such as HTML and SVG is served with Content-Security-Policy: sandbox. Methods other than GET/HEAD return 405.

serveFile(options)#

A low-level helper for building a file response in your own route.

OptionTypeDescription
requestRequestRange and conditional GET headers are read from it
statDriverStatFor size, date and ETag
open(range?) => Promise<ReadableStream>Opens the content
downloadbooleanSends it as an attachment
filenamestringContent-Disposition name (default stat.name)
cacheControlstringDefault "private, max-age=0, must-revalidate"
mimestringGuessed from the file name by default

Other helpers: contentDisposition(type, filename) (RFC 6266/5987, UTF-8 names), parseRange(header, size) (ByteRange, "invalid" or null), etagOf(stat).


Node adapters#

@thefinder/core/node. If a parser such as express.json() or koa-bodyparser has already read the body, the adapter rebuilds it.

FunctionUsage
toNodeHandler(handler)http.createServer(toNodeHandler(finder.handler)). Also works as Express / Connect middleware; on errors it calls next(err).
toExpress(handler)Same as toNodeHandler: app.all("/api/files", toExpress(finder.handler))
toKoa(handler)router.all("/api/files", toKoa(finder.handler))
toFastify(handler, { path? })A Fastify plugin: fastify.register(toFastify(finder.handler), { prefix: "/api/files" }). Turns off body parsing in its own scope; path defaults to "/".
toWebRequest(req)IncomingMessage → Request. Honors x-forwarded-proto / x-forwarded-host.
sendWebResponse(res, response)Streams a Response into a ServerResponse

A file server can be mounted the same way: app.get("/uploads/*", toExpress(createFileServer({ driver, prefix: "/uploads" }))).


Errors#

import { TheFinderError, isTheFinderError } from "@thefinder/core";

throw new TheFinderError("UNAUTHORIZED");           // the message defaults to the code
throw new TheFinderError("FORBIDDEN", "This folder is off limits");
MemberDescription
code: ErrorCodeThe full list of codes and their HTTP statuses: protocol.md › Error codes
status: numberHTTP status derived from the code
isTheFinderError(e)Type guard

A TheFinderError thrown in a hook or a driver reaches the client with its own code and message. Any other error becomes INTERNAL and its details are written only to the server log.

Helpers#

ExportDescription
encodeId(volume, path) / decodeId(id)Build and decode ids. decodeId throws BAD_REQUEST on invalid input.
normalizePath(input)"a//b/./c" → "/a/b/c". Doesn't try to resolve paths with .., backslashes or NUL; throws BAD_REQUEST.
joinPath(dir, name), dirname(path), basename(path)POSIX volume path operations
extname(name)Lower-case extension without the dot; "" for dotfiles
isInside(parent, child)Whether child is parent itself or below it
mimeOf(name)MIME type from the extension (built-in table); application/octet-stream when unknown
isActiveContent(mime)Types that can run scripts in the browser (HTML, SVG, XML, JS)
createZipStream(sources)A dependency-free streaming zip writer (zip64 included). Already compressed formats are stored without compression.
readZipEntries(read, size) / openZipEntry(read, entry)A random-access zip reader; the archive is never loaded into memory
CSRF_HEADER"x-thefinder"
VERSIONPackage version
TRASH_ROOT, THUMBS_ROOT, VERSIONS_ROOT"/.tf-trash", "/.tf-thumbs", "/.tf-versions"
isTrashPath(path), isVersionsPath(path), isReservedPath(path)Whether the path is inside one of the internal folders
Volume, ThumbnailServiceInternal engine classes; for advanced use, their APIs may change in minor releases
interface ZipSource {
  name: string;          // path inside the archive; folders end with "/"
  mtime: number;
  size?: number;         // used to decide on zip64 for very large files
  open?: () => Promise<ReadableStream<Uint8Array>>;
}

Types#

Every type can be imported with import type { … } from "@thefinder/core". In browser code the same types are available from @thefinder/core/client.

Entry#

FieldTypeDescription
idstringId
parentstring \| nullId of the parent folder; null at the volume root
volumestringVolume id
namestringName; at the volume root, the volume's name
pathstringVolume path
kind"file" \| "dir"
sizenumberBytes; 0 for folders (use the size command for the real size)
mtimenumberLast modified (ms)
mimestring"directory" for folders
read, writebooleanPermissions from readOnly and permission
locked?trueVolume root
hasDirs?booleanOnly in tree responses
url?stringDirect URL of the file when the volume has a url
trash?{ id, originalPath, deletedAt }Only in trash responses; path is the original location

VolumeInfo#

{ id, name, kind, root: Entry, readOnly, maxUploadSize: number | null, allowExtensions: string[] | null, denyExtensions: string[], trash: { retentionDays, count } | null, versions: { maxPerFile, retentionDays } | null }

trash and versions are null when that feature is off.

InitResult#

{ volumes: VolumeInfo[], chunkSize, version, thumbnails: { sizes, extensions } | null, images: { extensions, formats } | null }

FileVersion#

{ id, size, createdAt, reason }. reason tells what replaced the version: edit, upload, replace, optimize, revert…

StorageStats#

FieldDescription
volumeVolume id
files, dirs, sizeUser files only (trash, versions and cache excluded)
categoriesRecord<"image" \| "video" \| "audio" \| "document" \| "archive" \| "code" \| "other", { files, size }>
largestThe largest files (Entry[]), biggest first
versions{ files, count, size, orphaned, items: VersionedFile[] } (items at most 200)
trash{ count, size }
cache{ files, size }: thumbnail cache
capacity{ total, free } \| null: the disk, if the driver can report it
truncatedtrue if the volume is too large to scan completely
scannedAtScan time (ms)

VersionedFile: { id, path, name, exists, count, size, latest }. exists: false means the file was deleted or moved outside theFinder, so its versions are orphaned.

TransformResult#

FieldDescription
id, nameSource file
before, after?Source and result size (bytes)
width?, height?Result dimensions
entry?The written file (the source itself if written in place)
created?true if a new file was created next to the source
skipped?"larger" (the result didn't get smaller) or "unsupported" (the format can't be processed)
error?Error code if this file failed

This page is generated from docs/api/core.md.