Skip to content

Storage

Stratal provides a StorageModule for file operations backed by native Cloudflare R2 through Worker bindings. It supports multiple disk configurations, chunked uploads, presigned URLs, and path template variables. Each disk maps to an R2 bucket binding declared in your wrangler.jsonc.

Storage is built into Stratal. There is nothing to install. It talks to R2 directly through Worker bindings, with no S3 client or AWS SDK involved.

A disk is described by a StorageEntry. The disk is a logical name you reference in code, binding matches an r2_buckets binding name from your wrangler.jsonc, and root is the path prefix written into the bucket.

  1. Declare the R2 bucket binding in wrangler.jsonc:

    {
    "r2_buckets": [
    {
    "binding": "UPLOADS_BUCKET",
    "bucket_name": "my-uploads"
    }
    ],
    "vars": {
    "APP_SECRET": "your-app-secret"
    }
    }
  2. Register the StorageModule in your root module using forRoot or forRootAsync:

    import { Module } from 'stratal/module'
    import { StorageModule } from 'stratal/storage'
    @Module({
    imports: [
    StorageModule.forRoot({
    storage: [
    {
    disk: 'uploads',
    binding: 'UPLOADS_BUCKET',
    root: 'uploads/{year}/{month}',
    },
    ],
    defaultStorageDisk: 'uploads',
    presignedUrl: {
    defaultExpiry: 3600,
    maxExpiry: 604800,
    },
    }),
    ],
    })
    export class AppModule {}
PropertyTypeDescription
storageStorageEntry[]Array of disk configurations
defaultStorageDiskstringDisk name used when no disk is specified
presignedUrl.defaultExpirynumberDefault presigned URL expiry in seconds
presignedUrl.maxExpirynumberMaximum allowed expiry (up to 604800 for 7 days)
routeStorageRouteConfig?Optional config for the auto-registered storage routes

Each entry in the storage array configures a disk:

PropertyTypeDescription
diskstringUnique disk identifier referenced in code
bindingstringR2 bucket binding name from wrangler.jsonc
rootstringRoot path prefix within the bucket (supports template variables)

You can define multiple disks, each pointing at its own R2 bucket binding:

StorageModule.forRoot({
storage: [
{
disk: 'documents',
binding: 'DOCS_BUCKET',
root: 'documents/{year}',
},
{
disk: 'public-assets',
binding: 'ASSETS_BUCKET',
root: 'assets',
},
],
defaultStorageDisk: 'documents',
presignedUrl: { defaultExpiry: 3600, maxExpiry: 604800 },
})

Each binding must have a matching entry in your wrangler.jsonc r2_buckets array:

{
"r2_buckets": [
{ "binding": "DOCS_BUCKET", "bucket_name": "my-docs" },
{ "binding": "ASSETS_BUCKET", "bucket_name": "my-assets" }
]
}

When calling storage methods, you can specify a disk by name. If omitted, the defaultStorageDisk is used.

You can query the configured disk names at runtime:

const disks = this.storage.getAvailableDisks()
// ['documents', 'public-assets']

Inject StorageService and call upload with the file content, a relative path, and upload options:

import { Transient, inject } from 'stratal/di'
import { STORAGE_TOKENS, type StorageService } from 'stratal/storage'
@Transient()
export class DocumentService {
constructor(
@inject(STORAGE_TOKENS.StorageService) private readonly storage: StorageService,
) {}
async uploadDocument(content: ArrayBuffer, filename: string) {
const result = await this.storage.upload(content, `docs/${filename}`, {
size: content.byteLength,
mimeType: 'application/pdf',
metadata: { uploadedBy: 'system' },
})
return result
// { path, disk, fullPath, size, mimeType, uploadedAt }
}
}

The upload method returns an UploadResult:

FieldTypeDescription
pathstringRelative path within the disk
diskstringDisk name used
fullPathstringFull object path including the disk root
sizenumberFile size in bytes
mimeTypestringMIME type
uploadedAtDateUpload timestamp
OptionTypeRequiredDescription
sizenumberYesContent size in bytes
mimeTypestringNoMIME type
metadataRecord<string, string>NoCustom object metadata stored on the R2 object
taggingstringNoTag string stored as object metadata for lifecycle handling

Pass a disk name as the final argument to target a non-default disk:

