Skip to content

Repository files navigation

boa-stack-img-transformer

A POC Cloudflare Workers + Containers setup for on-demand image transformation with R2-backed caching, deployable as a shared service that any number of consumer apps can call via a service binding.

The goal is to keep an image-processing pipeline fully on Cloudflare (no third-party CDN/image services), share one transformer across many apps, and keep worker memory flat regardless of output size.

Live demo: https://boa-stack-img-transformer-app.boxofapps.workers.dev/


What this demonstrates

  • Sharp inside a Cloudflare Container, fronted by a small Hono server, talking to its worker via a Durable Object Container class.
  • A transform() RPC method on the transformer worker that orchestrates the full pipeline (cache-check → container call → cache-write → response) so consumer apps stay thin.
  • Caller-owned R2 caching via callbacks — the transformer has no R2 binding of its own. Each consumer app passes its own cacheRead / cacheWrite closures, so one transformer can serve many apps with different buckets.
  • A non-buffering streaming path for R2.put across RPC using FixedLengthStream. This is the non-obvious bit; see the section below.
┌────────────┐     service binding RPC     ┌──────────────────────┐
│ consumer   │ ─────── transform() ──────> │ transformer worker   │
│ app worker │                             │   (Hono + RPC entry) │
│   (R2)     │ <── cacheRead / cacheWrite ─│                      │
└────────────┘                             └─────────┬────────────┘
                                                     │
                                              container.fetch()
                                                     │
                                                     ▼
                                           ┌──────────────────┐
                                           │ Cloudflare       │
                                           │ Container        │
                                           │  (Node + sharp)  │
                                           └──────────────────┘

Repo layout

.
├── apps/
│   └── app-vue/                   # demo consumer (Vue SPA + Worker server)
│       ├── server/index.ts        # GET /api/assets/* — calls transformer.transform()
│       ├── server/utils/imgTransformerUtils.ts  # preset definitions + URL parsing
│       └── wrangler.jsonc         # has R2 binding + service binding to transformer
└── services/
    └── boa-img-transformer/       # the transformer service
        ├── src/index.ts           # Worker: transform() RPC method
        ├── src/schemas.ts         # zod schemas + toLengthKnown helper + TransformParams type
        ├── src/exports.ts         # what consumer apps import from "@boxofapps/img-transformer"
        ├── container_src/server.ts  # Node + sharp inside the container
        ├── Dockerfile             # node:22-alpine + pinned pnpm@10
        └── wrangler.jsonc         # Worker + Container + Durable Object config

The non-obvious bit: streaming into R2 across RPC

R2.put accepts a ReadableStream body but rejects any stream whose length it cannot determine:

TypeError: Provided readable stream must have a known length
(request/response body or readable half of FixedLengthStream)

The container sets Content-Length on its response (it buffers the encoded output internally so it can both report a length and return a proper error status if sharp throws). That length does not survive when the response body is proxied across the Workers RPC boundary back into the consumer worker — workerd hands the consumer a fresh ReadableStream with no length metadata, and R2.put refuses it.

The fix is to pass the length alongside the stream as an explicit argument and have the caller wrap the stream in a FixedLengthStream of that exact size before R2.put. Bytes still flow through chunk-by-chunk; nothing is buffered on the worker side.

The transformer's cacheWrite callback signature is therefore:

cacheWrite: (key: string, data: ReadableStream<Uint8Array>, contentType: string, length: number) => Promise<void>

And every consumer's implementation collapses to:

import { toLengthKnown } from "@boxofapps/img-transformer";

cacheWrite: async (key, data, contentType, length) => {
  await env.R2.put(key, toLengthKnown(data, length), { httpMetadata: { contentType } });
}

toLengthKnown is just stream.pipeThrough(new FixedLengthStream(length)). FixedLengthStream also asserts the byte count at runtime, so a Content-Length mismatch errors loudly instead of silently truncating.

Why this matters

  • Memory: without this fix the only alternative is to buffer the encoded output into a Uint8Array inside one of the workers, which costs ~2 × output_size of worker heap per concurrent request. A 2 MB webp under high concurrency can pressure the 128 MB worker memory limit.
  • Discoverability: this requirement is not documented on the R2 Workers API reference page or the Streams API page. The runtime error is the only source.
  • It used to "just work": for several months the previous design passed transformResponse.body straight through to R2.put, and this succeeded — presumably because workerd's older RPC stream proxy preserved the underlying response's length more eagerly. After a dependency bump (@cloudflare/containers 0.2 → 0.3) and a normal redeploy, every cache write started failing. We don't have a clean bisect, but the FixedLengthStream contract is the only one that's stable and documented (in the error message, at least).

Memory profile

For a 1.6 MB source PNG → 160×160 webp thumbnail (~4 KB out):

Component Peak memory used
Consumer worker a small handful of bytes (stream chunks only)
Transformer worker a small handful of bytes (stream chunks only)
Container full encoded output buffered before responding (~4 KB) + sharp working set
R2 the persisted ~4 KB

