Dev island bundler — implementation
Implements the design decided in spec 17:
Implements the design decided in spec 17:
- A2′ eager startup pre-bundle + lazy rebuild on chunk GET
- B1 on disk under
<appDir>/.patties-dev/client/ - C1 CLI owns the lifecycle
- D
/_patties/client/URLs, stable filenames,Cache-Control: no-store
This spec is the buildable plan. The intent is that a single PR can land the whole thing.
Existing pieces to reuse
src/build/scan-islands.ts→scanIslands(appDir)returnsIslandEntry[]. Use as-is.src/build/client-entry.ts→generateClientEntry(islands, { frameworkRoot })returns the entry module source. Use as-is.src/build/index.ts→resolveFrameworkRoot()already exists (private). Lift it to a sharedsrc/internal/framework-root.tssosrc/dev/can import it without depending onsrc/build/.src/dev/watcher.ts→ already publishes{ type: "update", island }on changes underapp/islands/.src/render/index.tsx→createRenderer({ manifest })already accepts aClientManifest. No shape change required.src/server/index.ts→startServer({ staticRoutes })already exists and is what the bundler's chunk routes will piggyback on.
New module: src/dev/bundler.ts
Public API
import type { ClientManifest } from "../render/index.tsx";
export interface DevBundlerOptions {
appDir: string;
}
export interface DevBundlerHandle {
/** Live reference. Renderer reads this on each render; we mutate in place. */
readonly manifest: ClientManifest;
/** Route table keyed by URL. Pass into startServer({ staticRoutes }). */
readonly staticRoutes: Record<string, (req: Request) => Promise<Response>>;
/** Mark the bundle dirty. Cheap; does not trigger a build. */
markDirty(): void;
/** Most recent build error, if any. Cleared on the next successful build. */
lastError(): BuildError | null;
/** Tear down the in-memory state and remove `<appDir>/.patties-dev/`. */
dispose(): Promise<void>;
}
export interface BuildError {
message: string;
logs: string[];
}
export async function startDevBundler(opts: DevBundlerOptions): Promise<DevBundlerHandle>;
Re-exported from src/dev/index.ts as startDevBundler and the two types.
Internal state
interface State {
appDir: string;
outDir: string; // `${appDir}/.patties-dev/client`
entrySrcPath: string; // `${appDir}/.patties-dev/client-entry.ts`
manifest: ClientManifest;
staticRoutes: Record<string, (req: Request) => Promise<Response>>;
dirty: boolean;
inFlight: Promise<void> | null;
lastError: BuildError | null;
}
We rebuild the entire client bundle on dirty (not per-island). Reasons:
Bun.buildis fast enough that per-island is premature optimization.- The production pipeline produces a single entry + split chunks via
splitting: true; matching that shape in dev keeps the renderer contract identical between dev and prod. - Per-island would require either disabling splitting (worse output) or running N parallel
Bun.buildcalls (harder to reason about).
Startup flow
export async function startDevBundler(opts): Promise<DevBundlerHandle> {
const outDir = `${opts.appDir}/.patties-dev/client`;
const entrySrcPath = `${opts.appDir}/.patties-dev/client-entry.ts`;
await Bun.$`mkdir -p ${outDir}`.quiet();
const state: State = {
appDir: opts.appDir,
outDir,
entrySrcPath,
manifest: { entry: null, islands: {} },
staticRoutes: {},
dirty: false,
inFlight: null,
lastError: null,
};
await rebuild(state); // eager pre-bundle (A2′)
wireStaticRoutes(state); // installs /_patties/client/* handler
return {
get manifest() { return state.manifest; },
get staticRoutes() { return state.staticRoutes; },
markDirty() { state.dirty = true; },
lastError() { return state.lastError; },
dispose() { return disposeBundler(state); },
};
}
rebuild(state)
async function rebuild(state: State): Promise<void> {
if (state.inFlight) return state.inFlight; // coalesce concurrent callers
state.inFlight = (async () => {
try {
const islands = await scanIslands(state.appDir);
const frameworkRoot = resolveFrameworkRoot();
await Bun.write(state.entrySrcPath, generateClientEntry(islands, { frameworkRoot }));
const result = await Bun.build({
entrypoints: [state.entrySrcPath],
target: "browser",
splitting: true,
sourcemap: "linked",
minify: false,
outdir: state.outDir,
// Stable names. Dev uses Cache-Control: no-store so no need to hash.
naming: { entry: "[name].js", chunk: "[name].js", asset: "[name].[ext]" },
});
if (!result.success) {
state.lastError = {
message: "client bundle failed",
logs: result.logs.map((l) => l.message ?? String(l)),
};
return;
}
updateManifest(state.manifest, result.outputs, state.entrySrcPath, islands, state.outDir);
state.dirty = false;
state.lastError = null;
} finally {
state.inFlight = null;
}
})();
return state.inFlight;
}
updateManifest is a near-copy of populateClientManifest from src/build/index.ts. Lift the latter to a shared src/internal/client-manifest.ts and call it from both places — do not copy-paste.
wireStaticRoutes(state)
Installs handlers under /_patties/client/* that:
- If dirty,
await rebuild(state)first. Concurrent GETs coalesce on the sharedinFlightpromise. - Read the requested file from
state.outDirwithBun.file(). - Return with
Content-Typeinferred from extension andCache-Control: no-store. - If the file does not exist, return 404.
We register a single wildcard route, not one route per chunk. The existing startServer({ staticRoutes }) API only takes a flat map; we work around that by registering a function under a synthetic pattern that the Bun matcher treats as a wildcard. Concrete shape TBD by the implementer — if startServer needs an extension, add an optional wildcardRoutes?: Record<string, Handler> field rather than overloading staticRoutes semantics.
dispose(state)
- Awaits any in-flight build.
- Clears
state.staticRoutes. rm -rfon${appDir}/.patties-dev/. Use Bun'sawait Bun.$\rm -rf ${path}\.quiet().- Wired to
process.on("SIGINT" | "SIGTERM")via the existinginstallSigintHandlerinsrc/cli/log.ts. The CLI callsbundler.dispose()from inside the SIGINT handler before exiting.
Watcher changes
src/dev/watcher.ts:
export interface DevOptions {
appDir: string;
bundler?: DevBundlerHandle; // new, optional
}
In the existing notifyChange(path):
if (path.startsWith(islandsDir)) {
options.bundler?.markDirty(); // ← new, synchronous, cheap
const rel = path.slice(islandsDir.length);
const name = rel.replace(/\.[tj]sx?$/, "").replace(/\//g, "-");
publish(JSON.stringify({ type: "update", island: name }));
return;
}
Order matters: mark dirty before publishing the WS message so a fast-acting browser cannot GET the chunk before the dirty flag is set.
CLI wiring
src/cli/dev.ts, in bootstrap():
const bundler = await startDevBundler({ appDir: resolved.appDir });
const devServer = createDevServer({ appDir: resolved.appDir, bundler });
// install dispose on shutdown
const sigint = installSigintHandler(); // assume returns a hook list
sigint.push(() => bundler.dispose());
// ...existing plugin onDevStart loop...
const entry = findUserEntry(resolved.appDir);
if (entry) {
const mod = await import(entry);
const start = mod.default as StartFn | undefined;
if (typeof start === "function") {
await start({
devServer,
port: resolved.port,
host: resolved.host,
appDir: resolved.appDir,
bundler, // ← new field
});
printReady(resolved);
return EXIT.OK;
}
log.warn(`${entry} has no default export; starting a stub dev server.`);
}
// Stub fallback unchanged — does NOT get the bundler.
The StartFn signature in src/cli/dev.ts gains an optional bundler:
type StartFn = (opts: {
devServer: DevServer;
port: number;
host: string;
appDir: string;
bundler?: DevBundlerHandle;
}) => void | Promise<void>;
Optional so user-authored server.ts files written before this spec continue to compile and run (without hydrated islands — same as today).
installSigintHandler today is fire-and-forget; if it doesn't already expose a way to register additional cleanups, extend it to do so as part of this PR.
Renderer changes
This spec also pulls forward the HTMLRewriter work decided in [[rfc-bun-htmlrewriter]] (accepted Phase 2, not yet encoded). The dev bundler needs a <script src="/_patties/client/client-entry.js"> tag injected into the SSR HTML. Today src/render/index.tsx does that with string concatenation against </body>. Spec 18 replaces that with an HTMLRewriter pipeline, because:
- The dev bundler is now a second customer for the same injection point (the HMR client script is the first). Two string-concat callers in the same render path is the moment to factor.
- The RFC's reasoning stands unchanged: string-concat against minified shells is fragile, and the rewriter ships unchanged to Cloudflare / workerd via the edge adapter (spec 12).
- The CSRF auto-injection in [[rfc-bun-csrf]] depends on the rewriter landing first; doing it here unblocks that work too.
Implementation:
- New
src/render/inject.tsexportingcreateInjector(scripts: string[]) → (res: Response) => Response. UsesHTMLRewriterwith a singleelement("body", { element(el) { el.append(scriptTags, { html: true }) } })handler. src/render/index.tsxconstructs an injector per render with the appropriate scripts (HMR client in dev, plus the client-entry tag whenmanifest.entryis set).- Remove
BODY_CLOSE_REand the string-injection branch. - Existing unit tests under
tests/render/that asserted on the concatenated string update to read the rewritten body. (Cheaper: buffer the rewritten Response in the test, assert on the result.)
src/render/dev-error.tsx gains an optional "current build error" input. src/render/index.tsx plumbs it through:
export interface RenderOptions {
manifest?: ClientManifest;
dev?: boolean;
modules?: Record<string, unknown>;
/** new: function returning the most recent dev build error, if any */
getBuildError?: () => BuildError | null;
}
In renderPage, before normal rendering: if dev && getBuildError() returns non-null, short-circuit to renderDevErrorPage with that error instead of rendering the page. This makes broken island edits visible on the next navigation without crashing the server.
The template's app/server.ts passes getBuildError: () => opts.bundler?.lastError() ?? null.
Template changes (handed off to CLI spec 09)
packages/create-patties/templates/default/ updates:
gitignore— add.patties-dev/.app/server.ts— consume the bundler:
import type { DevBundlerHandle, DevServer } from "patties/dev";
import { createRenderer } from "patties/render";
import { createRouter } from "patties/router";
import { startServer } from "patties/server";
interface StartOpts {
devServer: DevServer;
port: number;
host: string;
appDir: string;
bundler?: DevBundlerHandle;
}
export default async function start(opts: StartOpts): Promise<void> {
const renderer = createRenderer({
dev: true,
manifest: opts.bundler?.manifest,
getBuildError: () => opts.bundler?.lastError() ?? null,
});
const router = await createRouter({ appDir: opts.appDir, renderer });
startServer({
port: opts.port,
hostname: opts.host,
dev: true,
devServer: opts.devServer,
routes: router.routes,
fallback: router.fallback,
staticRoutes: opts.bundler?.staticRoutes,
});
}
- The
app/server.tsI added in PR-current already exists in a thinner form; this spec replaces it.
Dev/prod manifest divergence (clarification)
The macro policy decided in [[rfc-bun-import-attributes]] lists "Client manifest (island name → chunk URL)" as a required macro: the production server bundle reads the manifest at build time via MANIFEST and never at runtime. This spec deliberately diverges in dev:
- Dev mutates
state.manifestin place across rebuilds; the renderer holds the live reference and reads it on each render. - The
MANIFESTmacro is not used in the dev path.
This is consistent with the build-time-discovery rule because the production server bundle still goes through src/build/index.ts → generateServerEntry → MANIFEST macro. The dev bundler lives entirely under src/dev/ and is import-disjoint from the production bundle (see the guard test below).
Document this divergence in src/dev/bundler.ts with a one-line header comment referencing the RFC, so the next person reading the code understands why this dev path looks unlike the prod path.
Build-time-discovery guard
Production server bundle must not import src/dev/bundler.ts. Enforce with a new test in tests/integration/build.test.ts:
test("production server bundle does not reference src/dev/", async () => {
const built = await fs.promises.readFile(serverEntryArtifact, "utf8");
expect(built).not.toMatch(/startDevBundler/);
expect(built).not.toMatch(/\.patties-dev/);
});
Also add a static import-graph check (CI grep): no file under src/server/** or src/build/** may import anything under src/dev/. A shell one-liner is enough; wire it into bun run validate.
Test plan
Unit — tests/dev/bundler.test.ts (new)
Fixture: minimal app under tests/fixtures/dev-bundler/ with one island app/islands/Counter.tsx.
startDevBundler({ appDir: fixture })populatesmanifest.entryandmanifest.islands["Counter"]with non-null URLs.Bun.file(${outDir}/client-entry.js).exists()is true after startup.markDirty()does not block, does not trigger I/O — verified by mockingBun.buildto count calls (one call from startup).- Calling the static route handler for the entry URL after
markDirty()triggers exactly one rebuild even if invoked concurrently 10x (coalescing assertion). - Editing the island file (
Bun.writewith broken syntax) +markDirty()+ GET → response is still 200 with stale content,lastError()returns the build error.
Integration — tests/integration/dev-hmr.test.ts (extend)
Add after the existing reload test:
- Boot
patties devagainst a fixture with one island. - GET
/→ assert response HTML contains<script src="/_patties/client/client-entry.js". - GET that URL → 200,
Content-Type: text/javascript,Cache-Control: no-store, body contains the island name. - Touch the island file with
Bun.write. Wait for the WS message. GET the URL again → still 200, body reflects the new contents.
Integration — tests/integration/build.test.ts (extend)
Add the production-bundle guard test described under Build-time-discovery guard.
Manual
bunx create-patties demo && cd demo && bun dev → load / → the demo todo island responds to clicks without patties build having ever run.
Migration / compatibility
StartFngains an optionalbundlerfield. Existing userserver.tsfiles written before this spec continue to compile and run; they just don't hydrate islands (same as today). Thecreate-pattiestemplate is updated so new projects get the wiring by default.createRenderergains an optionalgetBuildError. Existing callers are unaffected.createDevServergains an optionalbundler. Existing callers are unaffected.- No public-export changes beyond the two new symbols (
startDevBundler,DevBundlerHandle) onpatties/dev.
Risks / open in implementation
startServerstatic-routes shape. TodaystaticRoutesisRecord<string, Response>— a flat map of URL → fixed response. Our bundler needs per-request functions (so we can check the dirty flag). Three options for the implementer: ((req: Request) => Promise<Response>). Tightest fit; keeps one field. field onServerOptions`. rebuild. Defeats the lazy-rebuild design — rejected. Recommend the first; the type widening is backwards-compatible.- Extend
ServerOptionswithstaticRoutesaccepting `Response | - Add a new
wildcardRoutesfield. Cleanest separation; one more - Have the bundler pre-write all chunk Responses and re-publish on
- Extend
- Race between scan and write. If the user saves an island file mid-rebuild,
scanIslandsruns against a transient FS state. Bun's FS is synchronous within the build, so the risk is limited to the user actively editing during the build window (sub-second). If it bites, debounce in the watcher (50ms). - Sourcemap URLs. Bun emits
//# sourceMappingURL=…relative to the chunk. Because chunks and maps live in the sameoutDirserved under the same prefix, the default relative URL should resolve. Verify in the integration test by curling the sourcemap URL. - AGENTS.md mention.
.patties-dev/should be excluded fromagents-mdscan if that scan ever walks user dirs. Today it doesn't, but flag it forsrc/agents-md/generate.tsreview.
Acceptance criteria
bunx create-patties demo && cd demo && bun devserves a page where islands hydrate and respond to input, withoutpatties buildever running.- Editing an island file marks the bundle dirty without triggering a build; the build only runs when the browser re-fetches the chunk URL after the HMR notification.
patties buildartifacts are byte-for-byte unchanged by this spec (samesrc/build/code path).- Production server bundle contains zero references to
src/dev/or.patties-dev; the new integration test asserts both. bun run validatepasses including the new import-graph guard.- SIGINT during
patties devremoves the.patties-dev/directory before exit. - Script injection in
src/render/index.tsxusesHTMLRewriterrather than string concatenation (encodes [[rfc-bun-htmlrewriter]]); flip that RFC'sstatus:fromencoded_in: []to includeframework/draft/18-dev-island-bundler-implonce this lands.