Upload Hooks
Authorize uploads, track them from start to confirm, and persist verified metadata.
Enable upload operations on a named route with upload: true or an UploadConfig object.
import { dimahS3, route, s3Error } 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,
guard: async ({ request }) => {
const session = await getSession(request);
if (!session) throw s3Error("UNAUTHORIZED");
},
onConfirmed: async ({ key, object, request }) => {
const session = await getSession(request);
await db.user.update({
where: { id: session.userId },
data: { avatarKey: key, avatarSize: object.size },
});
},
},
}),
},
});Upload lifecycle flow
- route.guardhook
- objecthook
- upload.guardhook
- onStarthook
- Upload to S3client
- HeadObjectS3
- confirmGuardhook
- onConfirmedhook
route.guard: Runs after the instance guard, before the key is chosen.object: Chooses the key folder or key, S3 metadata, ACL, storage class, cache control, and tags.upload.guard: Authorizes the upload once the server-owned key is known.onStart: Runs once the upload can begin, with itsuploadTokenand confirm deadline. The presign response, with its upload ticket, is returned after it.- Confirm: HeadObject reads the stored object. The server checks the upload marker, the size against the declared size (
UPLOAD_SIZE_MISMATCH), and the route limits. WithverifyContent, it checks the first bytes too. confirmGuard: Authorizes the stored object. It sees the verifiedobjectfrom HeadObject next to the declaredfile, so checkobject. A rejection deletes it.onConfirmed: Receives the verifiedobjectfrom HeadObject, and thepreviousKeythe server deletes next. Persist the record here.
upload.guard and onStart also run for multipart init and s3.put. kind
tells them apart: "put" or "post" for a presigned upload, "multipart",
or "server" for s3.put. s3.put skips confirmGuard.
Tracking an upload
onStart is the place to record a pending upload. It receives the absolute
deadline after which the upload can never be confirmed:
upload: {
onStart: async ({ uploadToken, key, file, expiresAt, locals }) => {
await db.upload.create({
data: {
token: uploadToken,
key,
name: file.name,
userId: locals.userId,
expiresAt,
},
});
},
onConfirmed: async ({ uploadToken, object }) => {
await db.upload.updateMany({
where: { token: uploadToken, status: "pending" },
data: { status: "active", size: object.size },
});
},
},expiresAt is the ticket expiry, or 15 minutes from now for s3.put.
Presigned uploads also get url and urlExpiresAt, and multipart uploads
get uploadId.
Failure rules
| Hook | When it throws |
|---|---|
upload.guard | The error is returned. Nothing exists in S3. |
onStart | The error is returned. A multipart upload is aborted; s3.put skips PutObject. |
confirmGuard | A rejection (4xx) deletes the object, or aborts the multipart upload. |
onConfirmed, plugin | A rejection (4xx) deletes the object. Any other throw keeps it for a retry. |
onConfirmed, yours | The object is kept and the call returns 500 INTERNAL_ERROR. |
A confirm that fails for an infrastructure reason, such as an S3 network error or a guard whose database is down, keeps the object so the client can retry.
onConfirmed runs at least once per upload. Confirm is stateless, so a
retried confirm runs every check and hook again with the same
uploadToken. Make the hook safe to replay, and reject content in
confirmGuard, not in onConfirmed.
Type reference
import type {
UploadGuardContext,
UploadOnStartContext,
UploadConfirmGuardContext,
UploadOnConfirmedContext,
UploadObjectContext,
VerifiedObject,
} from "@dimah-s3/server";UploadGuardContext
Runs after upload.object assigns the key (and after the instance and route guards).
Prop
Type
UploadOnStartContext
Prop
Type
UploadConfirmGuardContext
Runs on confirm and multipart complete, after HeadObject and the size checks.
Prop
Type
UploadOnConfirmedContext
Prop
Type
VerifiedObject
The object in onConfirmed, as HeadObject reports it.
Prop
Type
UploadObjectContext
Input passed to upload.object to generate custom destination keys and metadata.
Prop
Type
Frequently asked questions
Record it in onConfirmed, where object.size comes from S3 HeadObject.
file in the earlier hooks holds the values the browser declared. Confirm
also rejects an object whose stored size differs from the declared one.
Your hook's error is logged and passed to onError, and the client gets
500 INTERNAL_ERROR. The object is kept, so a retried confirm runs the hook
again. To refuse an object, throw from confirmGuard: the object is deleted
and the client gets your error.
Return metadata from upload.object. What the client sends as metadata
arrives as clientMetadata and is never written to S3 unless you copy it:
avatar: route({
upload: {
object: async ({ file, clientMetadata }) => ({
keyFolder: "avatars",
metadata: {
"original-name": file.name,
album: clientMetadata?.album ?? "default",
},
}),
},
}),Metadata keys and values share a 2 KB budget. Non-ASCII values are encoded
for you. In onConfirmed, object.metadata returns them decoded.