wispwisp
Primitives

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: ["*"] }],
});
OptionTypeDescription
corsBucketCorsRule[]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.

On this page