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/.jsx are fully supported as modules (import them freely for JSX views); they just can't be the entry. A JSX app needs a one-line .ts shim that re-exports it, e.g. src/index.tsexport { 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:

  • hono must be declared in package.json — the runtime import (hono/jsx/jsx-runtime) is resolved at build time like any other dependency. Any .tsx in a space without hono in 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 assetsrc/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.jsonimport up from "unpoly" in src/client.ts bundles Unpoly into /client.js at 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 the src/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_urlsdest=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"/about finds about.html or about/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 (the App class). To serve an SPA, set "single-page-application" (returns /index.html for 404s); to serve a static error page, set "404-page" (walks up the tree looking for the nearest 404.html).
  • headers: undefined — no custom response headers are set or unset via config; the bundler always sets Content-Type, ETag, and a default Cache-Control (public, max-age=31536000, immutable for content-hashed filenames like app.a1b2c3d4.js, public, max-age=0, must-revalidate otherwise).
  • 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.