Development HMR Internals

Furin development mode is one Bun process, but it is not one module graph. Bun owns the browser bundle and HMR transport. Furin owns the extra work needed to keep server rendering, route data, route topology, and diagnostics consistent with that browser graph.

This page documents the internal contract. Application code does not call any of the functions or endpoints described here.

Architecture at a glance

Four loops run together during development:

text
source edit
  ├─ Bun browser graph ──────> hot module ──────> React reconciliation
  ├─ Furin SSR graph ────────> versioned import -> next SSR/data request
  ├─ route/data coherence ───> rescan + invalidate -> fresh loader result
  └─ diagnostics ────────────> error/ready event -> development overlay

They meet at two generated files in .furin/:

  • _hydrate.tsx contains the client route table, hydration entry, stable hot component registry, loader refresh hook, and HMR connection recovery.
  • index.html is Bun's HTML entry. Furin mounts its HTMLBundle at /_bun_hmr_entry, under the instance prefix when one exists.

Bun scans the HTML entry, bundles its scripts and styles, and supplies the browser WebSocket and import.meta.hot runtime. Furin does not implement a second HMR transport.

Architecture decision

Furin deliberately keeps a hybrid boundary: use Bun for browser invalidation and implement framework-specific coherence inside Furin.

OptionBenefitCostDecision
Bun HMR plus Furin coherenceOne process and one browser bundler; preserves React state while keeping SSR and loaders freshFurin must maintain versioned server imports, route rescanning, and diagnosticsSelected
Full-page reload after every editSmallest implementationLoses local state and hides invalidation bugsRejected
Vite or Rsbuild development serverMature HMR graph, plugin lifecycle, and error handlingIntroduces a second development architecture and weakens the Bun-only designRejected
Bun HMR aloneNo framework orchestrationBrowser code can move ahead of SSR modules, loaders, and the route snapshotInsufficient

The recommended direction is to keep the Bun-native boundary and harden it with observable conformance tests. Next.js owns a deeper integrated compiler graph, while TanStack Start delegates development HMR to Vite or Rsbuild. Copying either approach would trade away Furin's defining single-process constraint rather than improve it incrementally.

Startup

Running the application with bun --hot initializes development mode in this order:

  1. Register the furin-dev-page-loader runtime plugin.
  2. Scan pagesDir and build the initial root, route, and Elysia snapshots.
  3. Generate .furin/_hydrate.tsx, .furin/index.html, and route types. Files are rewritten only when their content changed, so generated output does not cause a reload loop.
  4. Import the generated HTML as a Bun HTMLBundle and mount it at the instance's /_bun_hmr_entry path.
  5. Start a per-instance route watcher. It watches authored route files and their dependencies and reconciles refreshes after a short debounce, serializing refreshes so requests cannot observe a partially replaced route snapshot.
  6. Mount the data, diagnostics, DevTools, sync, public asset, and not-found handlers around the generated Elysia route tree.

Route modules are not eagerly imported into the ordinary server watcher graph. They are loaded through a virtual namespace on demand, so editing a page can use browser HMR without restarting the whole server module graph.

Server rendering stays fresh

One identity per source revision

Every dev request resolves root.tsx and the matched page through a versioned specifier:

text
/absolute/path/to/page.tsx?furin-server&t=<source-version>

<source-version> is derived from the file's nanosecond modification time, size, and an explicit generation counter. It is not Date.now() and it does not change for every request.

Furin caches the import promise by source path and version. Concurrent requests for the same revision share one evaluation; a new edit gets a new module identity. This avoids both stale SSR code and unbounded, request-proportional ESM registry growth.

Relative imports inside a versioned module are resolved to absolute paths. Local JavaScript and TypeScript dependencies receive their own source version, so an edited helper cannot remain stale behind a fresh page module. Failed imports are removed from the cache and can recover on the next edit.

Virtual server transform

