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:
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.tsxcontains the client route table, hydration entry, stable hot component registry, loader refresh hook, and HMR connection recovery.index.htmlis Bun's HTML entry. Furin mounts itsHTMLBundleat/_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.
| Option | Benefit | Cost | Decision |
|---|---|---|---|
| Bun HMR plus Furin coherence | One process and one browser bundler; preserves React state while keeping SSR and loaders fresh | Furin must maintain versioned server imports, route rescanning, and diagnostics | Selected |
| Full-page reload after every edit | Smallest implementation | Loses local state and hides invalidation bugs | Rejected |
| Vite or Rsbuild development server | Mature HMR graph, plugin lifecycle, and error handling | Introduces a second development architecture and weakens the Bun-only design | Rejected |
| Bun HMR alone | No framework orchestration | Browser code can move ahead of SSR modules, loaders, and the route snapshot | Insufficient |
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:
- Register the
furin-dev-page-loaderruntime plugin. - Scan
pagesDirand build the initial root, route, and Elysia snapshots. - 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. - Import the generated HTML as a Bun
HTMLBundleand mount it at the instance's/_bun_hmr_entrypath. - 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.
- 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:
/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:
- Record application imports in the development module graph.
- Select the server side of isomorphic functions.
- Rewrite relative imports to absolute, versioned specifiers.
- Transpile TypeScript and JSX with
Bun.Transpiler. - Resolve bare imports from the authored module's directory.
- Canonicalize React and React DOM imports.
- 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, andstaticParams; - 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:
- invalidates the current page's prefetch entry;
- requests fresh route data with
x-furin-hmr-refresh: 1; - asks the topology watcher to refresh before resolving that data request;
- reruns or retrieves loaders according to the current route mode; and
- 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:
| Mode | Loader behavior | HTML behavior |
|---|---|---|
| SSR | Run the current loader chain for every request | Render from current source |
| SSG | Cache synchronous loader data until a tracked source changes | Re-render HTML for every request |
| ISR | Cache synchronous loader data for revalidate, unless a tracked source changes first, keyed on the declared query params like production | Re-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 request | Prerender 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.
| Phase | Origin |
|---|---|
transform | Furin's server transform or parser |
import | Route evaluation or a transitive application import |
loader | Root, layout, or page loader execution |
render | Server React render |
hydrate | Initial browser hydration |
client-render | React 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.errorshows or replaces the active overlay;furin.readyclears a warm overlay after a successful route revision;- a cold
500page 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:
/_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:
reactreact/jsx-runtimereact/jsx-dev-runtimereact-domreact-dom/serverreact-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
| Edit | Expected result |
|---|---|
| Page component | Preserve compatible React state; render the latest implementation |
| Hook order or signature | Remount the affected component; keep unaffected parents mounted |
| Root or nested layout | Patch the stable component and refresh the server route chain |
Loader, mode, or revalidate | Refresh route data using the newly rebuilt route definition |
| Imported local module or re-export | Propagate through the browser graph and use a versioned SSR dependency |
| Route or convention add/remove | Replace route snapshots and regenerate the client table without a manual restart |
CSS or @import dependency | Let Bun update styles while React state remains mounted |
| Server entry, API, or runtime internals | Let bun --hot re-evaluate the affected server graph |
| HMR transport reconnect | Reload 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:
[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:webviewruns the real-browser conformance tier withBun.WebViewand Chrome.FURIN_WEBVIEW_TESTS=1 bun test --todo tests/browser/hmr-conformance.webview.test.tsruns the known-gap specifications frompackages/core.bun run test:hmr:soakruns 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
| File | Responsibility |
|---|---|
src/build/hydrate.ts | Generate the client route table, stable hot registry, root reuse, data refresh, and reconnect handling |
src/plugin/transform-client.ts | Remove server-only route stages, derive hook signatures, and inject route accept boundaries |
src/client/hmr.ts | Preserve component identities and remount boundaries when hook signatures change |
src/client/router/provider.tsx | Perform HMR data refreshes and reject stale navigations |
src/server/dev-page-plugin.ts | Load versioned SSR modules and transform their source |
src/server/dev-page-imports.ts | Rewrite imports of those modules and preserve React singletons |
src/server/router/source-version.ts | Compute source revisions and advance the explicit generation |
src/server/router/dispatch.ts | Dispatch pages for both runtimes through their route source, module loader, and cache policy ports |
src/server/router/hmr.ts | Rebuild routes, refresh layout chains, and provide the development ports: current modules, the loader cache, and server diagnostics |
src/server/cache/dev-loader.ts | Cache loader output and invalidate it by route dependencies |
src/plugin/dev-routes.ts | Generate Elysia route trees and watch route topology |
src/server/render/template.ts | Read current Bun document assets and inject framework clients |
src/server/dev/graph.ts | Own route snapshots, module revisions, dependency chains, and development cache state |
src/server/dev/diagnostics.ts | Classify, store, replay, and symbolicate diagnostics |
src/server/dev/plugin.ts | Expose the overlay client, client-report endpoint, and cold-error response |
src/server/browser-events/plugin.ts | Multiplex sync, diagnostics, and DevTools over one versioned WebSocket |
src/client/dev-error-overlay.ts | Render diagnostics and coordinate recovery in the browser |
Upstream references
- Bun full-stack development server
- Bun hot reloading and
import.meta.hot - Next.js Fast Refresh
- TanStack Start architecture
- Vite HMR API
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.