
Cloudflare R2
Storage
Source: packages/laikacms/src/impl/storage-r2
A StorageRepository implementation backed by a Cloudflare R2 bucket (or anything shaped like the Workers R2Bucket binding). R2 is a flat object store, so this implementation simulates a hierarchical file system on top of it: folders are key prefixes, empty folders are .keep objects, and file extensions are handled transparently.
Why R2?
R2 storage is one of the two flagship backends for LaikaCMS: zero egress fees, S3-compatible API, and it runs natively alongside a Cloudflare Workers deployment. It requires an R2Bucket binding (from @cloudflare/workers-types, an optional peer dependency of this package), so it's typically used from a Worker — a wrangler.toml R2 bucket binding, or any object implementing the same head/get/put/delete/list surface (see laikacms/storage/s3's createS3Bucket() for an adapter that lets any S3-compatible store — including R2 via its S3 endpoint — pass as an R2Bucket-like object).
Usage
import { runTask } from 'laikacms/compat';
import { jsonSerializer } from 'laikacms/serializers/json';
import { R2StorageRepository } from 'laikacms/storage/r2';
export default {
async fetch(request: Request, env: { CONTENT_BUCKET: R2Bucket }) {
const repo = new R2StorageRepository(
env.CONTENT_BUCKET, // R2Bucket binding
{ json: jsonSerializer }, // serializerRegistry
'json', // defaultFileExtension
);
const post = await runTask(repo.getObject('posts/hello-world'));
return Response.json(post);
},
};Constructor parameters
R2StorageRepository takes positional constructor arguments (not an options object):
| Parameter | Position | Required | Default | Description |
|---|---|---|---|---|
bucket | 1st | yes | — | An R2Bucket (the Workers binding, or any object implementing the same head/get/put/delete/list methods). |
serializerRegistry | 2nd | yes | — | Maps file extension → StorageSerializer, same as every other StorageRepository. |
defaultFileExtension | 3rd | yes | — | Extension used for newly created objects when no other extension is determined. |
ignoreList | 4th | no | ['**/.keep', '**/.DS_Store', '**/Thumbs.db', '**/desktop.ini', '**/.catalog', '**/.laikacms'] | Glob patterns excluded from listings. |
determineExtension | 5th | no | defaultDetermineExtension | Callback overriding how the on-write extension is chosen. Same contract as the other repositories' determineExtension. |
Behaviour notes
- Folder simulation. Directories don't exist natively in R2; a folder is inferred from key prefixes, and an empty folder is represented by a
.keepobject so it survives listing. createdAtsurvives overwrites. R2'suploadedtimestamp resets on everyput, which would otherwise clobber an object's original creation time on update. This repository stores the originalcreatedAtin R2 custom metadata (x-laika-created-at) on create, and carries it forward on every subsequentupdateObject.- Eventual consistency on writes. After
createObject/createOrUpdateObjectwrites, the repository tries to read the object back to return the canonical server-side view. If R2's eventual-consistency window means the readback briefly fails, it emits a recoverable warning and synthesizes the returnedStorageObjectfrom the write input instead of failing — the data that was written is durable even if it isn't immediately readable. - Extension handling. Keys never carry a file extension in the public API —
getObject('posts/a')resolves whichever registered extension exists in the bucket.createObjectfails withEntryAlreadyExistsErrorif an object already exists under any registered extension. - Capabilities.
getCapabilities()reports pagination as in-memory offset/page slicing (no cursor support) andchanges: unsupportedChanges— R2 has no live change notifications, sosubscribeChangesis not supported by this implementation. - Listings on missing folders. R2 has no real folder concept, so an empty prefix listing (no objects, no common prefixes — not even a
.keep) is indistinguishable from a nonexistent one. This repository treats that case as missing:listAtoms/listAtomSummariesreturntotal: 0with no data, plus arecoverableError(NotFoundError) callers can inspect. This matchesstorage-fs'slistAtoms(though not itslistAtomSummaries, which is deliberately silent — see LCMS-809 in that package's README),storage-web,storage-web-fs, WebDAV, GitLab, and Bitbucket on both methods, but differs from@laikacms/github,storage-drizzle, andstorage-github-cdn, which silently returntotal: 0either way.storage-s3(any S3-compatible client wrapped viacreateS3Bucket) inherits this exact behavior, since it plugs into the sameR2StorageRepository. See LCMS-1004.
Testing
packages/laikacms/src/impl/storage-r2/testing.ts exports r2StorageContractCase, a StorageContractCase that backs a real R2StorageRepository with an in-memory R2Bucket stub — see the storage testkit convention for how to write your own. (The removeAtoms contract test is skipped for this backend: R2's delete is idempotent, so it can't distinguish removed-vs-skipped keys.)
Assets
Source: packages/laikacms/src/impl/assets-r2
Cloudflare R2-backed implementation of AssetsRepository for Cloudflare Workers.
Runtime: Cloudflare Workers only. This module uses the R2 Workers binding API (
R2Bucket). It cannot run in Node.js or Bun without a compatible R2 emulator. Uselaikacms/assets/obsidianor a filesystem-based repository for local development.
Installation
pnpm add laikacmsUsage
import { R2AssetsRepository } from 'laikacms/assets/r2';
import { FileSanitizerImpl } from 'laikacms/file-sanitizer';
// Recommended: provide a sanitizer to strip privacy-sensitive metadata
const assets = new R2AssetsRepository({
bucket: env.MY_BUCKET,
sanitizer: new FileSanitizerImpl(),
createUrl: key => `https://assets.example.com/${key}`,
});Constructor options
| Option | Type | Required | Description |
|---|---|---|---|
bucket | R2Bucket | Yes | The Cloudflare R2 bucket binding from the Workers environment. |
sanitizer | Sanitizer | Yes* | A Sanitizer instance that strips privacy-sensitive metadata (EXIF, GPS, …) from uploaded files. See Sanitizer below. |
createUrl | (key: string) => string | No | Maps a storage key to a public URL. If omitted, getUrls returns the raw key. |
createVariations | (key: string) => Record<string, AssetVariation> | No | Returns named variations (thumbnails, WebP versions, etc.) for an asset key. See Image variations below. |
dangerouslyAllowAllFiles | true | No | Bypasses sanitization entirely. Do not use in production. See Escape hatch below. |
* Either sanitizer or { dangerouslyAllowAllFiles: true } is required. The constructor throws at runtime if neither is provided.
Sanitizer
Uploaded files may contain privacy-sensitive metadata (GPS coordinates, device identifiers, author names, …). R2AssetsRepository requires a Sanitizer to strip this metadata before writing to R2.
The recommended implementation is FileSanitizerImpl from laikacms/file-sanitizer:
import { FileSanitizerImpl } from 'laikacms/file-sanitizer';
const sanitizer = new FileSanitizerImpl();FileSanitizerImpl handles JPEG, PNG, GIF, and WebP files. Other file types are scanned for dangerous content and rejected if any is found. See the laikacms/file-sanitizer README for details on supported formats and limitations.
The Sanitizer interface itself is exported from laikacms/sanitizer — use it when you need to pass the type explicitly or write your own implementation:
import type { Sanitizer } from 'laikacms/sanitizer';Escape hatch
If you need to accept files without any sanitization (e.g., in a tightly controlled internal tool where you own the upload pipeline end-to-end), pass { dangerouslyAllowAllFiles: true } instead of a sanitizer:
const assets = new R2AssetsRepository({
bucket: env.MY_BUCKET,
dangerouslyAllowAllFiles: true,
});This bypasses all metadata stripping and file-type checks. Uploaded files may contain
EXIF data, GPS coordinates, embedded scripts, or other sensitive information. Only use this option when you have independent controls in place (e.g., internal-only upload endpoint, antivirus scanning, audit logging).
Filtering
R2AssetsRepository supports a named filter[search] query parameter when listing resources via the assets-api:
GET /resources?filter[search]=logoThis performs a case-insensitive substring match on the resource key — the example above returns every asset whose key contains "logo" (e.g. brand/logo.png, icons/logo-dark.svg).
Sending an undeclared filter name (anything other than search) returns 400 Bad Request. Inspect GET /capabilities (attributes.filtering.filters) to see the current list of supported filter names at runtime.
Image variations
createVariations lets you expose pre-generated responsive variants (thumbnails, WebP copies, size tiers) for each asset. The callback receives the asset key and returns a named map of AssetVariation objects; these are served via GET /resources/:key?include=variations on the assets-api.
import { R2AssetsRepository } from 'laikacms/assets/r2';
import { FileSanitizerImpl } from 'laikacms/file-sanitizer';
const assets = new R2AssetsRepository({
bucket: env.MY_BUCKET,
sanitizer: new FileSanitizerImpl(),
createUrl: key => `https://assets.example.com/${key}`,
createVariations: key => ({
thumb: {
variant: 'thumb',
url: `https://assets.example.com/thumb/${key}`,
width: 200,
height: 200,
},
webp: {
variant: 'webp',
url: `https://assets.example.com/webp/${key.replace(/\.[^.]+$/, '.webp')}`,
mimeType: 'image/webp',
},
}),
});Return {} (or omit the option) for assets that have no variations.
TypeScript
The options type is exported for consumers that need to reference it explicitly:
import type { R2AssetsRepositoryOptions } from 'laikacms/assets/r2';