Storage
You need files. Avatars, invoices, exports, generated thumbnails. In dev they live on disk; in production they live in S3 or R2 or DigitalOcean Spaces. The code should not care which.
Warlock’s storage layer is a manager singleton that wraps one or more drivers. Calls go to the active driver by default; storage.use("name") returns a scoped view of any other configured driver. Every write returns a StorageFile — a thin OOP handle with rich properties (url, name, hash) and chainable methods (copy, move, delete). Same API, every driver.
Mental model
Section titled “Mental model”flowchart LR
code["storage.put(buffer, location)"]
manager["storage<br/><i>manager singleton</i>"]
scoped["scoped view<br/><i>storage.use('s3')</i>"]
driver["driver<br/><i>local · s3 · r2 · spaces</i>"]
file["StorageFile<br/><i>{ path, url, hash, size }</i>"]
target["disk / bucket"]
code --> manager
manager --> driver
manager --> scoped
scoped --> driver
driver --> target
driver --> file
Three roles in this picture:
- The driver is the boring part — it knows how to put bytes into a local folder, an S3 bucket, an R2 bucket. You configure drivers in
src/config/storage.tsand rarely think about them again. - The manager (
storage) is what your code calls. It routes to the active driver and emits events. If you want a different driver for one call, youstorage.use("name")and call the same method on the scoped view. StorageFileis the return value of every write. Think of it as the file’s identity card: relative path, full URL, hash, driver. Pass it to your model, persistfile.path, and you have everything you need to fetch the file back later.
Configuration
Section titled “Configuration”src/config/storage.ts declares your drivers — one entry per disk. The framework ships factories that stamp the right driver field on the options:
import { env, type StorageConfigurations, storageConfigurations, storagePath,} from "@warlock.js/core";
const storageOptions: StorageConfigurations = { default: "local", drivers: { local: storageConfigurations.local({ root: storagePath(), urlPrefix: "/uploads", }), aws: storageConfigurations.aws({ accessKeyId: env("AWS_ACCESS_KEY_ID"), secretAccessKey: env("AWS_SECRET_ACCESS_KEY"), region: env("AWS_REGION"), bucket: env("AWS_S3_BUCKET"), urlPrefix: "/uploads", }), r2: storageConfigurations.r2({ bucket: env("R2_BUCKET"), endpoint: env("R2_ENDPOINT"), accessKeyId: env("R2_ACCESS_KEY_ID"), secretAccessKey: env("R2_SECRET_ACCESS_KEY"), accountId: env("R2_ACCOUNT_ID"), region: env("R2_REGION", "auto"), publicDomain: env("R2_BASE_URL"), }), spaces: storageConfigurations.spaces({ accessKeyId: env("DO_KEY"), secretAccessKey: env("DO_SECRET"), region: env("DO_REGION"), bucket: env("DO_BUCKET"), endpoint: env("DO_ENDPOINT"), }), },};
export default storageOptions;default names the disk that calls go to when no driver is specified. The factories (storageConfigurations.local|aws|r2|spaces) just stamp the matching driver field on the options — beyond that, every field is forwarded straight to the underlying driver.
| Factory | Driver | Required fields |
|---|---|---|
storageConfigurations.local() | local | root (filesystem path), optional urlPrefix for public URLs |
storageConfigurations.aws() | s3 | accessKeyId, secretAccessKey, region, bucket |
storageConfigurations.r2() | r2 | accessKeyId, secretAccessKey, bucket, accountId |
storageConfigurations.spaces() | spaces | accessKeyId, secretAccessKey, region, bucket, endpoint |
Cloud factories accept any S3-compatible option in addition to the required fields — endpoint for custom S3-API hosts, prefix for an auto-prepended key prefix, retry for backoff config.
Per-environment switching
Section titled “Per-environment switching”The common pattern: local in dev, cloud in production, same code path.
const storageOptions: StorageConfigurations = { default: env("STORAGE_DRIVER", "local"), drivers: { local: storageConfigurations.local({ root: storagePath(), urlPrefix: "/uploads" }), r2: storageConfigurations.r2({/* ... */}), },};Set STORAGE_DRIVER=r2 in production, leave unset in dev — the same storage.put(...) writes to local files in dev and to R2 in prod.
The shape
Section titled “The shape”import { storage } from "@warlock.js/core";
const file = await storage.put(buffer, "uploads/photo.jpg");
console.log(file.url); // "/uploads/uploads/photo.jpg" or full https URL on cloudconsole.log(file.name); // "photo.jpg"console.log(file.path); // "uploads/photo.jpg"console.log(file.hash); // sha256:abc123...Changed in 5.21:
storage.put(string, location)always stores the string as content. To upload a file that already exists on local disk, usestorage.putFromPath(localPath, location). Code that passed a local path toput()must switch toputFromPath(). Cloudlist()prefixes now end in/.
That’s the entire surface for the common case. storage.put(...) writes to the default driver and returns a StorageFile. Persist file.path to your DB and you can rebuild a StorageFile later with storage.file(path).
storage is the lowercase singleton — there is no Storage.disk() static API. Don’t construct it yourself; the framework wires it on boot.
Writing files
Section titled “Writing files”Every put* returns a StorageFile:
import { createReadStream } from "node:fs";
// From a Bufferconst file = await storage.put(buffer, "uploads/photo.jpg");
// From a Buffer with explicit metadataawait storage.put(buffer, "uploads/photo.jpg", { mimeType: "image/jpeg", cacheControl: "max-age=31536000", visibility: "public",});
// From a stream — best for large files (no whole-buffer copy in memory)await storage.putStream(createReadStream("./video.mp4"), "uploads/video.mp4");
// From a remote URL — Warlock downloads then storesawait storage.putFromUrl("https://example.com/image.jpg", "uploads/image.jpg");
// From a base64 data URL — MIME extracted from the prefix automaticallyawait storage.putFromBase64("data:image/png;base64,iVBORw0KGgo...", "uploads/photo.png");
// From a local file on disk (put(string) stores the string itself, not the file)await storage.putFromPath("/tmp/report.pdf", "uploads/report.pdf");
// From a multipart UploadedFile — pull from `request.file()` / `request.validated()`await storage.put(request.file("avatar"), `avatars/${userId}.jpg`);For uploads, the usual pattern is UploadedFile.save(directory, options?) — that lives on the file object itself. See File uploads for the full flow.
putFromUrl refuses private, loopback, link-local and cloud-metadata hosts before it downloads. The classifier behind that check, isPrivateOrReservedIp, is also exported on its own from @warlock.js/core/net. That subpath loads only node:net and the classifier — never the HTTP server, database, cache or mail modules the root @warlock.js/core entry boots — so a worker, CLI or standalone agent process can use the same rules without bundling the framework:
import { isPrivateOrReservedIp } from "@warlock.js/core/net";
isPrivateOrReservedIp("10.0.0.1"); // trueisPrivateOrReservedIp("169.254.169.254"); // true (cloud metadata)isPrivateOrReservedIp("93.184.216.34"); // falseIt is the same function the root @warlock.js/core exports, so both entries apply identical rules.
Put options
Section titled “Put options”type PutOptions = { mimeType?: string; // Override the guessed MIME metadata?: Record<string, string>; // Custom metadata — cloud drivers only cacheControl?: string; // Cache-Control header — cloud drivers only contentDisposition?: string; // Content-Disposition header — cloud drivers only visibility?: "public" | "private"; // Cloud drivers only};Pass any combination as the third arg to put / putStream / putFromUrl / putFromBase64. mimeType is the only one that affects the local driver — the rest are cloud-only knobs that the local driver silently ignores.
Reading files
Section titled “Reading files”// Buffer — loads the whole file into memoryconst buffer = await storage.get("uploads/photo.jpg");
// Stream — best for large filesconst stream = await storage.getStream("uploads/video.mp4");
// JSON — convenience wrapper that buffers and parsesconst config = await storage.getJson("config/settings.json");
// Existence check (no body read)const exists = await storage.exists("uploads/photo.jpg");
// Metadata only — no body readconst info = await storage.metadata("uploads/photo.jpg");// → { path, name, size, isDirectory, lastModified?, mimeType?, etag?, ... }
// Just the byte countconst bytes = await storage.size("uploads/photo.jpg");get() is fine for small files (images, JSON, PDFs). For anything multi-MB, prefer getStream() and pipe — you don’t want a 200 MB video sitting in the heap.
Create-only writes — putIfAbsent
Section titled “Create-only writes — putIfAbsent”const file = await storage.putIfAbsent("<urlset/>", "sitemaps/index.xml");
if (file === null) { // something already exists at that location}putIfAbsent(file, location, options?) writes atomically and only if nothing exists. It returns the StorageFile, or null when the location is taken. Unlike put, a string is treated as content, not a path. The local driver writes a temp file and hard-links it; S3 and R2 send If-None-Match: *. DigitalOcean Spaces doesn’t support it. Check storage.supportsPutIfAbsent() first — a driver without it throws StorageCapabilityError.
Delete, copy, move
Section titled “Delete, copy, move”await storage.delete("uploads/photo.jpg");// → true if deleted, false if not found
await storage.deleteMany(["a.txt", "b.txt", "c.txt"]);// → DeleteManyResult[] — { location, deleted, error? } per file
await storage.copy("uploads/photo.jpg", "backups/photo.jpg");// → StorageFile at backups/photo.jpg
await storage.move("uploads/temp.jpg", "uploads/photo.jpg");// → StorageFile at uploads/photo.jpg (the original is gone)deleteMany is one batch operation on cloud drivers (one S3 DeleteObjects call) — much faster than N round-trips. The result has per-file status so you can tell which ones failed.
Directory operations
Section titled “Directory operations”await storage.copyDirectory("uploads/temp", "uploads/final");// → number of files copied (uses concurrency 5 by default)
await storage.moveDirectory("uploads/temp", "uploads/final");// → number of files moved
await storage.emptyDirectory("uploads/temp");// → number of files deleted (directory itself stays)
await storage.deleteDirectory("uploads/temp");// → true if directory deleted
// Upload an entire local directory tree into storageconst result = await storage.putDirectory("./public/assets", "cdn/assets", { concurrency: 10, filter: (_, relative) => !relative.startsWith("."), // skip dotfiles onProgress: (done, total) => console.log(`${done}/${total}`),});// → { uploaded: StorageFile[], failed: { localPath, error }[], total }putDirectory walks the local tree, streams each file into storage, and collects failures rather than aborting on the first error — same shape as deleteMany.
Listing files
Section titled “Listing files”const files = await storage.list("uploads", { recursive: true, limit: 100, cursor: previousResponse.cursor, // for pagination});
// → StorageFileInfo[] — [{ path, name, size, isDirectory, lastModified?, mimeType?, etag? }]list() returns one page at a time. The cursor field is driver-specific on cloud (S3 continuation token); local lists everything in one shot. For very large directories on cloud, page through with the cursor.
// Public URL — sync, fast, no roundtripconst url = storage.url("uploads/photo.jpg");// Local: "/uploads/uploads/photo.jpg"// S3: "https://bucket.s3.amazonaws.com/uploads/photo.jpg"// R2 + publicDomain: "https://cdn.acme.com/uploads/photo.jpg"
// Temporary signed URL — expires in N secondsconst tempUrl = await storage.temporaryUrl("private/doc.pdf", 3600);// Local: HMAC-signed token URL// Cloud: presigned URL via the SDKFor private files, prefer temporaryUrl over building your own URL — the signing/token machinery is already there.
Rate limits on served files
Section titled “Rate limits on served files”Files served straight from disk by the HTTP layer — the public folder
(storage.publicRoot, under storage.publicPrefix), router.directory() mounts
and router.file() routes — are exempt from the global http.rateLimit quota by
default, so a page’s assets do not spend the client’s budget. Set
http.rateLimit.staticFiles: true to count them.
A route you register with uploadedFileController is an application route: it
can render image variants on demand, so it stays under the global limiter.
Exempt it explicitly when that suits your traffic:
import { router, uploadedFileController } from "@warlock.js/core";
router.get("/uploads/*", uploadedFileController, { rateLimit: false });See Rate limiting → Static files.
Scoped storage — storage.use(name)
Section titled “Scoped storage — storage.use(name)”For a single call against a non-default driver, scope to it:
const r2File = await storage.use("r2").put(buffer, "exports/data.csv");const localFile = await storage.use("local").put(buffer, "tmp/preview.jpg");
console.log(r2File.url); // https://...console.log(localFile.url); // /uploads/...storage.use(name) returns a ScopedStorage view with the same surface — put, get, delete, copy, move, list, everything, including the directory operations (deleteDirectory, copyDirectory, moveDirectory, emptyDirectory) and putFromUrl. It is typed as ScopedStorage (before 5.30 it was typed as the narrower ScopedStorageContract, so those calls needed a cast). Use it when you want one call to route somewhere other than the default without flipping the global default.
To change the global default, storage.setDefault("name") — but you almost always want per-call scoping for clarity. A wandering setDefault in a service is a great way to confuse the next reader.
Image variant cache
Section titled “Image variant cache”uploadedFileController renders image variants (?variant=thumb) on demand and caches each derivative on disk, in uploads.images.cacheDirectory or <disk root>/.cache/image-variants. Every derivative of one source sits in a directory of its own, so the cache follows the source:
- Deleting or moving a file through a local disk (
storage.delete,deleteMany,move,deleteDirectoryfor every file under the directory, and the same calls onstorage.use("name")) removes its derivatives. - Rewriting a source in place (new size or mtime) renders fresh derivatives on the next request and removes the old version’s.
- Anything else — a file removed outside storage, a cache you want to reset — call
purgeImageVariants:
import { purgeImageVariants } from "@warlock.js/core";
await purgeImageVariants("machines/12/photo.webp"); // one file's derivativesawait purgeImageVariants(undefined, { disk: "private" }); // a disk's whole cacheA configured cacheDirectory is shared by every local disk (derivatives are keyed by the disk’s real root, so they never collide), so purging it without a path clears every disk’s derivatives.
Use a dedicated derivative-only folder, including when the cache is inside a storage root. Full and per-file purges remove only recognized generated image, source-manifest and temporary files beneath matching shard/source/generation directories. They retain the cache root, directories, unrelated files and symlinks. Background eviction validates the same layout; it may also remove empty generated generation directories, while user folders and unrelated entries are never touched. Per-file purges reject symlinked shard and source directories. Stale-generation pruning follows the same recognized-file rules and keeps the current generation, unrelated files, directories and symlinks intact. Reserve generated-looking paths for derivatives. Reclaim files from older cache layouts manually.
Bound cache storage and age
Section titled “Bound cache storage and age”uploads.images.maxCacheBytes defaults to 512 MiB and maxCacheAge defaults to seven days in seconds. Both accept positive integers. A derivative older than the configured age is regenerated even on a cache hit. Derivative writes schedule a coalesced background check, at most once per minute. Hits check only the requested derivative’s age and never walk the cache. The check walks the cache only when it may reclaim something: the first time after boot, when the running byte estimate exceeds the budget, when the oldest retained derivative has passed maxCacheAge, and at least once per maxCacheAge; otherwise it does no directory I/O. The sweep removes expired derivatives and then evicts the oldest until the byte budget fits, retaining the file being returned even when it alone exceeds that budget. A running byte estimate is reconciled with disk during each sweep. The sweep also removes writer temporaries older than one hour, left behind by a crashed write (younger ones may still be renamed into place and are kept). A generation directory left without derivatives loses its source.json manifest and then the directory itself, once it is older than five minutes, so a directory a writer has just created survives; the directory is removed only when empty and never through a symlink. Old object-storage generations, left behind when a source’s ETag changes, are reclaimed this way through normal age and size eviction. The budget can be exceeded until the next sweep; idle caches are cleaned after their next generated derivative. Use Number.MAX_SAFE_INTEGER for a practically unbounded budget. The cache directory must not equal or contain any configured local disk root, including disks registered at runtime. Generation and purge share this guard and compare realpaths, so symlinks cannot hide an overlap; an invalid setting throws ImageVariantsConfigError before removing cache data, and so does a cache or disk root that cannot be resolved (for example a cache beneath a regular file, ENOTDIR, or EACCES; the original error is its cause). The guard’s result is remembered per cache directory, disk root and set of configured local roots, so repeated requests do not resolve the same paths again; registering another disk changes that set and is checked anew. These limits cover derivatives across all disks sharing the configured cache directory. Concurrent readers can observe eviction and should retry resolution if a returned path disappears before it is read (the built-in controller does this).
Variants of other disks
Section titled “Variants of other disks”The default route serves the default disk. Serve another local disk with createUploadedFileController, behind whatever check the files need:
import { createUploadedFileController } from "@warlock.js/core";
router.get("/invoices/*", createUploadedFileController({ disk: "private" }), { middleware: [authMiddleware()],});Or resolve a variant yourself, inside a route that already decides access:
import { resolveImageVariant } from "@warlock.js/core";
const variant = await resolveImageVariant({ disk: "private", path, variant: "thumb" });
if (!variant) return response.notFound();
return response.sendBuffer(await fs.readFile(variant.absolutePath), { contentType: variant.contentType,});resolveImageVariant applies the same guards as the route: the path must stay inside the disk root and outside the cache, only declared variants and formats render, and source size and pixel limits hold. It returns undefined for a missing file and throws HttpError (400, 413, 415) for a request the route would refuse.
StorageFile — the OOP handle
Section titled “StorageFile — the OOP handle”Every put* / copy / move returns a StorageFile. You can also build one for an existing path:
const file = storage.file("uploads/photo.jpg");Sync properties (no I/O)
Section titled “Sync properties (no I/O)”| Property | Description |
|---|---|
file.path | full storage path ("uploads/photo.jpg") |
file.name | basename ("photo.jpg") |
file.extension | lowercased extension, no dot ("jpg") |
file.directory | parent directory |
file.driver | driver name ("local" / "s3" / "r2" / "spaces") |
file.url | public URL (uses cached value when available) |
file.hash | sha256, set by put* operations |
file.absolutePath | filesystem path — local driver only, else undefined |
file.isDeleted | true after file.delete() |
Async data
Section titled “Async data”await file.data(); // full StorageFileData — size, mimeType, url, hash, driverawait file.size();await file.mimeType();await file.lastModified();await file.etag(); // cloud only — undefined for localawait file.metadata(); // StorageFileInfo — info object the driver returns from HEADContent reads
Section titled “Content reads”const buffer = await file.contents();const stream = await file.stream();const text = await file.text(); // utf-8const base64 = await file.base64();const dataUrl = await file.dataUrl(); // "data:image/jpeg;base64,..."Operations
Section titled “Operations”await file.copy("backups/photo.jpg"); // → new StorageFile pointing at the copyawait file.move("archive/photo.jpg"); // → this instance, with updated pathawait file.rename("photo-v2.jpg"); // → this instance, moved within same directoryawait file.delete(); // marks `isDeleted = true`await file.exists(); // booleanawait file.temporaryUrl(3600); // signed URL for this specific fileType guards
Section titled “Type guards”await file.isImage(); // mimeType.startsWith("image/")await file.isPdf(); // application/pdfawait file.isDocument(); // application/*await file.isExcel(); // openxml spreadsheet OR vnd.ms-excelawait file.isDoc(); // msword OR openxml wordprocessingmlawait file.isAudio(); // audio/*await file.isVideo(); // video/*Useful for routing logic — if (await file.isImage()) { thumbnail = await new Image(buffer).resize(...).toBuffer(); }.
Cloud-only operations
Section titled “Cloud-only operations”Some methods only work on cloud drivers (s3, r2, spaces). Calling them on local throws with a clear message — check the driver if you’re in cross-driver code.
Presigned URLs
Section titled “Presigned URLs”For direct browser-to-cloud uploads and downloads — the server hands the client a signed URL, the client uploads/downloads directly to/from the bucket, your server never sees the bytes.
// Download URL — client GETs the file directlyconst downloadUrl = await storage.use("r2").getPresignedUrl("private/doc.pdf", { expiresIn: 3600, // seconds});
// Upload URL — client PUTs bytes directlyconst uploadUrl = await storage.use("r2").getPresignedUploadUrl(`uploads/${userId}/${filename}`, { expiresIn: 3600, contentType: "application/pdf", maxSize: 50 * 1024 * 1024, metadata: { uploadedBy: userId },});
return response.success({ uploadUrl });The client uploads with:
await fetch(uploadUrl, { method: "PUT", headers: { "Content-Type": "application/pdf" }, body: file,});The contentType you pass into getPresignedUploadUrl must match what the client sends — S3 enforces it server-side and rejects mismatches with 403.
Visibility and storage class
Section titled “Visibility and storage class”await storage.use("aws").setVisibility("uploads/photo.jpg", "public");await storage.use("aws").setVisibility("private/doc.pdf", "private");
const v = await storage.use("aws").getVisibility("uploads/photo.jpg");
await storage.use("aws").setStorageClass("archive/old.zip", "GLACIER");Visibility maps to S3 ACLs (or the cloud-equivalent). Storage class lets you flip files to cheaper, slower tiers — GLACIER, STANDARD_IA, etc. Both are no-ops on the local driver and throw if called against it.
Cloud bucket / region
Section titled “Cloud bucket / region”const cloud = storage.useCloud("aws");
const bucket = await cloud.getBucket();const region = await cloud.getRegion();
await cloud.getPresignedUrl("private/doc.pdf");useCloud(name) is the same as use(name) but with the cloud-specific contract — useful when TypeScript needs to know the driver definitely has presigned URLs.
Temporary URLs on the local driver
Section titled “Temporary URLs on the local driver”Local storage can’t issue presigned URLs (there’s no S3 to sign against), so it signs with an HMAC token instead. The flow: generate a token with temporaryUrl, expose a route that validates the token, and serve the file:
import { storage } from "@warlock.js/core";
const signedUrl = await storage.temporaryUrl("private/doc.pdf", 3600);// → "/temp-files/<encoded-token>"
// In a route that handles the token:const result = await storage.validateTemporaryToken(token);
if (!result.valid) { return response.forbidden({ error: result.error }); // result.error is one of: "expired" | "invalid_signature" | "invalid_token" | "missing_key" | "file_not_found"}
// For local driver — use response.sendFile for efficiencyif (result.absolutePath) { return response.sendFile(result.absolutePath);}
// For other drivers — streamconst stream = await result.getStream!();stream.pipe(response.raw);Cloud drivers return { valid: false, error: "invalid_token" } from validateTemporaryToken — they validate via the presigned URL on the cloud side, not here. If you’re in cross-driver code, check result.valid and result.absolutePath and fall back to streaming.
The local driver reads its HMAC key from the signatureKey option on the driver config — set it in src/config/storage.ts if you use temporary URLs in dev.
Runtime driver registration
Section titled “Runtime driver registration”For multi-tenancy or runtime config — e.g. each tenant has its own R2 bucket — register a driver at runtime:
storage.register("tenant-r2", { driver: "r2", bucket: tenant.bucket, accountId: tenant.accountId, accessKeyId: tenant.key, secretAccessKey: tenant.secret,});
await storage.use("tenant-r2").put(buffer, "data.csv");storage.register(name, config) clears any cached driver instance for that name, so the next access rebuilds with the new config. The cached instance is process-local — if you have multiple processes you need to register per process (or use storageDriverContext to scope per request, which is the request-scoped multi-tenant pattern).
Storage events
Section titled “Storage events”The manager emits lifecycle events for cross-cutting concerns (logging, analytics, audit):
storage.on("afterPut", ({ location, file, driver }) => { log.info("storage", "uploaded", `${file?.size} bytes to ${driver}:${location}`);});
storage.on("afterDelete", ({ location, driver }) => { analytics.track("file_deleted", { driver, path: location });});The full event list:
| Event | Fires |
|---|---|
beforePut | Before write |
afterPut | After successful write |
beforeDelete | Before delete |
afterDelete | After delete |
beforeCopy | Before copy |
afterCopy | After copy |
beforeMove | Before move |
afterMove | After move |
Event handlers receive a typed payload — { driver, location, timestamp } plus event-specific extras (file on the after-events, from/to on copy/move). Subscribe in src/app/<module>/main.ts if the listener is module-specific, or in a top-level main.ts for global handlers.
storage.off(event) removes all handlers for a given event type — useful in tests, rare in production.
Common patterns
Section titled “Common patterns”Switching disk per environment
Section titled “Switching disk per environment”Set STORAGE_DRIVER=r2 in production .env, leave unset in dev. Same code path everywhere:
const file = await storage.put(buffer, `avatars/${userId}.jpg`);// dev → ./storage/uploads/avatars/123.jpg// prod → s3://bucket/avatars/123.jpgUploading a request file
Section titled “Uploading a request file”import type { RequestHandler } from "@warlock.js/core";import { storage } from "@warlock.js/core";
export const uploadAvatarController: RequestHandler = async ({ request, response }) => { const upload = request.file("avatar")!; const file = await storage.put(upload, `avatars/${request.locals.user!.uuid}/${upload.fileName}`);
return response.successCreate({ url: file.url, hash: file.hash, path: file.path, });};The real codebase wraps this in an UploadedFile.save(...) shortcut — see File uploads — but the underlying mechanic is storage.put(upload, location).
Direct browser upload via presigned URL
Section titled “Direct browser upload via presigned URL”The classic large-file pattern. Server returns a signed URL; client uploads directly to R2 / S3.
import { storage } from "@warlock.js/core";
export async function issueUploadUrlService(userId: string, filename: string, mimeType: string) { const uploadUrl = await storage.use("r2").getPresignedUploadUrl(`uploads/${userId}/${filename}`, { expiresIn: 600, contentType: mimeType, });
return { uploadUrl, key: `uploads/${userId}/${filename}` };}Client uploads with fetch(uploadUrl, { method: "PUT", body: file }). Server never sees the bytes — they go straight to the bucket.
Persisting a StorageFile to a model
Section titled “Persisting a StorageFile to a model”Real example from the reference codebase — the Upload model wraps a storage.file(path):
import { storage, type StorageFile } from "@warlock.js/core";import { Model, RegisterModel } from "@warlock.js/cascade";
@RegisterModel()export class Upload extends Model<UploadSchema> { public static table = "uploads";
public storageFile(): StorageFile { return storage.file(this.get("path")); }}Then anywhere you have an Upload, you can upload.storageFile().contents(), .url, .delete(), etc. — same OOP handle, lazy resolution.
Gotchas
Section titled “Gotchas”storageis the lowercase singleton. Don’tnew Storage()— your config won’t reach the instance, and the framework’s references won’t see your instance.storage.path(location)only works on the local driver. Cloud drivers throw. If you’re in cross-driver code, check the driver before calling it.StorageFile.move()updates the instance in place. Afterawait file.move("..."), the samefilereference now points at the new location. The old path is gone. (copy()returns a new instance; the original stays valid.)- Local URLs are relative (
/uploads/...). Prefix withbaseUrlfromconfig.get("app").baseUrlif you need absolute URLs for emails or external callers. Cloud drivers return absolute URLs already. - Presigned uploads enforce content-type at the cloud side. The
contentTypeyou pass togetPresignedUploadUrlmust match what the client sends. Mismatch returns 403 from the cloud, not from your server. - Driver name keys are case-sensitive.
"r2", not"R2". Match the keys insrc/config/storage.tsexactly. putFromUrldownloads to the server first. It’s not a server-side copy — the bytes flow through your process. For huge files in cloud-to-cloud copies, usestorage.copy(from, to)(same driver only) or build a copy command via the cloud SDK directly.- The
prefixdriver option auto-prepends to every key. If you setprefix: "tenant-42"and callstorage.put(buffer, "avatar.jpg"), the file lands attenant-42/avatar.jpg. This is by design for multi-tenant isolation — but it meansstorage.list("tenant-42")would double-prefix. Use bare paths in the call, let the driver add the prefix. - Treat storage keys and locations as untrusted input. Never feed a raw request value (a filename, an
:id, a?path=query) straight intostorage.get(...),storage.put(...), orstorage.path(...). A key like../../etc/passwdis a path-traversal attempt. The local driver rejects paths that escape the storage root, but you should still sanitize and validate keys yourself — restrict them to a known prefix per tenant/user and strip..segments — so an attacker can’t read or overwrite another tenant’s files. Cloud drivers don’t have a filesystem root to contain you, so key sanitization is on you there.
See also
Section titled “See also”- Running on multiple servers — why local storage is a single-server choice (
storage.silenceSingleServerWarning). - File uploads — the
UploadedFile.save(...)pattern and validation. - Image processing — the
Imageclass for transforms before saving. - Recipe: Upload to S3 — end-to-end cloud upload flow.
End-to-end: variants on a non-default disk
Section titled “End-to-end: variants on a non-default disk”S3, R2 and Spaces sources can use the same variants through their storage driver. Derivatives remain on the application’s writable local filesystem: the default cache is <app>/storage/.cache/image-variants, or set uploads.images.cacheDirectory explicitly outside public/.
Configure an explicit public image disk while keeping ordinary uploads local:
import { env, storageConfigurations, storagePath, type StorageConfigurations,} from "@warlock.js/core";
export default { default: "local", drivers: { local: storageConfigurations.local({ root: storagePath("uploads"), urlPrefix: "/uploads" }), media: storageConfigurations.r2({ accountId: env("R2_ACCOUNT_ID"), accessKeyId: env("R2_ACCESS_KEY_ID"), secretAccessKey: env("R2_SECRET_ACCESS_KEY"), bucket: env("R2_BUCKET"), publicDomain: env("R2_PUBLIC_DOMAIN"), prefix: "catalog", }), },} satisfies StorageConfigurations;Set those environment values and make the catalog prefix publicly readable
through your CDN. For private images, use an authenticated route and signed
original URLs instead. Configure the variant and a writable cache outside every
upload root:
import type { UploadsConfigurations } from "@warlock.js/core";
export default { images: { variants: { thumb: { width: 320, height: 240, fit: "cover" } }, formats: ["webp"], cacheDirectory: "/var/cache/shop/image-variants", maxCacheBytes: 512 * 1024 * 1024, maxCacheAge: 7 * 24 * 60 * 60, },} satisfies UploadsConfigurations;Mount the route in a discovered app routes file. This route serves only the
explicit media disk, regardless of the default disk:
import { createUploadedFileController, router } from "@warlock.js/core";
router.get("/catalog-images/*", createUploadedFileController({ disk: "media" }));After app boot, store a known JPEG and generate its descriptor in your ingest
service or command. The key is relative to the configured catalog prefix:
import { readFile } from "node:fs/promises";import { generateImageVariants, storage } from "@warlock.js/core";
export async function importImage() { await storage.use("media").put(await readFile("photo.jpg"), "products/photo.jpg"); return generateImageVariants("products/photo.jpg", { disk: "media", variants: ["thumb"], urlPrefix: "/catalog-images", });}The original URL comes from R2/CDN; the derivative is served by the app at
/catalog-images/products/photo.jpg?variant=thumb&format=webp. Fetch it with
curl -I 'http://localhost:3000/catalog-images/products/photo.jpg?variant=thumb&format=webp';
expect an image content type. Each app instance has its own bounded derivative
cache. A hit checks remote metadata without downloading an ETag-versioned source;
cache housekeeping is eventual and runs off the response path. Reusing a storage
key invalidates its generation when the metadata changes. Purge the old local
cache explicitly after remote deletion or moves when immediate cleanup matters.
The shorter API example below uses a disk named r2; use media with the complete
configuration above:
import { createUploadedFileController, generateImageVariants } from "@warlock.js/core";
router.get("/cloud-images/*", createUploadedFileController({ disk: "r2" }));
const image = await generateImageVariants("products/photo.jpg", { disk: "r2", urlPrefix: "/cloud-images",});// image.src uses the storage driver's original URL.// image.variants.thumb.url is /cloud-images/products/photo.jpg?variant=thumb.Declare thumb in uploads.images.variants, and pass image to the web image component or persist it with the owning record. The remote controller serves variant queries; original requests return 404, so serve originals through the driver’s CDN URL. Selecting disk: "r2" explicitly opts this route into remote serving. The default uploadedFileController keeps returning 404 for a cloud default disk; createUploadedFileController({ remote: true }) deliberately enables that default disk. An unguarded remote route exposes images throughout its bucket/prefix, including objects otherwise accessed only through signed URLs. For private objects, guard the variant route and supply a signed original URL separately. resolveImageVariant({ disk: "r2", path, variant: "thumb" }) supports authenticated custom routes too.
Each request reads the source’s metadata. With an ETag, the ETag, byte size and last-modified timestamp identify the cached generation, and a hit does not download the source. Without an ETag, the bytes are downloaded and hashed to identify the generation. Concurrent downloads for the same disk, path and version are shared; ingest generation downloads once for all variants and formats. Downloads enforce maxSourceBytes on the actual bytes, and maxSourcePixels is checked before rendering. Bucket identity and the effective tenant prefix isolate sources. Remote delete/move operations do not purge local derivatives automatically: call purgeImageVariants(path, { disk: "r2" }) for immediate cleanup, or let the size/age policy reclaim them. Purging without a path clears the shared cache.