await this.storage.upload(content, `docs/${filename}`, options, 'public-assets')

For large files or streams where the content length is unknown, use chunkedUpload:

const result = await this.storage.chunkedUpload(stream, 'videos/clip.mp4', {
mimeType: 'video/mp4',
})

Chunked upload splits the stream into R2 multipart parts under the hood. It cleans up the partial upload if any part fails. The size option is optional for chunked uploads.

const file = await this.storage.download('docs/report.pdf')
// Access the file as a stream
const stream = file.toStream()
// Or convert to a string
const text = await file.toString()
// Or convert to bytes
const bytes = await file.toArrayBuffer()
// Metadata is also available
console.log(file.contentType, file.size, file.metadata)

The download method returns a DownloadResult with the following properties:

PropertyTypeDescription
toStream()ReadableStream | undefinedFile content as a web stream
toString()Promise<string> | undefinedFile content as a string
toArrayBuffer()Promise<Uint8Array> | undefinedFile content as bytes
contentTypestringMIME type
sizenumberFile size in bytes
metadataRecord<string, string>?Custom R2 object metadata
await this.storage.delete('docs/report.pdf')

Delete is idempotent. No error is thrown if the file does not exist.

const exists = await this.storage.exists('docs/report.pdf')

This issues an R2 head request, so it does not download the file content.

Generate temporary URLs that grant time-limited access to files. The URLs point at the auto-registered storage routes and are signed with your APP_SECRET:

// Download URL (GET)
const download = await this.storage.getPresignedDownloadUrl('docs/report.pdf', 3600)
// Upload URL (PUT)
const upload = await this.storage.getPresignedUploadUrl('docs/new-report.pdf', 3600)
// Delete URL (DELETE)
const del = await this.storage.getPresignedDeleteUrl('docs/old-report.pdf', 3600)

Each method returns a PresignedUrlResult:

FieldTypeDescription
urlstringThe signed URL
expiresInnumberExpiry duration in seconds
expiresAtDateExpiration timestamp
method'GET' | 'PUT' | 'DELETE' | 'HEAD'HTTP method the URL is valid for

The expiresIn parameter is validated against the configured limits:

  • Minimum: 1 second
  • Maximum: the presignedUrl.maxExpiry value from your config (up to 604800 seconds / 7 days)
  • If omitted, presignedUrl.defaultExpiry is used

StorageModule mounts a hidden StorageController that proxies R2 operations behind signed URLs. The presigned-URL helpers return URLs that point at these routes:

