Server
createStorage, what an upload replaced, streaming reads, the Express and Next.js App Router routers, and at-rest encryption.
Server
createStorage
The server side of the contract: it re-runs the same validation the browser
ran, encrypts what the scope declares, and talks to a StorageProvider.
import { createStorage } from 'uploaderkit/server'
import { createGcsProvider } from 'uploaderkit/adapters/gcs'
const storage = createStorage({
scopes,
provider: createGcsProvider({ publicBucket, privateBucket }),
crypto: { encrypt, decrypt }, // required when any scope declares `encrypt`
signedUrlTtl: 300, // seconds
})| Method | Answers |
|---|---|
upload({ scope, entityId, file, uploadedBy }) | An UploadResult: the StoredFile to persist, plus replaced. |
read({ scope, key }) | Raw bytes, decrypted when the scope is encrypted. |
remove({ scope, key }) | true when the object existed. |
signedUrl({ scope, key, download, expiresIn }) | A fresh expiring URL. Throws on public scopes. |
list(prefix) | { key, size }[]. |
Construction is defensive: a private scope riding a provider that cannot sign,
or an encrypted scope without crypto, throws a ScopeError before the
first request — while a deploy can still fail loudly.
What an upload replaced
upload() answers an UploadResult — a StoredFile plus the keys the sweep
removed:
const { key, url, replaced } = await storage.upload({ scope, entityId, file })
// The bucket no longer has these. Whatever you persisted must forget them too,
// or your UI keeps rendering objects that are gone.
await db.files.deleteMany({ key: { $in: replaced } })replaced is empty unless the scope resolves to an 'entity' replace, and it
only lists what the provider confirmed deleted. The sweep runs after a
successful put — a failure between the two would otherwise leave the entity
with nothing — and a delete that fails is swallowed: the upload the caller
asked for did happen, and a stale object is not worth failing it over.
A public object's url carries a short ?v= fingerprint of its content, so a
stable-key scope (an avatar) does not keep serving the previous image from a
CDN or the browser cache after an overwrite.
Streaming reads
storage.readStream({ scope, key }) serves a file without holding it in
memory — through get a 20MB document costs its full size in RAM per
concurrent reader. The Express view handler pipes it. It degrades honestly:
a provider without getStream, or an encrypted scope whose crypto lacks
decryptStream, falls back to a buffered read wrapped in a one-chunk stream,
so callers always get one shape.
The trade of streaming decryption, stated where you decide: plaintext reaches
the consumer before the GCM tag is verified, so tampering surfaces as a
stream that breaks at the end — read() verifies before returning a single
byte.
Express
Structural request/response shapes instead of Express types, so the package stays dependency-free and any Express 4/5 app satisfies them. The app keeps ownership of multer:
import { createExpressStorageHandlers } from 'uploaderkit/server/express'
const handlers = createExpressStorageHandlers(storage, {
authorize: async req => (req.user ? { userId: req.user.id } : null),
})
const upload = multer({ storage: multer.memoryStorage() })
router.post(
'/:scope/:entityId/upload',
useAuth,
upload.single('file'),
handlers.upload
)
router.delete('/:scope/:entityId', useAuth, handlers.remove)
router.get('/:scope/:entityId/signed-url', useAuth, handlers.signedUrl)authorize returns the acting user (or {} for "allowed") to proceed, or
null to answer 401.
Next.js App Router
Same surface over the Fetch API:
// app/api/storage/[scope]/[entityId]/upload/route.ts
import { createNextStorageHandlers } from 'uploaderkit/server/next'
const handlers = createNextStorageHandlers(storage, {
authorize: async request => {
const session = await auth(request)
return session ? { userId: session.userId } : null
},
})
export const POST = handlers.uploadOmitting authorize leaves the router open — only acceptable behind an
authenticated proxy.
Encryption
An encrypted scope needs two things wired, and createStorage throws at
boot without either: the cipher, and encryptedUrl.
const storage = createStorage({
scopes,
provider,
crypto,
// Where a client can READ an encrypted object. The bucket holds
// ciphertext, so a signed URL would serve garbage — this must point at
// your authenticated view route, which decrypts on the way out.
encryptedUrl: ({ scope, entityId, key }) =>
`/api/storage/${scope}/${entityId}/view?key=${encodeURIComponent(key)}`,
})StoredFile.url for those scopes is that route, so an <img> or the
FileViewer renders the real file. Both framework adapters expose the route
as handlers.view, answering the decrypted bytes with
Cache-Control: private, no-store — decrypted content must never land in a
shared cache:
// Express
router.get('/:scope/:entityId/view', useAuth, handlers.view)
// Next App Router — app/api/storage/[scope]/[entityId]/view/route.ts
export const GET = handlers.viewNo cipher is imposed: a scope declares encrypt: true and the app injects the
CryptoHooks. createAesGcmCrypto is the reference implementation
(AES-256-GCM, layout [iv 12][tag 16][ciphertext]) so you do not hand-roll it:
import { createAesGcmCrypto } from 'uploaderkit/server'
const crypto = createAesGcmCrypto(process.env.STORAGE_KEY!) // openssl rand -hex 32The key must be exactly 64 hex characters (32 bytes) — no passphrase derivation on purpose, since deriving would let two instances run "almost the same" secret and silently produce mutually unreadable files.
Encrypted objects are stored as application/octet-stream, so nothing ever
tries to render ciphertext; read() decrypts on the way out.