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
})
MethodAnswers
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.upload

Omitting 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.view

No 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 32

The 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.

On this page