The furin-dev-page namespace has no filesystem directory from which Bun can resolve relative imports. Its loader therefore performs the complete transform before returning JavaScript:

  1. Record application imports in the development module graph.
  2. Select the server side of isomorphic functions.
  3. Rewrite relative imports to absolute, versioned specifiers.
  4. Transpile TypeScript and JSX with Bun.Transpiler.
  5. Resolve bare imports from the authored module's directory.
  6. Canonicalize React and React DOM imports.
  7. Inject the JSX runtime helpers emitted by the transpiler.

Transform failures retain a distinct transform diagnostic phase instead of being reported as generic import failures.

Root and nested layouts

A fresh page import does not guarantee that a statically imported parent layout has a fresh identity. Before rendering, refreshLayoutChain() imports each existing intermediate _route module with the same versioning rule and patches the matching runtime chain entry.

Directories without _route files do not consume a chain position. The root is loaded separately, then its current layout and loader references replace the root entry captured by the nested chain.

Route discovery, the per-request renderer, and the pages' rewritten relative imports all load root.tsx through the same versioned specifier, so its top level runs once per revision. A root edit is not a bun --hot server reload: the route watcher rescans and the next page or 404 render uses the new root. Like page modules, the Elysia route tree is composed at startup, so a change to a route module's Elysia hooks (including root.tsx) takes effect after a restart.

Furin finally rebuilds the ResolvedRoute from the current page and route chain. Changing mode, adding or removing a loader, changing revalidate, or changing cache tags therefore takes effect without a manual restart. Structural fields such as the URL pattern and convention boundaries come from the topology snapshot and change through the watcher instead.

Render and document assets

After the route is rebuilt, Furin runs the applicable loaders and renders the current React tree. The SSR document receives the latest loader payload and the assets parsed from /_bun_hmr_entry.

The development HTML template and parsed asset metadata use a one-second cache. This bounds internal fetch and parsing work while allowing Bun's content-addressed entry and stylesheet URLs to converge quickly after a rebuild. Furin tracks the main client entry separately from framework modules such as the error overlay, so a React-rendered <Scripts /> element restores both classes of scripts.

A live browser update

Route modules receive a client transform before Bun bundles them. The transform:

  • removes server-only builder stages such as loader, requestLoader, head, params, query, and staticParams;
  • records the route component's hook-call signature;
  • wraps imported route components so they participate in React refresh; and
  • installs a self-accepting import.meta.hot.accept() callback.

When Bun delivers an updated route module, the callback forwards the new component to window.__FURIN_HMR_UPDATE__.

Stable component identities

The generated hydrate entry keeps a registry on window.__FURIN_HOT_COMPONENTS__. Each page, layout, and root has a stable wrapper keyed by role and source path. An edit replaces the implementation behind that wrapper rather than replacing the wrapper itself.

If the hook signature is unchanged, React can preserve compatible local state. If the signature changes, Furin replaces the inner boundary and React remounts that component. Rebuilding the route table removes obsolete registry entries without replacing surviving entries.

Commit code and data together

Component-only route edits preserve loader data, including when a loader uses imported helpers. Furin compares the server stages and the contents of their transitive application imports before requesting fresh data. Import bindings and paths participate in this comparison; server code stays out of the client bundle. Unresolved dependencies still trigger a conservative refresh. Bun can also re-evaluate a route after an import update without rerunning the transform; those updates refresh data because the embedded dependency fingerprint may be stale.

The initial document calls hydrateRoot(document, app). The React root is kept on window.__FURIN_ROOT__, the object that survives Bun module re-evaluation. Subsequent updates call root.render(app); they reconcile an already-owned DOM and never attempt to hydrate it a second time.

The route's HMR accept handler invokes window.__FURIN_HMR_REFRESH__ directly. Component-only edits commit without requesting data. When fresh data is needed, the router:

  1. invalidates the current page's prefetch entry;
  2. requests fresh route data with x-furin-hmr-refresh: 1;
  3. asks the topology watcher to refresh before resolving that data request;
  4. reruns or retrieves loaders according to the current route mode; and
  5. commits the updated component with the fresh router state without adding a history entry or resetting scroll.

