@davaux/storage
Zero-dependency S3-compatible object storage client for Davaux. Uses hand-rolled AWS Signature V4 signing over fetch — no AWS SDK dependency. Works against any S3-compatible provider: Linode Object Storage, Cloudflare R2, MinIO, AWS S3.
Installation
npm install @davaux/storage
Basic setup
import { createS3Client } from '@davaux/storage'
const s3 = createS3Client({
endpoint: process.env.S3_ENDPOINT!, // hostname only, e.g. 'us-southeast-1.linodeobjects.com'
region: process.env.S3_REGION!, // e.g. 'us-southeast-1'
bucket: process.env.S3_BUCKET!,
accessKeyId: process.env.S3_ACCESS_KEY_ID!,
secretAccessKey: process.env.S3_SECRET_ACCESS_KEY!,
})
Config
| Option | Type | Description |
|---|---|---|
endpoint | string | Hostname only, no protocol — e.g. us-southeast-1.linodeobjects.com. |
region | string | Signing region — e.g. us-southeast-1. |
bucket | string | Bucket name. |
accessKeyId | string | Access key. |
secretAccessKey | string | Secret key. |
publicUrl | string | Optional CDN/custom domain base URL, used instead of the virtual-hosted-style https://{bucket}.{endpoint} URL when constructing object URLs. |
Uploading objects
Buckets are private by default on most S3-compatible providers. Pass acl: 'public-read' for objects that should be directly viewable by browsers, such as avatars or banners:
// src/routes/api/upload.post.ts
import { defineAction } from 'davaux'
import { createS3Client } from '@davaux/storage'
const s3 = createS3Client({ /* ...config */ })
export const action = defineAction(async (ctx) => {
const form = await ctx.form()
const file = form.get('avatar') as File
const buffer = Buffer.from(await file.arrayBuffer())
const { key, url } = await s3.putObject(
`avatars/123/${file.name}`,
buffer,
file.type,
{ acl: 'public-read' },
)
return { key, url }
})
putObject throws if the upload fails (non-2xx response), with the provider's status code and body in the error message.
Deleting objects
await s3.deleteObject('avatars/123/abc.png')
deleteObject treats a 404 response as success — deleting an object that's already gone is not an error.
Resolving public URLs
s3.getPublicUrl('avatars/123/abc.png')
// => 'https://my-bucket.us-southeast-1.linodeobjects.com/avatars/123/abc.png'
If publicUrl is set in the config (for a CDN or custom domain in front of the bucket), getPublicUrl uses that base instead:
const s3 = createS3Client({
// ...
publicUrl: 'https://cdn.example.com',
})
s3.getPublicUrl('avatars/123/abc.png')
// => 'https://cdn.example.com/avatars/123/abc.png'
S3Client API
| Method | Description |
|---|---|
putObject(key, data, contentType, options?) | Uploads a Buffer to key with the given content type. Returns { key, url }. |
deleteObject(key) | Deletes the object at key. No-op if it doesn't exist. |
getPublicUrl(key) | Synchronously resolves the public URL for key, without making a request. |
PutObjectOptions
| Option | Type | Description |
|---|---|---|
acl | 'private' | 'public-read' | Canned ACL applied to the object. Omit to leave the bucket's default ACL in place. |
Why no AWS SDK?
The AWS SDK pulls in a large dependency tree for functionality this package implements directly: SigV4 request signing via Node's built-in node:crypto, and uploads/deletes via fetch. This keeps the package dependency-free and works identically against any S3-compatible endpoint, not just AWS.