- name
- cloudflare-bundler-apps
- description
- Author Cloudflare Worker Bundler-compatible apps that build and preview correctly inside a space. Use this skill whenever you scaffold, modify, or deploy a project that will be built with `@cloudflare/worker-bundler` (i.e. anything served from `/space/:name/preview/:branch/`). Covers wrangler config, project layout, static asset rules, server entry conventions, npm dependency limits, and the most common cause of blank previews (JSX in browser scripts).
This skill teaches how to build apps that deploy cleanly through the space deploy pipeline. Every project committed to a space is built by `@cloudflare/worker-bundler` (`createApp` when there are static assets, `createWorker` when there are none) and served on a Dynamic Worker via `WorkerLoader`. Get the conventions right and the preview "just works". Get them wrong and you get a blank page, a 500, or a `Build failed` error.
## How the pipeline works
When `deploy_space(branch)` is called, the space DO:
1. Reads every file from the branch's working tree (skipping `.git/`).
2. Parses `wrangler.json` / `wrangler.jsonc` / `wrangler.toml` for `main`, `compatibility_date`, `compatibility_flags`, and `[assets]`.
3. If `[assets].directory` is set, files under that directory become static assets served host-side by `handleAssetRequest`. Everything else is built into a Worker via `createApp` (with `server: <main>`).
4. If no assets directory is configured, only `createWorker({ entryPoint: <main> })` is run. The output is loaded as a Dynamic Worker; all requests go to the Worker.
5. Previews are served at `/space/:name/preview/:branch/*`. Responses with `content-type: text/html` are run through `HTMLRewriter`, which prefixes root-relative `src` / `href` / `action` attributes with the preview base path. **JS-side fetches and dynamic imports are NOT rewritten.**
Practical consequences:
- **You can rely on root-relative `src="/foo.js"` and `href="/style.css"` in HTML.** They will be rewritten to the preview path automatically.
- **You cannot rely on root-relative paths inside JS strings**: `fetch("/api/x")`, `new URL("/foo", location.origin)`, dynamic `import("/lib.js")`. These hit the wrong path under the preview prefix. Use relative paths (`./api/x`, `import.meta.url`) or read the base path from `<base href>` / a meta tag injected at build time.
- The `<base href>` tag is also rewritten if present — using it lets all relative URLs resolve against the preview path.
## Project layout
A typical full-stack space project:
```
/
├── wrangler.json # required for non-trivial setups
├── package.json # npm deps (text-only packages, see limits below)
├── src/
│ └── index.ts # server entry: export default { fetch }
└── public/ # static assets directory (configurable)
├── index.html
├── app.js # compiled, no JSX
└── styles.css
```
A static-only SPA can skip `src/` entirely — just `wrangler.json` + `public/` is enough as long as `main` points to a minimal pass-through worker or you accept that all requests fall through assets.
## Wrangler config
Minimum viable `wrangler.json`:
```json
{
"main": "src/index.ts",
"compatibility_date": "2025-04-01",
"assets": {
"directory": "./public",
"html_handling": "auto-trailing-slash",
"not_found_handling": "single-page-application"
}
}
```
Notes:
- `main` is required when there's any server code. The bundler also auto-detects `src/index.ts`, `src/index.js`, `index.ts`, `index.js` if missing.
- `compatibility_date` defaults to `2025-04-01` if omitted. Set it explicitly for newer features.
- Add `"compatibility_flags": ["nodejs_compat"]` only if you actually need Node built-ins.
- `assets.directory` is the **only** way to ship static files. Files outside this directory are bundled into the Worker or ignored — they will **not** be reachable via URL.
- `html_handling: "auto-trailing-slash"` is usually what you want for multi-page sites; SPAs should also set `not_found_handling: "single-page-application"` so deep links return `index.html`.
TOML works too (`wrangler.toml`), but the parser only handles top-level scalar fields plus an `[assets]` table — no inline tables, no env overrides. Prefer JSON.
## Server entry: one Durable Object class named `App`
**Your entire backend is one Durable Object.** Your main module exports
a class named `App` extending `DurableObject`. The platform loads it as
a [Cloudflare Durable Object
Facet](https://developers.cloudflare.com/dynamic-workers/usage/durable-object-facets/)
of the host SpaceDO and forwards every non-asset request — including
WebSocket upgrades — to your `App.fetch(request)`.
**Do not `export default { fetch }`.** A default fetch handler will be
ignored. There is no separate worker; the DO is the worker.
```ts
// src/index.ts
import { DurableObject } from "cloudflare:workers";
export class App extends DurableObject {
async fetch(request: Request): Promise<Response> {
const url = new URL(request.url);
// Lazy schema init on the first request. Idempotent.
this.ctx.storage.sql.exec(`
CREATE TABLE IF NOT EXISTS notes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
body TEXT NOT NULL
)
`);
if (url.pathname === "/api/notes" && request.method === "POST") {
const { body } = await request.json<{ body: string }>();
this.ctx.storage.sql.exec(`INSERT INTO notes (body) VALUES (?)`, body);
return new Response(null, { status: 201 });
}
if (url.pathname === "/api/notes") {
const rows = this.ctx.storage.sql
.exec(`SELECT id, body FROM notes ORDER BY id DESC`)
.toArray();
return Response.json(rows);
}
return new Response("Not found", { status: 404 });
}
}
```
Or with Hono inside the DO:
```ts
import { DurableObject } from "cloudflare:workers";
import { Hono } from "hono";
export class App extends DurableObject {
private app = new Hono()
.get("/api/hello", (c) => c.json({ msg: "hi" }))
.post("/api/notes", async (c) => {
const { body } = await c.req.json<{ body: string }>();
this.ctx.storage.sql.exec(`INSERT INTO notes (body) VALUES (?)`, body);
return c.json({ ok: true });
});
async fetch(request: Request) {
return this.app.fetch(request);
}
}
```
When `[assets]` is configured, static files take priority — your `fetch`
only sees requests that didn't match an asset. For a SPA, put
`index.html` in `public/` and `App.fetch` only handles `/api/*`.
### State and storage
State lives on `this.ctx.storage`. No `env.DB` exists; you do not need
to declare any binding. The DO's storage is a per-(space, branch)
SQLite database that survives redeploys.
- `this.ctx.storage.sql.exec(sql, ...params)` — full SQLite. Returns a
cursor with `.toArray()`, `.one()`, `.columnNames`, etc.
- `this.ctx.storage.kv.get(key)` / `.put(key, value)` — simple KV
backed by SQLite.
- `this.ctx.storage.transactionSync(() => { ... })` — atomic batch
inside the same DO instance.
```ts
// SQL
this.ctx.storage.sql.exec(`CREATE TABLE IF NOT EXISTS counter (n INTEGER)`);
const cur = this.ctx.storage.sql.exec(`SELECT n FROM counter LIMIT 1`).toArray();
const n = (cur[0]?.n as number) ?? 0;
// KV
const last = this.ctx.storage.kv.get<number>("last_seen") ?? 0;
this.ctx.storage.kv.put("last_seen", Date.now());
```
**Use the DO's storage for all persistent state** — users, sessions,
content, settings, anything that should survive a reload. Do not use
`localStorage` or `sessionStorage` for primary data; they are
per-browser, per-origin, and lost on incognito or any other browser.
### WebSockets
The platform forwards `Upgrade: websocket` requests transparently into
`App.fetch`. Use the standard Durable Object hibernation API:
```ts
export class App extends DurableObject {
async fetch(request: Request) {
const upgrade = request.headers.get("Upgrade");
if (upgrade === "websocket") {
const { 0: client, 1: server } = new WebSocketPair();
this.ctx.acceptWebSocket(server);
return new Response(null, { status: 101, webSocket: client });
}
return new Response("not found", { status: 404 });
}
webSocketMessage(ws: WebSocket, message: string | ArrayBuffer) {
// Broadcast to every connected client (chat-style fan-out).
for (const peer of this.ctx.getWebSockets()) {
peer.send(typeof message === "string" ? message : new Uint8Array(message));
}
}
webSocketClose(_ws: WebSocket, _code: number, _reason: string, _wasClean: boolean) {
// Cleanup if needed.
}
}
```
`acceptWebSocket` + `webSocketMessage` is the right pattern for
multi-connection use cases (chat rooms, multiplayer, presence) because
the DO can hibernate between messages without losing connections.
### Per-entity grouping (chat rooms, per-user records)
You get **one App DO per (space, branch)** — a single instance, not a
namespace. Do not call `idFromName` or try to declare a DO binding in
`wrangler.json`; both are rejected. For multi-room / multi-user state,
**partition rows with a column** in the App's SQLite:
```sql
CREATE TABLE messages (
room_id TEXT NOT NULL,
user_id TEXT NOT NULL,
body TEXT NOT NULL,
ts INTEGER NOT NULL DEFAULT (strftime('%s','now') * 1000)
);
CREATE INDEX messages_by_room ON messages (room_id, ts);
```
Then route by `room_id` inside `App.fetch`. SQLite is fast enough that
sharing the same table across rooms is fine for prototyping; if you
later need stronger isolation, attach `room_id` to a tag and use
`acceptWebSocket(ws, [room_id])` so `getWebSockets(room_id)` returns
only that room's sockets.
### What you cannot declare
- **`durable_objects.bindings` in `wrangler.json`.** The platform
extracts your `App` class automatically. Adding bindings fails the
deploy with a clear error.
- **`new_sqlite_classes` / migrations.** Same reason. Your App's
storage is set up by the platform.
- **`d1_databases`, `r2_buckets`, `kv_namespaces`.** Not available.
Everything goes in `this.ctx.storage`.
## Static asset rules (read this twice)
This is where most preview failures happen. The browser eventually runs your assets — the bundler does **not** transform them. So:
### **NEVER ship JSX in a `<script type="module">` (or any other script tag).** Browsers cannot parse JSX.
This page **will be blank** with a `SyntaxError: Unexpected token '<'`:
```html
<script type="module">
import React from "https://esm.sh/react@18";
const App = () => <div>hi</div>; // ← browser dies here
</script>
```
Fix one of these three ways:
1. **Pre-compile** — write JSX in a build step (Vite/esbuild/tsup) and emit plain JS into `public/`. Best for production.
2. **`React.createElement` by hand** — works without a build step but is verbose.
3. **`@babel/standalone`** — only for prototypes. Load Babel **before** the script and use `type="text/babel"`. The modern `@babel/preset-react` defaults to the **automatic JSX runtime**, which emits `import { jsx } from "react/jsx-runtime"`. Browsers will reject that bare specifier unless your importmap maps it. **Always ship a complete importmap alongside the Babel script:**
```html
<script type="importmap">
{
"imports": {
"react": "https://esm.sh/react@18.3.1",
"react/jsx-runtime": "https://esm.sh/react@18.3.1/jsx-runtime",
"react-dom": "https://esm.sh/react-dom@18.3.1",
"react-dom/client": "https://esm.sh/react-dom@18.3.1/client"
}
}
</script>
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
<script type="text/babel" data-type="module" data-presets="react">
import React from "react";
import { createRoot } from "react-dom/client";
const App = () => <div>hi</div>;
createRoot(document.getElementById("root")).render(<App />);
</script>
```
`data-type="module"` is required for `import` to work inside the Babel script. Import React from the importmap key (`"react"`) — not from a hard-coded `esm.sh` URL — so your code and Babel's emitted `react/jsx-runtime` import resolve to the **same** React instance.
If you really cannot ship `react/jsx-runtime` in the importmap, force Babel to the legacy classic runtime instead so it emits `React.createElement` calls and never touches `react/jsx-runtime`:
```html
<script
type="text/babel"
data-type="module"
data-presets="react"
data-plugins='[["transform-react-jsx", { "runtime": "classic" }]]'
>
import React from "https://esm.sh/react@18.3.1"; /* must be in scope */
const App = () => <div>hi</div>;
</script>
```
The classic runtime requires `React` to be in lexical scope (because `<div>` becomes `React.createElement("div")`). The automatic runtime (default) does not, but needs `react/jsx-runtime` resolvable.
### Symptom-to-fix index
| Console error | Cause | Fix |
| --- | --- | --- |
Ver en GitHub