MethodRouteHelper
GET/storage/:disk/*getPresignedDownloadUrl
PUT/storage/:disk/*getPresignedUploadUrl
DELETE/storage/:disk/*getPresignedDeleteUrl

The :disk segment is the disk name and the * wildcard is the file path within that disk. These routes are hidden from your generated OpenAPI docs.

Pass a route option to StorageModule.forRoot() to change the base path or opt out:

StorageModule.forRoot({
// ...storage, defaultStorageDisk, presignedUrl
route: {
basePath: '/files', // default: '/storage'
disabled: false, // default: false
},
})
OptionTypeDefaultDescription
route.basePathstring/storageBase path the storage routes are mounted under
route.disabledbooleanfalseSkip auto-registration of the storage routes

When you set a custom basePath, the presigned URLs are generated against that path automatically.

The root path in a disk configuration supports template variables that are resolved at runtime:

VariableResolved toExample
{date}Current date in YYYY-MM-DD format2026-02-26
{year}Current year2026
{month}Current month, zero-padded02

For example, a root of uploads/{year}/{month} resolves to uploads/2026/02. This keeps files organized by date automatically without any manual path construction.

Storage operations throw specific error classes. Each maps to an HTTP status, so the global error handler can translate it into the right response:

Error classStatusWhen thrown
StorageErrorvariesBase class for storage errors; extends ApplicationError
FileNotFoundError404The requested file does not exist
FileTooLargeError413The file exceeds the allowed size
InvalidFileTypeError422The file MIME type is not allowed
import {
FileNotFoundError,
FileTooLargeError,
InvalidFileTypeError,
} from 'stratal/storage'
try {
const file = await this.storage.download('docs/missing.pdf')
} catch (error) {
if (error instanceof FileNotFoundError) {
// File does not exist - return 404
}
if (error instanceof InvalidFileTypeError) {
// The MIME type is not allowed - reject the upload
}
}

For fine-grained control over large file uploads, the R2 provider implements IMultipartProvider. This is useful when you need to upload files in parts from the client, resume interrupted uploads, or handle very large files that exceed single-request limits.

The multipart methods live on the underlying provider rather than on StorageService. Inject the StorageManagerService and resolve the provider for a disk, then narrow it to IMultipartProvider:

import { Transient, inject } from 'stratal/di'
import { STORAGE_TOKENS, StorageManagerService } from 'stratal/storage'
import type { IMultipartProvider } from 'stratal/storage/providers'
@Transient()
export class UploadService {
constructor(
@inject(STORAGE_TOKENS.StorageManager)
private readonly manager: StorageManagerService,
) {}
private async provider(disk = 'uploads'): Promise<IMultipartProvider> {
return (await this.manager.getProvider(disk)) as IMultipartProvider
}
async initiateUpload(filename: string) {
const provider = await this.provider()
const { uploadId, key } = await provider.createMultipartUpload(
`uploads/${filename}`,
{ contentType: 'application/octet-stream' },
)
return { uploadId, key }
}
}

Each part must be at least 5 MB (except the last part). Parts are identified by a sequential part number starting at 1:

async uploadPart(key: string, uploadId: string, partNumber: number, data: Uint8Array) {
const provider = await this.provider()
const { etag } = await provider.uploadPart(key, uploadId, partNumber, data)
return { etag, partNumber }
}

After all parts are uploaded, complete the multipart upload by passing the list of parts:

import type { CompletedPart } from 'stratal/storage/providers'
async completeUpload(key: string, uploadId: string, parts: CompletedPart[]) {
const provider = await this.provider()
const result = await provider.completeMultipartUpload(key, uploadId, parts)
return result // { key, location? }
}

If an upload needs to be cancelled, abort it to clean up any uploaded parts:

const provider = await this.provider()
await provider.abortMultipartUpload(key, uploadId)
const provider = await this.provider()
// List uploaded parts for a specific upload
const { parts, isTruncated } = await provider.listParts(key, uploadId)
// List all in-progress multipart uploads
const { uploads } = await provider.listMultipartUploads()

The provider also exposes headObject for metadata inspection and deleteObjects for batch deletion:

const provider = await this.provider()
// Get object metadata without downloading the file
const metadata = await provider.headObject('uploads/report.pdf')
// { size, contentType, metadata } or null
// Delete multiple objects in a single request
const result = await provider.deleteObjects([
'uploads/old-file-1.pdf',
'uploads/old-file-2.pdf',
])
// { deleted: 2, errors: [] }
MethodDescription
createMultipartUpload(key, options?)Start a new multipart upload. Returns { uploadId, key }.
uploadPart(key, uploadId, partNumber, body)Upload a single part. Returns { etag, partNumber }.
completeMultipartUpload(key, uploadId, parts)Finalize the upload. Returns { key, location? }.
abortMultipartUpload(key, uploadId)Cancel and clean up an in-progress upload.
listParts(key, uploadId, partNumberMarker?)List uploaded parts. Returns { parts, isTruncated, nextPartNumberMarker }.
listMultipartUploads(keyMarker?, uploadIdMarker?)List all in-progress uploads.
headObject(key)Get object metadata. Returns { size, contentType, metadata } or null.
deleteObjects(keys)Delete multiple objects. Returns { deleted, errors }.
getBucket()Returns the R2 bucket binding name for the disk.
  • Use path templates. Leverage {year}, {month}, and {date} in your root configuration to organize files automatically.
  • Minimize disk switching. Design your disk layout so most operations use the default disk. Pass an explicit disk name only when you need a different bucket.
  • Use presigned URLs for client uploads. Generate presigned upload URLs instead of proxying file uploads through your API. This offloads bandwidth from your Worker.
  • Keep expiry times short. Use the shortest practical presigned URL expiry. The default of 1 hour is reasonable for most use cases.
  • Handle errors gracefully. Always catch storage errors. A file might not exist, the disk might be misconfigured, or the binding might be missing.
  • Separate environments. Point your disks at different R2 buckets for development, staging, and production to prevent accidental data overlap.
  • Keep APP_SECRET safe. Store it as a Worker secret in production rather than committing it to wrangler.jsonc.