A user drops a 2 GB video into your form. If those bytes travel through your API, you now pay for the bandwidth twice, hold a request open for minutes, and hit the payload limit of every serverless platform you wanted to run on. The fix is to never let the file touch your API at all.
Do not proxy the bytes
The instinct is to accept the upload on a route and forward it to storage. That instinct is the problem. Your API becomes a pipe: it buffers or streams the whole file, its memory and timeout budgets are set by the largest thing anyone uploads, and on FaaS or the edge the request body cap rejects the upload before your code even runs.
Signed URLs remove your API from the data path. The client asks your API for permission, your API hands back a short-lived URL that authorises a single upload, and the client sends the bytes straight to cloud storage. Your API only ever sees small JSON.
Two small endpoints
The whole flow is two ordinary handlers. The first signs a URL that lets the client upload one object for a few minutes; the second is called after the upload finishes and records where the file landed. Neither ever reads the file.
import { EventHandler, Post } from '@stone-js/router'
@EventHandler('/uploads')
export class UploadController {
constructor ({ fileSystem, files }) {
this.fileSystem = fileSystem // the default disk, injected by @CloudFile
this.files = files // your metadata store
}
// 1. The client asks for permission to upload one object.
@Post('/sign')
async sign (event) {
const key = `uploads/${crypto.randomUUID()}.png`
// a signed, direct-to-storage PUT target: no bytes here
return await this.fileSystem.temporaryUploadUrl(key, { contentType: 'image/png', expiresIn: 300 })
}
// 2. After the direct upload succeeds, the client records the pointer.
@Post('/complete')
async complete (event) {
const { key, title } = event.get('body')
return this.files.save({ key, title }) // record the pointer, not the file
}
}The client uses the returned target to PUT the file directly to storage, then posts to /uploads/complete with the key it was given. The temporary URL expires on its own, so an unfinished upload leaves nothing to clean up.
The signing is a blueprint concern
@stone-js/cloud-file makes signing, TTLs and provider choice a blueprint concern instead of hand-written SDK glue, the same way the runtime is already an adapter concern. You declare one disk, and the default filesystem is injected as fileSystem:
@CloudFile({ driver: 's3', bucket: 'uploads', region: 'eu-west-3' })
@StoneApp({ name: 'uploads' })
export class Application {}fileSystem speaks one agnostic contract: temporaryUploadUrl for the direct upload, temporaryUrl for a private download, put and url for the rest. The s3 driver is S3-compatible, so the same code targets AWS, Cloudflare R2, MinIO and DigitalOcean Spaces by setting endpoint, and your domain never talks to a cloud SDK. The signed-url-uploads starter below is this recipe, ready to run.
Why it matters
- No big-payload API. Your routes only carry small JSON, so they fit inside FaaS and edge body limits and modest timeouts.
- Storage scales, your API does not have to. The bytes go straight to a service built for them; your API stays cheap and fast.
- Automatic cleanup. A short TTL means an abandoned upload URL simply expires. There is no orphaned request to unwind.
The two endpoints are plain routing, so read Matching and precedence for how they are chosen, and pair the submit step with Validation so the metadata you save is well-formed before it reaches your store.