Server Setup
Install `@dimah-s3/server`, set the ticket secret, configure S3, and mount your route handler.
Install
npm i @dimah-s3/server@beta @aws-sdk/client-s3Set the ticket secret
Presign returns an upload ticket that the
server signs with a secret. Set DIMAH_S3_SECRET to a random value of at
least 32 characters, the same on every instance:
# openssl rand -base64 32
DIMAH_S3_SECRET="…"dimahS3() reads it on the first upload. You can pass secret instead. When
neither is set, uploads return 500 INTERNAL_ERROR, unless NODE_ENV is
development or test: then a random secret is used and a warning is
logged.
Configure S3 and routes
Pass your @aws-sdk/client-s3 instance to dimahS3 and define named routes.
Operations (upload, download, delete, and upload.multipart) are off by
default. Turn on only what each route needs.
import { S3Client } from "@aws-sdk/client-s3";
import { dimahS3, route } from "@dimah-s3/server";
export const awsS3 = new S3Client({
region: process.env.S3_REGION,
endpoint: process.env.S3_ENDPOINT,
credentials: {
accessKeyId: process.env.S3_ACCESS_KEY_ID!,
secretAccessKey: process.env.S3_SECRET_ACCESS_KEY!,
},
requestChecksumCalculation: "WHEN_REQUIRED",
});
export const s3 = dimahS3({
client: awsS3,
bucket: process.env.S3_BUCKET!,
routes: {
avatar: route({
upload: {
fileTypes: ["image/*"],
maxFileSize: 2 * 1024 * 1024, // 2MB
},
download: true,
delete: true,
}),
},
});requestChecksumCalculation
Recent AWS SDK versions default to "WHEN_SUPPORTED", which adds checksum
parameters that a presigned browser upload cannot match. The server checks
each client before its first presign. Any value other than "WHEN_REQUIRED"
is logged as a config error and the request returns 500 INTERNAL_ERROR.
Uploading more than 5 GiB
A single presigned PUT or POST cannot exceed 5 GiB. Larger files return
FILE_TOO_LARGE. Turn on upload.multipart for them. The route's
maxFileSize still applies.
Mount route handler
Adapters are available for all major JavaScript runtimes:
import { toNextJsHandler } from "@dimah-s3/server/next";
import { s3 } from "@/lib/s3";
export const { GET, POST, PUT, PATCH, DELETE } = toNextJsHandler(s3);Cache Components
Next.js 16.4 enables Cache Components on new apps. This handler reads the
request, so presign, download, and delete run on each request. Leave the
handler uncached. A use cache wrapper would reuse one presign across
requests.
Node, Express, and Fastify
These adapters stream the request body from req, so the body cap is counted
as bytes arrive, and stream response bodies such as a /file
download to res. When the client disconnects before the response finishes,
the request signal aborts, and so does the S3 call behind it.
Behind a proxy that sets X-Forwarded-Proto and X-Forwarded-Host, pass
trustProxy so the request URL uses them:
toNodeHandler(s3, { trustProxy: true });
toExpressHandler(s3, { trustProxy: true });
toFastifyHandler(s3, { trustProxy: true });Turn it on only behind a proxy that sets those headers. Clients can send them too.
Server-side API (s3.api)
s3.api has the same methods and payloads as the browser client, and runs
them in process with the same guards and hooks. The last argument is
{ headers, signal, request, locals }; signal aborts the call and its S3
requests, like the browser option:
"use server";
import { headers } from "next/headers";
import { s3 } from "@/lib/s3";
export async function getAvatarDownloadUrl(key: string) {
return s3.api.download(
{ route: "avatar", key },
{ headers: await headers() },
);
}headers— Copied onto a syntheticGETrequest tobaseURL, or tohttp://localhostplusbasePath. Guards read cookies and auth headers from it. The request has no query or body.request— The request hooks see. Pass the incoming one when you have it; it is used as is, andheadersis ignored.locals— Trusted values that seedlocalsbefore any guard runs. HTTP requests can never set them.
await s3.api.deleteMany(
{ route: "files", keys },
{ locals: { userId: job.ownerId } },
);Plugin endpoints, such as the db() list, are HTTP only. Plugins expose their
server-side API on the instance instead (s3.db).
Server-side uploads (s3.put)
s3.put uploads from the server through the route's upload policy. It runs
upload.object, upload.guard, upload.onStart (kind: "server"), the
PutObject, and upload.onConfirmed. confirmGuard does not run.
const object = await s3.put(
{
route: "avatar",
name: "profile.png",
type: "image/png",
body: imageBuffer,
},
{ headers: await headers() },
);
// object: { key, bucket, name, size, type, eTag, metadata, … }body can be a Blob, a ReadableStream (pass size with it), a
Uint8Array, an ArrayBuffer, or a string. The object gets a
Content-Disposition with the file name. metadata is written to S3 on top
of upload.object().metadata. clientMetadata reaches the hooks and is never
written.
Type reference
import type {
DimahS3Config,
PutInput,
S3ServerCallOptions,
} from "@dimah-s3/server";DimahS3Config
Prop
Type
S3ServerCallOptions
Prop
Type
PutInput
Prop
Type
Frequently asked questions
Every failure is an APIError with a stable code and an HTTP status:
- Throw
s3Error("FORBIDDEN"), or another catalog code, to reject a call. - A plain
Errorthrown anywhere, guards included, is logged, passed toonError, and returns500 INTERNAL_ERROR.
See Errors for the full list of error codes.
By default, dimah-s3 isolates object keys by route prefix (the route name):
avatar/{uuid}/{name}Upload generates that key. Download and delete accept a client-sent key only
if it stays under the same prefix; otherwise they return KEY_INVALID. That
check is always in @dimah-s3/server. db() adds per-user
scope, not the prefix bound.
Setting keyPrefix: false disables that namespace boundary and requires strict
application guards. See
Security.
To customize the folder or key structure, use upload.object in your route definition.
Set basePath when initializing dimahS3, or baseURL when the API has its
own origin. With baseURL, proxy download URLs are absolute, and its path
must equal basePath when you set both:
export const s3 = dimahS3({
basePath: "/api/storage",
// ...
});Make sure the client specifies the same basePath (or full baseURL if hosted on a separate domain):
export const s3Client = createS3Client({
baseURL: "https://api.example.com/api/storage",
});maxRequestBodySize caps every request body, 1 MiB by default. It is counted
on the stream before parsing, so a chunked body cannot get around it. Going
over returns 413 REQUEST_TOO_LARGE. Body endpoints accept
application/json only and return 415 UNSUPPORTED_MEDIA_TYPE for anything
else.