RedwoodSDK · Part 1 of 1
What RedwoodSDK Actually Depends On
- redwoodsdk
- cloudflare
- celld
- durable-objects

RedwoodSDK is a Vite plugin that layers React Server Components and Server Functions on top of @cloudflare/vite-plugin, and it says so plainly: it targets the workerd runtime, full stop. Sessions and realtime lean on Durable Objects, the standard starter’s database is D1, file storage is R2. The question worth asking before adopting it isn’t “does this run on Cloudflare” — obviously — it’s whether that’s a hard architectural ceiling or just the only place anyone’s pointed it so far.
celld gave us a cheap way to find out. It’s a self-hosted daemon, maintained under denoland, that reimplements the same platform surface — Workers, Durable Objects with SQLite storage and WebSocket hibernation, D1, R2, KV, Queues, Workflows, Cron Triggers — and it deploys straight from the wrangler.json a project already has. If RedwoodSDK’s dependency on Cloudflare lives in its bindings and not in bespoke framework code, an unmodified build should run there.
Five failures deep, four of them mechanical
npm run build worked on the first try — no changes needed just to produce output. Pointing that output at a local celld node is where it got interesting.
celld rejected the generated wrangler.json outright: “celld deploy does not support these config keys: agent_memory, ai_search, … jsx_factory, jsx_fragment, observability, rules, …” — twenty-nine of them, because @cloudflare/vite-plugin writes out wrangler’s entire internal config surface, not just the parts a project actually sets. Fixed that, hit the next one: the build’s asset binding points at a sibling ../client directory, and celld refuses any assets.directory that escapes the project root. Fixed that, hit a third: Cloudflare’s asset pipeline writes a .assetsignore file celld doesn’t recognize.
Three friction points, all mechanical — the kind of thing you write a script for once and never think about again. The fourth wasn’t:
resolve: no stub for specifier spec=node:events
Error: stateless Worker failed to load
This happened on the bare starter. No sessions, no database, no storage — hello-world needed a Node builtin celld apparently didn’t have.
It wasn’t missing. The scanner never saw it.
celld statically scans a Worker bundle’s source for node:*/cloudflare:* imports before V8 links the module, so it knows which builtins to stub in. The regex behind that scan required whitespace right after the keyword import:
- regex::Regex::new(r#"import\s+([^;]*?)\s*from\s*["']([^"']+)["']"#).unwrap()
+ regex::Regex::new(r#"import\s*([^;]*?)\s*from\s*["']([^"']+)["']"#).unwrap()
esbuild’s minifier drops that space before a { — there’s no ASI hazard there — so a production bundle reads import{EventEmitter as e}from"node:events" with zero whitespace after import. The scanner matched nothing, the builtin never got registered, and V8 failed to link an import that celld actually implements. One character fixes it, and it isn’t specific to node:events — it breaks any nodejs_compat builtin pulled in via a brace import in minified output, which is the default shape of every production Vite/Workers build.
We patched it, added three unit tests (one of which genuinely fails against the original regex — confirmed by reverting it), and rebuilt celld from source. The same unmodified RedwoodSDK bundle came up and served a fully server-rendered React Server Components page. denoland/celld doesn’t take pull requests from forks — despite 168 forks, it has no merged-PR history at all — so the fix, tests, and repro are filed as issue #201, with the patch sitting on a public branch for whoever picks it up.
Proving the primitives, not just the runtime
SSR working proves the JavaScript runtime loads. It says nothing about whether Durable Objects, D1, and R2 actually hold state — so we wired three routes directly into the test app, one per primitive RedwoodSDK leans on for sessions, data, and storage, and hit each one repeatedly:
- Durable Object, incrementing a counter in
ctx.storageacross three separate requests:1 → 2 → 3. - D1, creating a table and counting rows across two requests:
{n:1} → {n:2}. - R2, putting an object and reading it back: exact bytes round-tripped.
All three persisted correctly. The one real gotcha along the way was ours, not celld’s: the first pass wired routes as ({ env }) => …, assuming bindings arrive through the handler’s params — every route threw Cannot read properties of undefined until we switched to RedwoodSDK’s actual convention, import { env } from "cloudflare:workers" as an ambient import. Worth knowing if you’re used to Cloudflare-only Workers code.
Turning four manual steps into one
Everything above except the regex bug is scriptable, so we wrote @softwaiz/recell — a small, dependency-free CLI that runs after vite build. It strips the exact key list celld’s own error message names (a denylist, so a key celld newly supports just passes through untouched), re-roots the config so assets.directory stops escaping the project, and drops the unsupported .assetsignore file:
$ npm run build
$ npx @softwaiz/recell dist
recell: wrote dist/wrangler.json
$ celld dev dist
Re-run against a clean build with zero manual edits, every binding and both API routes responded exactly as they did under the hand-edited config.
Where that leaves it
RedwoodSDK’s coupling to Cloudflare is config-shaped, not code-shaped — it runs unmodified once celld’s rough edges are smoothed over, and none of those edges required touching the framework itself. What’s still open: RedwoodSDK’s own vite dev assumes Miniflare underneath, so the dev-server HMR loop hasn’t been bridged to celld yet; celld’s multi-node fleet behavior is untested against anything beyond a single local node; and celld has no shared edge cache, so caches.default is a known gap for any app that leans on it.
Cloudflare didn’t turn out to be the dependency. Cloudflare’s API shape was — and that shape now runs somewhere else too.