Storage
The django-storages counterpart: one small file API, two drivers behind it. Calling code never knows which driver is active.
The API
Live from the source:
/** Map a supported image content-type to a file extension, or null. */
export function extForImage(contentType: string): string | null {
return EXT_BY_TYPE[contentType] ?? null;
}
/** A collision-resistant object key under a prefix. */
export function uploadKey(prefix: string, ext: string): string {
return `${prefix}/${randomUUID()}.${ext}`;
}
export async function putObject(key: string, body: Buffer, contentType: string): Promise<void> {
await store().put(key, body, contentType);
}
export async function getObject(
key: string,
): Promise<{ body: Buffer; contentType: string } | null> {
try {
return await store().get(key);
} catch {
return null;
}
}
export async function removeObject(key: string): Promise<void> {
try {
await store().remove(key);
} catch {
// Best-effort cleanup — a failed delete shouldn't break the caller.
}
}
/**
* The app path that serves a stored object (via the file-proxy route).
*
* The key travels as a query param (not a path segment) on purpose: Vite's dev
* server intercepts request paths ending in an asset extension (`.png`, `.jpg`,
* …) and 404s them before they reach our server route. Keeping the extension in
* the query string sidesteps that, and behaves identically in production.
*/
export function fileUrl(key: string): string {
return `/api/files?key=${encodeURIComponent(key)}`;
}getObject returns null on any miss, removeObject is best-effort — uploads and
deletes never crash a user action over storage hiccups.
Driver selection
| Environment | Driver |
|---|---|
AZURE_STORAGE_ACCOUNT set | Azure Blob (account-key auth via AZURE_STORAGE_KEY) |
| otherwise | MinIO / any S3-compatible via S3_ENDPOINT, S3_ACCESS_KEY, S3_SECRET_KEY, S3_BUCKET |
Local development and on-prem both run MinIO (it is in the compose file); a cloud deployment flips to Blob by setting two app settings. Same code path either way — that is the point of the seam.
The file proxy
Browsers never talk to the object store directly. fileUrl(key) returns an app path
(/api/files?key=…) and the consumer mounts a small proxy route that streams the
object through getObject — access control stays in the app, storage credentials
stay on the server. The example's src/routes/api.files.ts is the reference
implementation.
Conventions
- Key layout:
uploadKey(prefix, ext)→prefix/<uuid>.<ext>; prefix by domain (avatars/<userId>,attachments/<projectId>/<taskId>) so cleanup and access checks are scoped lookups. - Server-only. The package's
browserexport is a throwing stub; URLs for the client are produced server-side withfileUrl.