Routes
Named file routes — server-owned keys, constraints, and object policies.
A route is a named storage policy on your server. Each route owns its
constraints, object keys, and enabled operations (upload, download,
delete, and upload.multipart).
The browser sends a route name with each request. The server enforces file constraints, validates permissions, and generates object keys.
import { dimahS3, route } from "@dimah-s3/server";
import { awsS3 } from "./aws-s3";
export const s3 = dimahS3({
client: awsS3,
bucket: process.env.S3_BUCKET!,
routes: {
avatar: route({
upload: {
fileTypes: ["image/*"],
maxFileSize: 2 * 1024 * 1024,
},
download: true,
delete: true,
}),
document: route({
upload: {
fileTypes: ["application/pdf", "image/*"],
maxFileSize: 10 * 1024 * 1024,
},
download: true,
delete: true,
}),
video: route({
upload: {
fileTypes: ["video/*"],
maxFileSize: 500 * 1024 * 1024,
multipart: true,
},
}),
},
});awsS3 is the S3Client from Server Setup.
Combining operations
Enable operations on the same route when they manage the same objects. For example, if users upload, view, and remove their avatars:
avatar: route({
upload: {
fileTypes: ["image/*"],
maxFileSize: 2 * 1024 * 1024,
},
download: true,
delete: true,
}),With the default keyPrefix (the route name), download and delete must target
keys within that route's namespace. That is route isolation, not per-user
ownership — see
Security.
Key generation and object identity
By default, the server generates object keys formatted as:
{keyPrefix}/{uuid}/{sanitizedName}keyPrefix defaults to the route name (avatar → avatar/…, document
→ document/…). The same prefix is used twice:
- Upload and multipart init — the server generates the key. The client sends a route name, not an object key. The key travels inside the upload ticket, so confirm and the later multipart calls never send it.
- Download and delete — the client sends a key. The server accepts it only if it sits under that route's prefix.
So DELETE ?route=avatar&key=avatar/{uuid}/photo.jpg is valid, and
DELETE ?route=avatar&key=document/{uuid}/cv.pdf returns KEY_INVALID.
Two routes cannot share or nest prefixes (files and files/images);
dimahS3() throws at startup.
Disabling namespace isolation
keyPrefix: false allows download and delete to target any safe key in the
bucket. Use it only when strict route or operation guards enforce the complete
access policy.
To customize key generation or organize files by user or tenant, return
keyFolder or key from upload.object:
avatar: route({
upload: {
fileTypes: ["image/*"],
maxFileSize: 2 * 1024 * 1024,
object: async ({ request }) => {
const session = await getSession(request);
return {
keyFolder: `users/${session.userId}`,
// Results in: avatar/users/{userId}/{uuid}/{name}
};
},
},
}),object runs after the instance and route guards, so it can also read
locals instead of loading the
session again.
Keys are limited to 1024 UTF-8 bytes. A generated key turns ${ in the file
name into _{. On a method: "POST" route, a key from object that
contains ${filename} returns KEY_INVALID, so the presigned form always
pins the exact key.
Route lifetimes and sizes (upload.expiresIn, upload.confirmWindow,
upload.maxFileSize, upload.multipart.partSize,
upload.multipart.ticketExpiresIn, and download.maxExpiresIn) must be
positive integers. Any other value throws at dimahS3().
Type reference
import type {
DimahS3RouteConfig,
UploadConfig,
DownloadConfig,
DeleteConfig,
MultipartConfig,
UploadObjectInfo,
} from "@dimah-s3/server";DimahS3RouteConfig
Prop
Type
UploadConfig
Prop
Type
UploadObjectInfo
What upload.object returns.
Prop
Type
DownloadConfig
Prop
Type
DeleteConfig
Prop
Type
Frequently asked questions
Return a deterministic key from object. S3 replaces the object at that
key when the new upload lands:
avatar: route({
upload: {
fileTypes: ["image/*"],
maxFileSize: 2 * 1024 * 1024,
object: async ({ request }) => {
const session = await getSession(request);
return {
key: `avatars/${session.userId}/avatar.png`,
};
},
},
}),With db(), an upload onto a key that already holds an active
object fails with CONFLICT. Allow it per route with
plugins: { db: { replace: "overwrite" } }.
Return previousKey from upload.object. After the new upload is confirmed
and every hook succeeds, dimah-s3 deletes the previous object from S3:
avatar: route({
upload: {
fileTypes: ["image/*"],
maxFileSize: 2 * 1024 * 1024,
object: async ({ request }) => {
const user = await getCurrentUser(request);
return {
keyFolder: `users/${user.id}`,
previousKey: user.currentAvatarKey,
};
},
},
}),previousKey must sit under the same route prefix.
Return them from upload.object. They are signed into PUT, POST, and
multipart init (and applied by s3.put) so the browser cannot change
them. Tag values are visible to the client — do not put secrets there.
avatar: route({
upload: {
fileTypes: ["image/*"],
object: () => ({
storageClass: "STANDARD_IA",
cacheControl: "max-age=31536000, immutable",
tagging: { purpose: "avatar" },
}),
},
}),Tags must be printable ASCII, at most 10 per object. User metadata may hold
any text: non-ASCII values are RFC 2047-encoded on write and decoded on read.
Metadata keys and values share a 2 KB budget. Going over either limit is a
config error that returns 500 and is logged.
Set checksum: true. The React client hashes the file in the browser, and
the server signs the SHA-256 into the upload, so S3 rejects any other bytes.
Multipart uploads hash and sign every part:
avatar: route({
upload: {
fileTypes: ["image/*"],
maxFileSize: 2 * 1024 * 1024,
checksum: true,
},
}),A presign or part request without a checksum returns CHECKSUM_REQUIRED.
GET /routes reports the setting as upload.checksum, so custom clients
know to send one.
Set verifyContent: true. After confirm, the server reads the first bytes of
the object and compares their signature with the declared type. A mismatch
deletes the object and returns FILE_CONTENT_MISMATCH. An upload without a
type is checked against the type its name gives, so x.png is checked as
image/png:
avatar: route({
upload: {
fileTypes: ["image/png", "image/jpeg", "image/webp"],
verifyContent: true,
},
}),verifyContent turns checksum on, because the signed checksum pins the
bytes that were checked. Setting checksum: false with it throws at
dimahS3(). Types without a known signature, such as text/plain, pass
without a read.
Set mode: "proxy" on download. The returned URL points to the
/api/s3/file endpoint, which streams the object through your server. See
File Proxy:
avatar: route({
download: {
mode: "proxy",
},
}),