The container has multi-GB of memory available and runs sharp natively, so it's the right place to buffer. The workers stay flat regardless of output size, which matters for higher-resolution presets (2k, preview4k etc.) that can produce multi-MB encoded output.


Running locally

Cloudflare Containers in wrangler dev work on macOS/Linux. On Windows you need to run the container's Node server separately and let the worker proxy to localhost:3000 (handled by MODE=development in src/index.ts).

pnpm install

In one terminal — the transformer worker:

cd services/boa-img-transformer
pnpm dev
# Windows: also run `pnpm dev:container` in another terminal

In another — the demo Vue app:

cd apps/app-vue
pnpm dev

Open the app, watch wrangler tail on either side. The Vue page requests /api/assets/img.png?p=thumb160 etc., which hits the transformer.

/api/clear-cache (POST) wipes the cached/ prefix in R2 so the next request goes through the full pipeline again.


Deploying

The two workers deploy independently. Deploy the transformer first so the consumer's service binding resolves to a worker whose RPC surface is in sync:

cd services/boa-img-transformer && pnpm run deploy
cd ../../apps/app-vue && pnpm run deploy

You'll need:

  • An R2 bucket for the consumer (its binding name in wrangler.jsonc is R2, bucket name boa-stack-img-transformer-app — change as needed).
  • The service binding name in the consumer's wrangler.jsonc must match the transformer's name (boa-stack-img-transformer-service).

Calling the transformer from your own app

Add the service binding to your wrangler.jsonc:

"services": [
  { "binding": "BOA_IMG_TRANSFORMER", "service": "boa-stack-img-transformer-service" }
]

Type the binding:

import type { BoaImgTransformerWorker } from "@boxofapps/img-transformer";

type Env = {
  BOA_IMG_TRANSFORMER: Service<BoaImgTransformerWorker>;
  R2: R2Bucket;
  // …
};

Call it:

import { toLengthKnown } from "@boxofapps/img-transformer";

const original = await env.R2.get(sourceKey);
if (original == null) return new Response("Not found", { status: 404 });

return env.BOA_IMG_TRANSFORMER.transform({
  body: original.body,
  config: {
    transform: { width: 160, height: 160, fit: "contain" },
    output: { format: "image/webp" },
  },
  cacheKey: `cached/${sourceKey}/thumb160`,
  cacheRead: async (key) => {
    const obj = await env.R2.get(key);
    if (obj == null) return null;
    return {
      body: obj.body,
      contentType: obj.httpMetadata?.contentType ?? "image/webp",
      size: obj.size,
    };
  },
  cacheWrite: async (key, data, contentType, length) => {
    await env.R2.put(key, toLengthKnown(data, length), {
      httpMetadata: { contentType },
    });
  },
});

That's the whole integration. The returned Response is the transformed image, ready to send to the browser.


Configuration knobs

  • Container sizewrangler.jsonccontainers[0].instance_type. "basic" for this demo; in production we use a custom shape ({ vcpu: 4, memory_mib: 12288, disk_mb: 4000 }).
  • Concurrencycontainers[0].max_instances. The worker uses getRandom(BOA_IMG_TRANSFORMER, 5) to fan out; raise both numbers together if you need higher throughput.
  • Cold start budgetsleepAfter on the Container subclass (currently "2m"). Containers cold-start in 5–10 s on first request after sleep; tune up to keep them warm during traffic spikes.
  • Sharp limitscontainer_src/server.ts uses limitInputPixels: 50000 * 50000 as a basic abuse guard. Tighten for untrusted inputs.

Known rough edges

Things that surfaced while building this and don't have a clean answer yet:

  1. R2.put length contract across RPC. There's no obvious way to put a length-known stream into R2 that survives the RPC stream proxy without each caller manually wrapping in FixedLengthStream. The runtime error names request/response body or readable half of FixedLengthStream as the only accepted shapes; passing a Response (with Content-Length set) across RPC also loses the length on the receiver side. An optional length field on R2PutOptions would remove the ceremony.

  2. Stream metadata across RPC. Response/Request bodies don't carry Content-Length across a service-binding hop, which is what forced this design. The behavior isn't really documented either way.

  3. Container response inversion. To satisfy R2's length requirement the container buffers the encoded output and sets Content-Length. Streaming directly from sharp to R2 would be lighter but loses the ability to return non-2xx on a sharp failure (the HTTP response is already committed as 200 once the first byte streams).

  4. Container lifecycle for spiky traffic. sleepAfter covers the common case; cold starts are 5–10 s. A "keep N warm" knob would help.

  5. @cloudflare/containers API churn. The 0.2 → 0.3 jump appears to have changed how container response streams flow back through the worker. This surfaced as a production regression on the first redeploy carrying the bump, despite no code changes on our side. Pinning the package version is recommended until the API settles.

  6. R2 bindings can't cross RPC. That's why caching is done via cacheRead / cacheWrite callbacks rather than the transformer owning the bucket directly. Workable, but extra surface area for every consumer.


License

MIT — see LICENSE if present, otherwise consider this MIT-equivalent.

About

No description, website, or topics provided.

Resources

Stars

4 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages