Skip to content

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 ​

ts
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):

ParameterPositionRequiredDefaultDescription
bucket1styes—An R2Bucket (the Workers binding, or any object implementing the same head/get/put/delete/list methods).
serializerRegistry2ndyes—Maps file extension → StorageSerializer, same as every other StorageRepository.
defaultFileExtension3rdyes—Extension used for newly created objects when no other extension is determined.
ignoreList4thno['**/.keep', '**/.DS_Store', '**/Thumbs.db', '**/desktop.ini', '**/.catalog', '**/.laikacms']Glob patterns excluded from listings.
determineExtension5thnodefaultDetermineExtensionCallback 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 .keep object so it survives listing.
  • createdAt survives overwrites. R2's uploaded timestamp resets on every put, which would otherwise clobber an object's original creation time on update. This repository stores the original createdAt in R2 custom metadata (x-laika-created-at) on create, and carries it forward on every subsequent updateObject.
  • Eventual consistency on writes. After createObject/createOrUpdateObject writes, 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 returned StorageObject from 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. createObject fails with EntryAlreadyExistsError if an object already exists under any registered extension.
  • Capabilities. getCapabilities() reports pagination as in-memory offset/page slicing (no cursor support) and changes: unsupportedChanges — R2 has no live change notifications, so subscribeChanges is 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/listAtomSummaries return total: 0 with no data, plus a recoverableError (NotFoundError) callers can inspect. This matches storage-fs's listAtoms (though not its listAtomSummaries, 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, and storage-github-cdn, which silently return total: 0 either way. storage-s3 (any S3-compatible client wrapped via createS3Bucket) inherits this exact behavior, since it plugs into the same R2StorageRepository. 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. Use laikacms/assets/obsidian or a filesystem-based repository for local development.

Installation ​

bash
pnpm add laikacms

Usage ​

ts
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 ​

OptionTypeRequiredDescription
bucketR2BucketYesThe Cloudflare R2 bucket binding from the Workers environment.
sanitizerSanitizerYes*A Sanitizer instance that strips privacy-sensitive metadata (EXIF, GPS, …) from uploaded files. See Sanitizer below.
createUrl(key: string) => stringNoMaps a storage key to a public URL. If omitted, getUrls returns the raw key.
createVariations(key: string) => Record<string, AssetVariation>NoReturns named variations (thumbnails, WebP versions, etc.) for an asset key. See Image variations below.
dangerouslyAllowAllFilestrueNoBypasses 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:

ts
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:

ts
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:

ts
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]=logo

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

ts
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:

ts
import type { R2AssetsRepositoryOptions } from 'laikacms/assets/r2';

Released under the MIT License.