File Storage
@forinda/kickjs-storage puts every place an app keeps files behind one Storage interface. The code that saves an avatar stays the same when it moves from a local folder in development to a bucket in production, or to memory in tests.
Example
file-storage-api: uploads, signed URLs, local disk in development and S3/MinIO in production. More in Examples.
pnpm add @forinda/kickjs-storageOr kick add storage. It has no SDK dependency: the S3 driver signs requests itself (AWS Signature V4) and sends them with fetch.
The main entry, with memoryStorage and s3Storage, uses only fetch and WebCrypto, so it runs on Node, Bun, Deno and Cloudflare Workers. diskStorage is Node only and comes from @forinda/kickjs-storage/disk.
The interface
interface Storage {
put(key: string, body: Body, options?: { contentType?: string }): Promise<void>
putIfAbsent(key: string, body: Body, options?: { contentType?: string }): Promise<boolean>
get(key: string): Promise<Uint8Array | null> // null when there is no such file
getStream(
key: string,
range?: { start?: number; end?: number },
): Promise<ReadableStream<Uint8Array> | null>
stat(key: string): Promise<FileInfo | null> // { key, size, lastModified, contentType? }
exists(key: string): Promise<boolean>
delete(key: string): Promise<void> // a missing key is not an error
list(options?: { prefix?: string; cursor?: string; limit?: number }): Promise<ListPage>
url(key: string, options?: UrlOptions): Promise<string> // expiresIn, plus response headers on buckets
}
// Bytes, text, or a stream: a web ReadableStream or a Node Readable
type Body = Uint8Array | string | AsyncIterable<Uint8Array>Three drivers implement it:
| Driver | For |
|---|---|
memoryStorage({ baseUrl? }) | Tests. Files live in a Map and are gone when the process exits. |
diskStorage({ root, baseUrl? }) | A directory on the server. Writes go to a temp file and are renamed into place. |
s3Storage({ bucket, region?, ... }) | AWS S3, Cloudflare R2, MinIO, and other S3-compatible APIs. Adds direct uploads. |
Setting it up
kick add storage, or kick new --packages storage, installs the package and writes src/adapters/storage.ts:
// src/adapters/storage.ts
import { StorageAdapter } from '@forinda/kickjs-storage/adapter'
import { diskStorage } from '@forinda/kickjs-storage/disk'
import { s3FromSettings } from '@forinda/kickjs-storage/settings'
export { STORAGE } from '@forinda/kickjs-storage/adapter'
export type { Storage } from '@forinda/kickjs-storage'
export const storageAdapter = () =>
StorageAdapter({
storage: process.env.S3_BUCKET ? s3FromSettings() : diskStorage({ root: 'uploads' }),
})It also adds storageAdapter() to bootstrap({ adapters }), the S3_* variables to .env.example, and uploads/ and .tus/ to .gitignore. Files go to the uploads/ folder until S3_BUCKET is set, then to that bucket. Change the file to pick drivers differently.
StorageAdapter({ storage }) binds the storage to the STORAGE token, so it can be injected anywhere. A storage from s3FromSettings() also brings its settings section, so a missing S3_BUCKET stops startup. A second storage binds its own token:
const BACKUPS = createToken<Storage>('app/storage/backups')
adapters: [
storageAdapter(),
StorageAdapter.scoped('backups', {
storage: s3FromSettings(S3Settings.scoped('BACKUPS')),
token: BACKUPS,
}),
]Each StorageAdapter also reports to /health/ready: it lists one key (list({ limit: 1 })), so an unreachable bucket or rejected credentials mark it down. With S3 the credentials need list permission on the bucket for the check to pass; healthCheck: false reports it up without checking.
Saving an upload
@Controller()
class AvatarController {
@Autowired(STORAGE) private files!: Storage
@Post('/')
@FileUpload({ mode: 'single', fieldName: 'avatar', allowedTypes: ['png', 'jpg'] })
async upload(ctx: RequestContext) {
const key = `avatars/${randomUUID()}`
await this.files.put(key, ctx.file!.buffer, { contentType: ctx.file!.mimetype })
ctx.json({ key, url: await this.files.url(key, { expiresIn: 600 }) })
}
}File Uploads covers @FileUpload, which buffers the file to memory on every runtime, so ctx.file.buffer is what you pass to put().
Streaming
put() takes a stream as well as bytes. Disk writes it to the file as it arrives. A bucket gets one request if the stream fits in one part, and a multipart upload if not, with four parts in flight at once. A failed part aborts the upload, so no half-written parts are left behind. Memory use stays at about partSize × partConcurrency (20 MiB by default) however large the file is. Tune both on s3Storage: partSize is at least 5 MiB, S3's minimum, and since S3 allows 10,000 parts, raise it for streams over about 48 GiB.
import { createReadStream } from 'node:fs'
await files.put('exports/2026-10.csv', createReadStream('/tmp/export.csv'), {
contentType: 'text/csv',
})getStream() reads without holding the file in memory. Hand it to ctx.sendResponse() to send it on, on any runtime:
@Get('/:id/download')
async download(ctx: RequestContext) {
const key = `reports/${ctx.params.id}.pdf`
const [info, body] = await Promise.all([this.files.stat(key), this.files.getStream(key)])
if (!info || !body) return ctx.problem.notFound()
await ctx.sendResponse(
new Response(body, {
headers: { 'content-type': info.contentType ?? 'application/pdf', 'content-length': String(info.size) },
}),
)
}For a bucket, redirecting to await files.url(key) is usually better: the browser downloads straight from the bucket and your server sends nothing.
Range requests
Pass { start, end } to getStream() for part of a file: both are byte offsets counted from 0, and end is included, as in an HTTP Range: bytes=start-end header. On disk that reads only those bytes; on a bucket it sends a Range header. An end past the file stops at its end, and a start past it reads nothing. Answer a Range request so PDFs open fast and video can seek:
@Get('/:id/video')
async video(ctx: RequestContext) {
const key = `videos/${ctx.params.id}.mp4`
const info = await this.files.stat(key)
if (!info) return ctx.problem.notFound()
const headers = { 'content-type': 'video/mp4', 'accept-ranges': 'bytes' }
// Single ranges only; `bytes=-500` (the last 500) is left out here.
const match = /^bytes=(\d+)-(\d*)$/.exec(String(ctx.headers.range ?? ''))
if (!match) {
const body = await this.files.getStream(key)
return ctx.sendResponse(new Response(body, { headers: { ...headers, 'content-length': String(info.size) } }))
}
const start = Number(match[1])
const end = Math.min(match[2] ? Number(match[2]) : info.size - 1, info.size - 1)
if (start > end) {
return ctx.sendResponse(new Response(null, { status: 416, headers: { 'content-range': `bytes */${info.size}` } }))
}
const body = await this.files.getStream(key, { start, end })
await ctx.sendResponse(
new Response(body, {
status: 206,
headers: { ...headers, 'content-range': `bytes ${start}-${end}/${info.size}`, 'content-length': String(end - start + 1) },
}),
)
}Listing
list() returns files whose key starts with prefix, in key order, up to limit (default and maximum 1000) at a time. Pass the returned cursor back for the next page. No cursor means it was the last page.
let cursor: string | undefined
do {
const page = await files.list({ prefix: `users/${userId}/`, cursor })
for (const file of page.files) await files.delete(file.key)
cursor = page.cursor
} while (cursor)Without a delimiter, listing isn't split into folders: prefix: 'a/' includes a/b/c.txt. On disk, a page reads only the folders it needs: the ones that can hold keys after the cursor and under the prefix. Each folder's sorted listing is kept until the folder changes (a file added or removed), so paging through a folder of a million files reads it once.
Folders: delimiter
Pass delimiter: '/' to list one level at a time, as a file browser does. Keys that continue past the next / collapse into a single entry in prefixes:
const page = await files.list({ prefix: 'users/42/', delimiter: '/' })
page.files // users/42/avatar.png, users/42/cv.pdf
page.prefixes // ['users/42/invoices/', 'users/42/photos/']Files and prefixes are counted together against limit and paged together, the same as S3's CommonPrefixes. Open a folder by listing it as the next prefix. On disk, a folder with no files in it isn't a prefix, which matches buckets, where folders exist only through the keys in them.
Create-only writes: putIfAbsent
putIfAbsent(key, body) stores the body only if nothing is stored under key yet. It returns true when it stored it and false when the key was taken. It is atomic: of several callers racing for the same key, on one server or many, exactly one gets true. Use it for idempotency markers, claims and "first writer wins" records:
// Process a webhook once, however many times it is delivered.
if (!(await files.putIfAbsent(`webhooks/${event.id}`, JSON.stringify(event)))) return
await handle(event)Buckets use S3 conditional writes (If-None-Match: *), which AWS S3, Cloudflare R2 and MinIO support. Disk uses an atomic hard link. A stream is written as it arrives: on a bucket, a body larger than one part goes up in parts, and completing the upload is the conditional step, so two large writers racing for one key still leave exactly one.
Joining objects in the bucket: compose
s3Storage has compose(key, sources), which joins objects already in the bucket into key, in order. The bucket does the copying (CopyObject, or UploadPartCopy in ranges of up to 5 GiB), so the bytes never pass through your server:
await files.compose('reports/2026.csv', ['reports/q1.csv', 'reports/q2.csv', 'reports/q3.csv'])S3 requires every part except the last to be at least 5 MiB. If an earlier source is smaller, compose streams the sources through instead, so it always works and copies inside the bucket whenever it can. files.location is the bucket's address: two storages with the same location are the same bucket.
Keys
A key is a /-separated path, like avatars/42.png. Every driver refuses a key that is empty, starts with /, has an empty, . or .. segment, or contains \ or NUL. So a key built from user input can't reach outside the disk root or into another object. checkKey(key) runs the same check if you want it earlier.
Build keys from ids you control, such as avatars/${user.id}.png or a randomUUID(), not from the uploaded file's name. Two users uploading photo.jpg would otherwise overwrite each other.
Where disk files go
A relative root resolves from process.cwd() when diskStorage() is called. That is the same base KickJS uses for .env files, assets and the SPA adapter, so ./uploads sits next to them in the project root. Run kick dev and kick start from the project root, and in production set the working directory (Docker WORKDIR, systemd WorkingDirectory, pm2 cwd). To put files elsewhere, pass an absolute path, such as /var/lib/myapp/uploads on a mounted volume.
URLs
Buckets:
url(key, { expiresIn })returns a presigned GET URL that works forexpiresInseconds. The default is 3600 and the maximum is 604800 (7 days), which is S3's limit. The bucket can stay private.contentType,contentDispositionandcacheControlare signed into the URL, and the bucket sends them asContent-Type,Content-DispositionandCache-Controlwith the file:tsconst link = await files.url(key, { contentType: 'application/pdf', contentDisposition: 'inline; filename="invoice.pdf"', cacheControl: 'private, max-age=600', })S3 has no such parameter for other headers, such as
X-Content-Type-Options. A cache lifetime longer than the URL'sexpiresIndoesn't help: the cached URL stops working first.Disk and memory:
url(key)isbaseUrlfollowed by the key, each segment URL-encoded. WithoutbaseUrlit throws. Serving that directory is up to the app, for example withexpress.static('./uploads')mounted at/uploads, or behind a CDN. These URLs don't expire, so don't put private files under a publicbaseUrl.
Uploads straight to the bucket
Large files shouldn't pass through your server. With s3Storage, the server signs a URL and the browser sends the file directly to the bucket. Your server checks who may upload what, picks the key, and never handles the bytes.
One request: uploadUrl()
const MAX = 20 * 1024 * 1024
@Post('/uploads')
async start(ctx: RequestContext) {
const { size, type } = ctx.body as { size: number; type: string }
if (size > MAX) throw HttpException.badRequest('File too large')
if (!['image/png', 'image/jpeg'].includes(type)) throw HttpException.badRequest('Unsupported type')
const key = `uploads/${ctx.get('user')!.id}/${randomUUID()}`
ctx.json({ key, url: await this.files.uploadUrl(key, { size, contentType: type, expiresIn: 300 }) })
}// browser
const { key, url } = await api.post('/uploads', { size: file.size, type: file.type })
await fetch(url, { method: 'PUT', body: file, headers: { 'content-type': file.type } })size and contentType are signed into the URL, so the bucket refuses a body of any other size or a different Content-Type. Check them against your limits before you sign. Without size, the URL accepts any size up to S3's 5 GiB limit for a single upload. When the browser reports success, files.stat(key) confirms the file arrived and how big it is.
Size unknown up front: uploadForm()
uploadUrl() fixes the exact size, so the browser must say it first. uploadForm() signs a form (an S3 POST policy) that accepts any size within a range instead. The bucket enforces the range, the key and the content type:
const TYPES = ['image/png', 'image/jpeg', 'application/pdf']
@Post('/uploads/form')
async form(ctx: RequestContext) {
const { type } = ctx.body as { type: string }
if (!TYPES.includes(type)) throw HttpException.badRequest('Unsupported type')
const key = `uploads/${ctx.get('user')!.id}/${randomUUID()}`
ctx.json(await this.files.uploadForm(key, { maxSize: 20 * 1024 * 1024, contentType: type }))
}// browser: the fields first, the file last
const { url, fields } = await api.post('/uploads/form', { type: file.type })
const form = new FormData()
for (const [name, value] of Object.entries(fields)) form.append(name, value)
form.append('file', file)
await fetch(url, { method: 'POST', body: form })The bucket rejects a file over maxSize with EntityTooLarge, and a different key or Content-Type with 403. contentType takes one exact type: a family such as image/* would also admit image/svg+xml, which can carry script, so check the client's declared type against your own list and sign that one. AWS S3 and MinIO support form uploads; Cloudflare R2 doesn't, so use uploadUrl() with size there. The bucket's CORS rule must allow POST.
Large and resumable: multipart
A multipart upload sends the file in parts, each through its own signed URL. A dropped connection costs one part, not the whole file. If the page reloads, listParts() says which parts the bucket already has, so only the rest are sent. The server needs five endpoints:
@Controller()
class MultipartController {
@Autowired(STORAGE) private files!: S3Storage
// Every key is under the user's own prefix, so no one can touch another user's upload.
private own(ctx: RequestContext, key: string) {
if (!key.startsWith(`uploads/${ctx.get('user')!.id}/`)) throw HttpException.forbidden()
return key
}
@Post('/')
async create(ctx: RequestContext) {
const { type } = ctx.body as { type: string }
const key = `uploads/${ctx.get('user')!.id}/${randomUUID()}`
ctx.json({ key, uploadId: await this.files.multipart.create(key, { contentType: type }) })
}
@Post('/part')
async part(ctx: RequestContext) {
const { key, uploadId, partNumber } = ctx.body as {
key: string
uploadId: string
partNumber: number
}
ctx.json({ url: await this.files.multipart.partUrl(this.own(ctx, key), uploadId, partNumber) })
}
@Post('/parts')
async parts(ctx: RequestContext) {
const { key, uploadId } = ctx.body as { key: string; uploadId: string }
ctx.json(await this.files.multipart.listParts(this.own(ctx, key), uploadId))
}
@Post('/complete')
async complete(ctx: RequestContext) {
const { key, uploadId, parts } = ctx.body as {
key: string
uploadId: string
parts: CompletedPart[]
}
this.own(ctx, key)
// The parts the bucket really has, not what the browser claims.
const size = (await this.files.multipart.listParts(key, uploadId)).reduce(
(sum, p) => sum + p.size,
0,
)
if (size > MAX) {
await this.files.multipart.abort(key, uploadId)
throw HttpException.badRequest('File too large')
}
await this.files.multipart.complete(key, uploadId, parts)
ctx.json({ key })
}
@Post('/abort')
async abort(ctx: RequestContext) {
const { key, uploadId } = ctx.body as { key: string; uploadId: string }
await this.files.multipart.abort(this.own(ctx, key), uploadId)
ctx.noContent()
}
}The browser splits the file and sends each part to its URL. It keeps the ETag header from each response, because complete needs it. Parts are numbered from 1 to 10000, and every part except the last must be at least 5 MiB.
Uppy does this client side, with retries, progress and resume after a reload. Its @uppy/aws-s3 plugin in multipart mode takes five functions (createMultipartUpload, signPart, listParts, completeMultipartUpload and abortMultipartUpload), and each one maps to one of the endpoints above.
Bucket setup
CORS. The browser calls the bucket directly, so the bucket needs a CORS rule that allows your origin to
PUT. For multipart, the rule must also expose theETagheader, or the browser can't read it:json[ { "AllowedOrigins": ["https://app.example.com"], "AllowedMethods": ["PUT"], "AllowedHeaders": ["*"], "ExposeHeaders": ["ETag"] } ]Abandoned uploads. Parts of an upload that is never completed or aborted stay in the bucket and are billed. Add a lifecycle rule that aborts incomplete multipart uploads after a day or so (
AbortIncompleteMultipartUploadon S3, and the same option on R2).
Resumable uploads through your server: tus
When files should pass through your own server, use TusAdapter. This suits disk storage, a bucket you don't want browsers writing to, or a server that checks each file. It speaks tus, the open protocol for resumable uploads, so tus-js-client, Uppy (@uppy/tus) and tus clients for iOS, Android and other platforms work with it unchanged.
import { TusAdapter } from '@forinda/kickjs-storage/tus'
bootstrap({
modules,
adapters: [
TusAdapter({
path: '/api/uploads',
storage: STORAGE, // a Storage, or a token bound to one
maxSize: 2 * 1024 ** 3,
onCreate: ({ headers, metadata, size }, id) => {
const user = verifyToken(headers.authorization) // throw HttpException.unauthorized() to refuse
if (!['video/mp4', 'application/pdf'].includes(metadata.filetype)) {
throw HttpException.badRequest('Unsupported file type')
}
// The type the file is served as: only after checking it, as above.
return {
key: `uploads/${user.id}/${id}`,
data: { owner: user.id },
contentType: metadata.filetype,
}
},
authorize: (headers, upload) => {
if (verifyToken(headers.authorization).id !== (upload.data as { owner: string }).owner) {
throw HttpException.forbidden()
}
},
onComplete: (upload) => uploads.upsert(upload.id, { key: upload.key, size: upload.size }),
}),
],
})// browser
import { Upload } from 'tus-js-client'
new Upload(file, {
endpoint: '/api/uploads',
headers: { authorization: `Bearer ${token}` },
metadata: { filename: file.name, filetype: file.type },
chunkSize: 8 * 1024 * 1024,
onProgress: (sent, total) => render(sent / total),
}).start()How it works:
- Arrival. Each request's bytes are kept as a chunk in
partials, aStorageof its own: by default adiskStorageunderdir(.tus). If the connection drops, the bytes already received are kept, and the client asks (HEAD) where to continue and sends only the rest. tus-js-client remembers upload URLs across page reloads. - Completion. When the last byte arrives, the file is streamed into
storageunder the keyonCreatechose (defaultuploads/<id>), with thecontentTypeonCreatereturned, orapplication/octet-streamwithout one. The client'smetadata.filetypeis never used as-is: a client could declaretext/htmland turn the file into a page that runs script. Check it against your own list and return it, as in the example. ThenonCompleteruns. The response to that last request waits for both, so a client that sees success knows the file is stored. If storing oronCompletefails, the client gets a 500 and its retry runs both again. SoonCompleteruns at least once, and can run twice if it succeeded but the step after it failed. Make it idempotent, for example an upsert keyed byupload.id. - Checks.
onCreatesees every new upload's headers, size and metadata before anything is written.authorizesees every later request to that upload. Withoutauthorize, anyone holding an upload's URL, which contains an unguessable id, can continue or cancel it. Throw anHttpExceptionfrom either hook to refuse with its status. - Limits.
maxSize(default 1 GiB) is checked against the declared length, and the body can never run past it. An upload takes at most 10,000 PATCH requests: each one is a chunk object and a marker, so a client sending tiny chunks can't multiply them. That is plenty for a 1 GiB file in 1 MiB chunks, and tus-js-client and Uppy send one chunk by default. Unfinished uploads expireexpiresInseconds (default one day) after their last write and are swept hourly. - Middleware. The adapter runs after the global middleware, so
cors(), rate limits andcsrf()apply. Pass the CSRF header through tus-js-client'sheaders. For a browser on another origin,cors()must expose the tus headers:exposedHeaders: ['Location', 'Upload-Offset', 'Upload-Length', 'Upload-Expires', 'Tus-Resumable']. - Finishing in the bucket. When
storageandpartialsare the same bucket (samelocation), the finished file is joined from its chunks withcompose, inside the bucket. Chunks of at least 5 MiB are copied, so set tus-js-client'schunkSizeto 5 MiB or more for large files. Smaller ones stream through. - Several instances. Pass a
partialsthat every instance shares, such as a bucket (partials: s3Storage({...}), or a DI token), and any instance can take any request of any upload. No sticky sessions or locks are needed: a chunk counts once its commit marker is written withputIfAbsent, so of two requests racing to write the same offset, one wins and the other gets 409 and asksHEADagain. With the default localpartials, have the load balancer send an upload's requests to one instance. Unfinished uploads live undertus/inpartials, so final keys undertus/are refused: with one bucket for both, such a key could overwrite another upload's records.
The protocol is tus 1.0.0 with the creation, creation-with-upload, termination, expiration, checksum and concatenation extensions:
- Checksums. A client that sends
Upload-Checksum(sha1,sha256ormd5) has each chunk checked. A mismatch answers 460 and drops the chunk, and the client sends it again. - Parallel uploads. With concatenation, a client splits a file into parts uploaded at once and then creates a final upload that joins them. Pass
parallelUploads: 3to tus-js-client.onCreateseesconcat: 'partial'for each part andconcat: 'final'for the join, andauthorizechecks every part the final upload names. Only the final upload reaches the storage and runsonComplete.
For a server that isn't KickJS, createTusHandler(options) from the same entry is the same endpoint as a Node (req, res, next) handler.
Cloudflare R2, MinIO and other S3-compatible services
Pass endpoint. Objects are then addressed path-style, as <endpoint>/<bucket>/<key>:
// Cloudflare R2
s3Storage({
bucket: 'avatars',
region: 'auto',
endpoint: `https://${accountId}.r2.cloudflarestorage.com`,
accessKeyId,
secretAccessKey,
})
// MinIO in development
s3Storage({
bucket: 'avatars',
endpoint: 'http://localhost:9000',
accessKeyId: 'minioadmin',
secretAccessKey: 'minioadmin',
})Without endpoint, the driver uses AWS's virtual-hosted address, https://<bucket>.s3.<region>.amazonaws.com.
forcePathStyle overrides that choice in either direction:
forcePathStyle: falsewith anendpointaddresseshttps://<bucket>.<endpoint host>/<key>. Use it for a provider that only serves virtual-hosted URLs.forcePathStyle: truewithout one addresseshttps://s3.<region>.amazonaws.com/<bucket>/<key>. Use it for an AWS bucket name containing dots, which breaks TLS on the virtual-hosted address.
Temporary credentials take a sessionToken. fetch replaces the global one, for a proxy or a test.
IAM roles instead of keys
On ECS, EC2 or EKS with a role, pass credentials instead of accessKeyId / secretAccessKey: a function that returns the current keys. It is called before every request and signed URL, so it must cache and refresh them itself. The AWS provider chain does both:
import { fromNodeProviderChain } from '@aws-sdk/credential-providers'
s3Storage({ bucket: 'avatars', region: 'eu-west-1', credentials: fromNodeProviderChain() })Timeouts
timeoutMs limits how long each request waits for the bucket to answer. Past it, the request is aborted and the call rejects, so a slow bucket fails the route instead of hanging it. It covers the wait for the response, not reading a body that is already arriving: a download that has started runs at the speed it gets.
s3Storage({ bucket: 'avatars', region: 'eu-west-1', credentials, timeoutMs: 5000 })Config from the environment
@forinda/kickjs-storage/settings has a settings section for a bucket. It is validated when the app starts and listed by kick env example:
# .env
S3_BUCKET=avatars
S3_REGION=eu-west-1
S3_ACCESS_KEY_ID=...
S3_SECRET_ACCESS_KEY=...
# S3_ENDPOINT=https://<account>.r2.cloudflarestorage.com
# S3_SESSION_TOKEN=...
# S3_FORCE_PATH_STYLE=trueimport { StorageAdapter } from '@forinda/kickjs-storage/adapter'
import { s3FromSettings } from '@forinda/kickjs-storage/settings'
bootstrap({ modules, adapters: [StorageAdapter({ storage: s3FromSettings() })] })s3FromSettings() reads the section on first use, after the app has bound it, and StorageAdapter declares the section, so the app validates it at startup. Without the adapter, list it yourself: bootstrap({ settings: [S3Settings] }). A missing S3_BUCKET, only one of S3_ACCESS_KEY_ID and S3_SECRET_ACCESS_KEY, an S3_ENDPOINT that isn't a URL, or an S3_FORCE_PATH_STYLE other than true / false, stops startup with every problem listed.
On a host with an IAM role, leave both keys unset and pass the provider as an option: s3FromSettings(S3Settings, { credentials: fromNodeProviderChain(), timeoutMs: 5000 }). Keys set in the environment win over credentials. With neither, the first call fails.
A second bucket reads its own variables through S3Settings.scoped('BACKUPS'), which reads BACKUPS_S3_BUCKET, BACKUPS_S3_ACCESS_KEY_ID, and so on. Pass it as s3FromSettings(Backups); the adapter that takes that storage declares it.
In tests
Override STORAGE with memoryStorage() and read back what the handler stored:
const files = memoryStorage()
const { client } = await createTestApp({
modules,
overrides: [[STORAGE, files]],
})
const res = await client().post('/api/v1/avatars').attach('avatar', png, 'a.png').expect(200)
expect(await files.exists(res.body.key)).toBe(true)To test direct uploads, or the S3 driver itself, start a real MinIO with startService('minio') from @forinda/kickjs-testing. It comes up with an empty test bucket and the credentials minioadmin / minioadmin:
const minio = await startService('minio')
afterAll(() => minio.stop())
await createTestApp({
modules,
settings: [
S3Settings.provide({
bucket: 'test',
endpoint: minio.url,
accessKeyId: 'minioadmin',
secretAccessKey: 'minioadmin',
}),
],
})Limits
- Direct and multipart uploads are bucket only.
uploadUrl(),uploadForm()andmultipartexist ons3Storage(typedS3Storage), not on disk or memory. In development, run MinIO, as shown in In tests. - Disk can't hold both
aanda/b. On disk, a key is a file and a key prefix is a folder, soput('a/b')afterput('a'), or the reverse, throws a clear error. Buckets and memory allow both. - Disk doesn't follow symbolic links out of the root. A key that names a link, or leads outside the root through a folder link, reads as missing (
get()returnsnull, so a route answers 404), and writing or deleting through such a link throws. A folder link that stays inside the root is followed. A link can't expose files outside the root. contentTypeis kept by buckets and memory, and ignored by disk. For disk, the server serving the files picks the type from the extension.- Edge runtimes.
memoryStorageands3Storagerun on Workers, Bun and Deno, so a Worker can reach R2 through its S3 API with an access key.diskStorage,TusAdapterand the settings entry are Node only.