Navigation versions and abort signals prevent an older, slower loader response from overwriting a newer refresh. The router waits for fresh data before publishing the updated component and router state together.

Transport recovery

Ordinary accepted edits do not reload the document. If Bun's native HMR WebSocket disconnects and later reconnects, Furin performs one page reload to restore a known-good module graph. This intentionally sacrifices local state only after a transport outage.

Route topology changes

The generated Elysia tree is static, but its renderers dispatch through a replaceable development snapshot. The topology watcher distinguishes two cases:

  • An existing route or dependency changed: invalidate server module identities, reload the route snapshot, and regenerate client files if their content changed.
  • A route or convention appeared or disappeared: rescan the filesystem, replace the matcher and renderer snapshot, and regenerate the client route table.

This makes hot addition and removal work for pages, error files, and not-found files. A newly added path reaches the refreshed renderer through the Elysia not-found fallback; a removed active page resolves to the current not-found boundary.

Furin never rewrites a route file that has content. Before each snapshot refresh, an empty or whitespace-only route file receives a starter route. After the refresh, the route config checks run; the first issue is printed in the terminal and shown in the error overlay, and it clears on the save that fixes it.

Loader and render-mode coherence

Development and production pages go through the same dispatcher and render pipeline (src/server/router/dispatch.ts). Each runtime only supplies its ports: the route source (a live snapshot whose shell has no schemas, so edited schemas take effect, or the built manifest), the module loader (current source versions with the diagnostic overlay, or bundled modules) and the cache policy. Production serves SSG, ISR and PPR documents from the page cache. Development has no page cache and treats each render mode deliberately:

ModeLoader behaviorHTML behavior
SSRRun the current loader chain for every requestRender from current source
SSGCache synchronous loader data until a tracked source changesRe-render HTML for every request
ISRCache synchronous loader data for revalidate, unless a tracked source changes first, keyed on the declared query params like productionRe-render HTML for every request
PPR (a requestLoader under an SSG or ISR document)Cache the public loader data like its SSG or ISR mode; run requestLoader for every requestPrerender the public shell and resume the request fields for every request

Only loader output is cached in development. HTML is always assembled again so the response can carry current code, document assets, and framework scripts. Deferred promises are not reused from an SSG or ISR cache hit. Navigation data (?_furin=data) reads and fills the same loader cache as documents, so an SSG loader runs once whether the page was first loaded or navigated to.

PPR documents go through the production prerender and resume, without its shell cache, so a page that breaks a PPR rule (for example a requestLoader field read outside a <Suspense> boundary) fails in development with the error overlay instead of only in production.

Each cache entry records the page, root, and existing intermediate layouts that can affect it. A source re-evaluation invalidates only entries indexed by that file, and so does the route table refresh that applies its edited schemas. The validity check also compares dependency modification times, so a rename or deletion fails conservatively instead of serving stale data.

Changing the route mode is safe because the mode is recomputed from the freshly adapted route before selecting the SSR, SSG, or ISR path.

Development diagnostics and recovery

Furin has a framework-level diagnostic pipeline in addition to Bun's bundler errors. It covers failures that occur after a module was delivered or while a request is executing.

PhaseOrigin
transformFurin's server transform or parser
importRoute evaluation or a transitive application import
loaderRoot, layout, or page loader execution
renderServer React render
hydrateInitial browser hydration
client-renderReact render after the browser owns the document

Server failures become a structured diagnostic and a 500 document containing the overlay bootstrap. Client hydration and render failures dispatch a furin:client-error event; the overlay posts the report back to the server for source-map symbolication.

The diagnostic contains the phase, route, message, cause, source location, application import chain, and stack. The import chain comes from the module graph recorded during transforms. Client frames are mapped through same-origin Bun source maps, and framework or dependency frames are classified as internal.

The browser receives a small versioned diagnostic channel over Furin's shared /_furin/events WebSocket:

  • furin.error shows or replaces the active overlay;
  • furin.ready clears a warm overlay after a successful route revision;
  • a cold 500 page reloads automatically after recovery; and
  • a changed server id also reloads, because event cursors from the previous process no longer describe the current graph.

