Bundling conventions for unfolder.space spaces
Builds run @cloudflare/worker-bundler's createApp inside the parent
Worker. Inputs come from the space's Workspace working tree; a build is
identified by its content snapshotHash. Edits go live on host.publish().
Entry point
- Server entry is auto-detected. Default search, in order:
src/index.ts,src/index.js,src/index.mts,src/index.mjs,index.ts,index.js,src/worker.ts,src/worker.js. - The entry must be
.ts/.js— NOT.tsx/.jsx..tsx/.jsxare fully supported as modules (import them freely for JSX views); they just can't be the entry. A JSX app needs a one-line.tsshim that re-exports it, e.g.src/index.ts→export { App } from "./app";.
JSX runtime — automatic, hono/jsx by default
The bundler compiles JSX with the automatic runtime, importing it from
hono/jsx. Plain .tsx/.jsx just works — no pragma, no import { jsx }
boilerplate:
import type { FC } from "hono/jsx";
export const Page: FC<{ title: string }> = ({ title }) => <h1>{title}</h1>;
Two requirements:
honomust be declared inpackage.json— the runtime import (hono/jsx/jsx-runtime) is resolved at build time like any other dependency. Any.tsxin a space withouthonoin its deps fails the build.- A file that wants a different JSX runtime can override per-file with a
pragma comment —
/** @jsxImportSource hono/jsx/dom */for client-side interactive JSX, or another library entirely; esbuild honors the pragma over the platform default.
(The unfolder.routing pattern is a complete, building example.)
Client entries — src/client.* is bundled for the browser
There is one client-bundling convention: src/client.{ts,tsx,js,jsx}, plus
every top-level file in src/client/ with those extensions, is bundled
separately for the browser and emitted as a static asset —
src/client.ts → /client.js, src/client/gallery.ts → /client/gallery.js.
Reference it from your HTML:
<script type="module" src="/client.js"></script>
- Client deps come from the same
package.json—import up from "unpoly"insrc/client.tsbundles Unpoly into/client.jsat build time. - Client bundles are browser-targeted (
platform: "browser"), minified, and served off R2 like any other asset. - Everything else under
src/stays server-side; nothing outside thesrc/client*convention reaches the browser.
Package CSS — import as a string, inject at runtime
.css imports bundle as strings (the text loader), including package
subpaths — no CDN URL, the version pinned by package.json, and a wrong path
fails the build instead of silently 404ing in the visitor's browser:
// src/client.ts
import unpolyCss from "unpoly/unpoly.min.css"; // the CSS, as a string
const style = document.createElement("style");
style.textContent = unpolyCss;
document.head.prepend(style);
Two rules: it must be a named import + inject (a bare side-effect
import "x.css" does nothing — the string is just dropped), and this is for
package / enhancement CSS. Your own site styles belong in public/theme.css,
served as a real static stylesheet (no flash of unstyled content).
Build platform — matches the runtime (nodejs_compat)
Every facet runs with nodejs_compat, and the build resolves packages the same
way. New spaces are scaffolded with a wrangler.jsonc declaring
compatibility_flags: ["nodejs_compat"]; if a space has none, the builder
applies the same platform-owned config at build time. Either way, packages with
a "node" export condition get their real node:* implementation (e.g.
better-auth's node:crypto scrypt), not a fallback. A root wrangler.* is a
build input, never served as a public asset.
Durable Object class — name matters
If your worker exports a stateful class, name it App and export it
named:
import { DurableObject } from "cloudflare:workers";
export class App extends DurableObject {
async fetch(request: Request) { /* ... */ }
}
The facet host calls worker.getDurableObjectClass("App") directly and
never reads export default — so don't add one. export default class App
will fail with an opaque internal error. Always use a named export.
For purely-static sites you don't need any server code: drop your HTML/CSS/JS into
the workspace and the edge (serveStatic) will serve them
before forwarding any unmatched requests to the isolate.
Static assets
Anything that isn't .ts/.tsx/.js/.jsx/.json (or that lives
under public/) is treated as a static asset.
Keep binary out of the workspace, though. public/ is for text assets
(HTML/CSS/JS, an SVG, a robots.txt). Images, fonts, video, and audio are
media — upload them to the media bucket (media.write / get_urls →
dest=media); they serve at the site root just the same (/favicon.ico,
/og.png) without bloating the source. See media.
Headers, redirects, and asset behavior — config.setAssetConfig
Asset serving is configured on UnfolderWorkspace, not via files in the
workspace tree. Set it from inside run_code:
async () => {
await config.setAssetConfig({
headers: {
"/*": { set: { "X-Frame-Options": "DENY" } },
"/assets/*": { set: { "Cache-Control": "public, max-age=31536000" } },
},
redirects: {
static: { "/old": { status: 301, to: "/new" } },
dynamic: { "/old/*": { status: 301, to: "/new/:splat" } },
},
not_found_handling: "single-page-application", // or "404-page" | "none"
});
};
The config is stored in UnfolderWorkspace's KV — one source of truth shared
between agent code (config.*) and the MCP layer. It takes effect on the
next host.publish(). Pass null to clear.
Read the current config with config.getAssetConfig(). Returns null if
none is set.
The full schema is AssetConfig from @cloudflare/worker-bundler:
headers, redirects.static, redirects.dynamic, html_handling,
not_found_handling. There are no _headers / _redirects files — don't
write them; they are ignored.
Defaults (no config set)
When config.getAssetConfig() returns null, the bundler's defaults apply:
html_handling: "auto-trailing-slash"—/aboutfindsabout.htmlorabout/index.html;/about/likewise. Trailing slash is auto-added or auto-dropped to find a match, and requesting the extension directly (/about.html) 307-redirects to the clean URL (/about).not_found_handling: "none"— unmatched requests fall through to your isolate (theAppclass). To serve an SPA, set"single-page-application"(returns/index.htmlfor 404s); to serve a static error page, set"404-page"(walks up the tree looking for the nearest404.html).headers: undefined— no custom response headers are set or unset via config; the bundler always setsContent-Type,ETag, and a defaultCache-Control(public, max-age=31536000, immutablefor content-hashed filenames likeapp.a1b2c3d4.js,public, max-age=0, must-revalidateotherwise).redirects: undefined— no redirects applied.
In all cases the edge first checks configured redirects (static, then
dynamic), then the asset manifest (static files in the workspace), then
falls through to the isolate's fetch. A static-only site works with no
config at all — just write your files and publish.
npm
package.json is honored. Add deps and they'll be resolved at build time.