Bucket
S3 storage with presigned uploads and event handlers for created and removed objects.
Declaring a bucket
import { Bucket } from "@vorynza/wisp";
export const uploads = new Bucket("uploads", {
cors: [{ origins: ["*"] }],
});| Option | Type | Description |
|---|---|---|
cors | BucketCorsRule[] | Optional. Each rule takes origins, and optional methods/headers. |
Presigned uploads
The common pattern: a route hands the client a short-lived URL to upload directly to S3, without the object ever passing through your Lambda.
export const presignUpload = api.post("/uploads/presign", {}, async () => {
const key = `uploads/${crypto.randomUUID()}.bin`;
const url = await uploads.presignedPut(key, { expiresIn: 300 });
return { status: 200, body: { key, url } };
});Reading and writing directly
const bytes = await uploads.getObject(key);
await uploads.putObject(key, bytes);Reacting to events
export const onUpload = uploads.on("created", async (object) => {
const body = await uploads.getObject(object.key);
await heartbeats.put({ id: `upload:${object.key}`, size: body.length });
});
export const onRemove = uploads.on("removed", async (object) => {
// object.key, object.size, object.eventType
});.on() accepts "created" or "removed". Unlike Queue, a bucket can have more than one subscription — you can
register a handler for each event type independently.
What this deploys to
One AWS::S3::Bucket, with an AWS::Lambda::Permission and NotificationConfiguration entry per subscription. The
bucket's name is a pseudo-parameter (Fn::Sub on ${AWS::AccountId}), never a Ref/GetAtt on the bucket resource
itself — S3 bucket names must be globally unique, and this also sidesteps the classic S3-notification circular
dependency CloudFormation would otherwise reject at deploy time.
A handler that calls .getObject() gets s3:GetObject; .putObject()/.presignedPut() get s3:PutObject — always
scoped to <bucket-arn>/* (object-level), never the bare bucket ARN.