The store retains the latest active error and a bounded event history, allowing a newly opened tab or a reconnecting socket to recover its state. Sync, diagnostics, and DevTools share this one connection instead of occupying three long-lived HTTP response slots per tab.

The internal endpoints live under the instance prefix:

text
/_furin/dev/overlay.js
/_furin/dev/client-errors
/_furin/events
/_furin/events/client.js

They accept loopback and same-origin development requests only. Production keeps the diagnostic plugin out of the server graph and resolves the remaining development boundaries to inert implementations before bundling.

Module identity invariants

React is a server singleton

Every SSR module must observe the same React dispatcher used by react-dom/server. A second React identity causes Invalid hook call or a null hook dispatcher after hot reload.

Furin canonicalizes these imports to resolved on-disk paths:

  • react
  • react/jsx-runtime
  • react/jsx-dev-runtime
  • react-dom
  • react-dom/server
  • react-dom/client

Workspace modules that remain in Bun's normal server graph receive the same singleton rewrite, not only virtual page modules.

The client router is a singleton

The generated hydrate entry and application code must import RouterProvider and Link through the same package identity. Otherwise two RouterContext objects can exist: the provider writes one while links read the other and fall back to document navigation.

The hydrate entry therefore imports router APIs through @teyik0/furin/link, never through an absolute internal source path.

Development startup

Furin loads route contracts before listening: configuration, schemas, loaders, request loaders, metadata, and the Elysia route order remain available immediately. For eligible inline .page() callbacks, the core extracts a separate server render module and loads it before rendering the first HTML request for that route. Data-only requests do not load that component.

The initial implementation splits callbacks that capture imports and their own parameters. Callbacks that capture local module state, named component arguments, and unrecognized route chains keep their existing eager behavior. Application routes require no syntax changes. Production builds retain their existing pipeline.

Imports used only by the extracted render callback are evaluated on first render. Their internal side effects are consequently deferred too; put initialization that must run at startup in an explicit side-effect import or the server entry. Shared imports retain their module identity. Source versions invalidate render modules after edits, including sources compiled by runtime plugins such as MDX.

This follows the separation of critical route configuration and components used by TanStack Router, while using Bun's runtime plugins. Deferring the whole Furin initialization would open the port earlier but leave the first request paying for every route.

The browser entry still references all routes. Bun can compile that client graph on the first HTML request, so a faster listening socket does not imply an equally large improvement in first-page latency. CI budgets measure the port, the complete first HTML response, and the first visit to a second route separately.

Multi-instance isolation

Every mounted Furin instance has its own prefix, generated .furin directory, route snapshot, route watcher, template cache, loader caches, diagnostics store, and internal endpoints. A hot update or invalidation in one prefixed application must not affect a sibling instance.

The root instance alone writes the shared furin-env.d.ts; prefixed instances use their own client entry without competing for that file.

Observable edit behavior

EditExpected result
Page componentPreserve compatible React state; render the latest implementation
Hook order or signatureRemount the affected component; keep unaffected parents mounted
Root or nested layoutPatch the stable component and refresh the server route chain
Loader, mode, or revalidateRefresh route data using the newly rebuilt route definition
Imported local module or re-exportPropagate through the browser graph and use a versioned SSR dependency
Route or convention add/removeReplace route snapshots and regenerate the client table without a manual restart
CSS or @import dependencyLet Bun update styles while React state remains mounted
Server entry, API, or runtime internalsLet bun --hot re-evaluate the affected server graph
HMR transport reconnectReload once to recover a known-good graph

Known limitations

The current Bun-backed pipeline has executable test.todo specifications for:

  • editing a dynamic import before its first execution;
  • creating a previously missing import;
  • restoring a deleted imported component;
  • pruning a stylesheet after its component import is removed; and
  • CSS Module class mapping imports in Bun's full-stack development bundle.

Furin does not hide these cases behind a global reload because that would make the passing Fast Refresh cases lose state and would conceal graph invalidation regressions.

Parent application hooks

When a parent Elysia application registers a hook that reaches Furin's routes (for example onBeforeHandle("global", ...) from a CORS, auth, or logging plugin), Elysia prints this warning at startup:

text
[Elysia] GET /_bun_hmr_entry is an HTML bundle served natively, hooks do not run for it

It is expected and harmless. /_bun_hmr_entry only serves Bun's development HTML entry, which needs no hooks, and the route does not exist in production. Elysia has no per-route opt-out for this warning yet.

A page added while the dev server runs is served through the root instance's NotFound handler until a restart mounts it, and a parent's beforeHandle, derive, and resolve hooks do not run on that path. Furin prints one warning per such page when the parent adds hooks to its routes. Restart the server before relying on a parent guard for a newly added page; production always runs it.

Large-project cold start, warm start, memory, and edit-to-paint performance need published reproducible benchmarks before making comparative speed claims.

Verification

The HMR suite treats browser-visible behavior as the public contract. Run these commands from packages/core:

  • bun run test:webview runs the real-browser conformance tier with Bun.WebView and Chrome.
  • FURIN_WEBVIEW_TESTS=1 bun test --todo tests/browser/hmr-conformance.webview.test.ts runs the known-gap specifications from packages/core.
  • bun run test:hmr:soak runs the endurance tier with 2,000 edits by default. FURIN_HMR_SOAK_EDITS=<count> selects a shorter local run.

The suite covers state preservation and remounting, re-exports, cycles, loaded dynamic imports, layouts, syntax and runtime recovery, the diagnostic overlay, CSS, rapid edits, stale loader responses, route topology, SSR/SSG/ISR, deferred data, composite RSC, prefixed instances, multiple tabs, reconnections, edit-to-paint, and memory behavior.

Internal ownership map

FileResponsibility
src/build/hydrate.tsGenerate the client route table, stable hot registry, root reuse, data refresh, and reconnect handling
src/plugin/transform-client.tsRemove server-only route stages, derive hook signatures, and inject route accept boundaries
src/client/hmr.tsPreserve component identities and remount boundaries when hook signatures change
src/client/router/provider.tsxPerform HMR data refreshes and reject stale navigations
src/server/dev-page-plugin.tsLoad versioned SSR modules and transform their source
src/server/dev-page-imports.tsRewrite imports of those modules and preserve React singletons
src/server/router/source-version.tsCompute source revisions and advance the explicit generation
src/server/router/dispatch.tsDispatch pages for both runtimes through their route source, module loader, and cache policy ports
src/server/router/hmr.tsRebuild routes, refresh layout chains, and provide the development ports: current modules, the loader cache, and server diagnostics
src/server/cache/dev-loader.tsCache loader output and invalidate it by route dependencies
src/plugin/dev-routes.tsGenerate Elysia route trees and watch route topology
src/server/render/template.tsRead current Bun document assets and inject framework clients
src/server/dev/graph.tsOwn route snapshots, module revisions, dependency chains, and development cache state
src/server/dev/diagnostics.tsClassify, store, replay, and symbolicate diagnostics
src/server/dev/plugin.tsExpose the overlay client, client-report endpoint, and cold-error response
src/server/browser-events/plugin.tsMultiplex sync, diagnostics, and DevTools over one versioned WebSocket
src/client/dev-error-overlay.tsRender diagnostics and coordinate recovery in the browser

Upstream references

Furin follows the same observable Fast Refresh goals as Next.js and the same self-accepting module model exposed by Bun and Vite, but its SSR versioning, loader refresh, topology watcher, and diagnostic protocol are Furin-specific.

Related development tooling

Furin DevTools is mounted automatically in development and reports route, request, loader, cache, payload, asset, and sync telemetry. It is adjacent to HMR but not part of the invalidation protocol: diagnostics answer "why did this revision fail?", while DevTools answers "what did the successful request do?".

DevTools endpoints live under /_furin/devtools/*, are scoped by instance, and are excluded from production together with their instrumentation implementation.

Comments