Live Reload

bext supports PHP-like live reload: change a source file and the browser updates automatically. No restart, no manual refresh. The server watches for file changes, triggers an optimized rebuild, atomically swaps the V8 pool, and notifies connected browsers to soft-refresh.

How it works

Developer saves a file
      │
      ▼
  File watcher detects mtime change (1s poll)
      │
      ▼
  Debounce (200ms) — wait for batch saves
      │
      ▼
  Fast or full rebuild (see below)
      │
      ▼
  Create new V8 pool from fresh bundle
      │
      ▼
  Atomic swap (RwLock) — in-flight requests finish on old pool
      │
      ▼
  Clear ISR + component + compression caches
      │
      ▼
  Notify browsers (SSE event or ETag change)
      │
      ▼
  Browser soft-refreshes — swaps content + styles, no full reload

Configuration

# bext.config.toml
[build]
watch_dirs = ["server"]
live_reload = true        # enable in production (always on in dev mode)
debounce_ms = 200         # wait for batch saves (default: 200ms)
Key Type Default Description
watch_dirs string[] [] Directories to watch for changes
live_reload bool false Enable automatic reload in production
debounce_ms u64 200 Debounce window for batch saves
script string (none) Build script to run (full rebuild only)
bun_path string "bun" Path to the Bun binary
timeout_secs u64 60 Maximum build time before timeout

Two rebuild modes

The watcher automatically picks the best mode based on what changed:

Changed files Mode Speed What runs
.ts, .tsx, .js, .jsx Fast ~50ms Direct bun build entry.ts — skips build script
.css, .json, .toml Full ~200ms Runs your [build] script (Tailwind, etc.)

The fast path invokes Bun's bundler directly with the entry file, bypassing the external build script. This means editing a page or route handler reloads in under 100ms total.

Client-side refresh

When the server rebuilds, browsers need to know. The framework provides three notification modes:

Auto-detect (recommended)



// In your template's <body>:
{clientRuntime({ liveReload: true })}

With liveReload: true, the client tries SSE first (instant, 0ms latency). If the server doesn't have the realtime feature enabled, it falls back to ETag polling (2s latency). No configuration needed — it just works.

SSE mode (instant, requires realtime feature)

{clientRuntime({ liveReload: "sse" })}

When the server swaps the pool, it pushes a "reload" event via Server-Sent Events. The browser receives it instantly and soft-refreshes. Requires [realtime] enabled = true in your bext config.

Polling mode (works everywhere)

{clientRuntime({ liveReload: "poll", pollInterval: 2000 })}

The browser sends a HEAD request every 2 seconds and checks the ETag header. When the pool swaps, all caches are cleared, so the next request has a different ETag. The client detects the change and soft-refreshes.

No live reload (just client navigation)

{clientRuntime({ navigation: true, liveReload: false })}

Client-side navigation still works (Turbo-like link interception, pushState, prefetch on hover) but no auto-refresh on server changes.

What "soft refresh" does

When a rebuild is detected, the client doesn't do a full page reload. Instead:

1. Re-fetches the current page's HTML via fetch() 2. Updates <link rel="stylesheet"> tags — new cache-busted URLs are added, stale ones removed 3. Updates <style> tags — inline styles are replaced with new content 4. Swaps <main> content — the page body updates without touching <nav>, <header>, <footer> 5. Uses View Transitions API if available — smooth crossfade animation

This means:

- Navigation state is preserved (scroll position resets to top)

- External stylesheets with cache-busted URLs always pull the latest CSS

- Inline styles (common in template sites) are always fresh

- No flash of unstyled content

Client-side navigation

The client runtime also provides SPA-like navigation (enabled by default):

{clientRuntime({ navigation: true })}

Features:

- Link interception — same-origin <a> clicks fetch HTML and swap <main>, no full page load

- history.pushState — URL updates without reload, back/forward works

- Prefetch on hover — after 65ms hover, pre-fetches the target page

- View Transitions API — smooth crossfade when available

- Fallback — external links, downloads, modifier keys (Ctrl+click) still work normally

Combined with live reload:

{clientRuntime({ navigation: true, liveReload: true })}

This gives you instant page transitions AND automatic updates when you edit source files.

Cache-busted assets

For Tailwind or other external CSS, use content-hashed filenames:

// Build script


await buildCSS({
  sources: ["server"],
  outFile: "dist/styles.[hash].css",      // → styles.18vjj5s.css
  manifest: "dist/css-manifest.json",      // → { "styles.*.css": "styles.18vjj5s.css" }
});
// Template


loadManifest("dist/css-manifest.json");

function RootLayout({ children }) {
  return (
    <html>
      <head>
        {stylesheet(asset("/styles.css"))}  {/* → /styles.18vjj5s.css */}
      </head>
      <body>
        <main>{children}</main>
        {clientRuntime({ liveReload: true })}
      </body>
    </html>
  );
}

When the CSS changes, the build produces a new hash, the manifest updates, and the template references the new URL. The client's soft-refresh detects the changed <link> tag and loads the new stylesheet.

Three trigger methods

1. Automatic (file watcher)

Enabled by watch_dirs + live_reload = true. Watches .ts, .tsx, .js, .jsx, .css, .json, .toml files. Ignores node_modules, .git, dist.

2. API endpoint

# Full rebuild (runs build script)
curl -X POST http://localhost:3000/__bext/admin/api/bundle/reload

# Fast rebuild (TS only, skips build script)
curl -X POST http://localhost:3000/__bext/admin/api/bundle/fast-reload

Response:

{
  "ok": true,
  "mode": "fast",
  "bundle_kb": 44,
  "build_ms": 48,
  "load_ms": 12,
  "total_ms": 60
}

3. SIGHUP signal

kill -HUP $(pidof bext-server)

Useful for deploy scripts and systemd integration.

Production safety

Live reload is safe for production:

- Zero dropped requests. RwLock allows concurrent reads (every request) with rare writes (pool swap). The write lock blocks new requests for microseconds.

- Failed builds are harmless. If the build fails, the old pool and caches remain untouched. An error is logged, the server keeps running.

- Bounded memory. The old pool is dropped after in-flight requests complete. No leak from repeated reloads.

- Correct caches. ISR, component, and compression caches are cleared atomically after the swap. No stale content.

Build times

Phase Duration
File change detection ~1s (polling interval)
Debounce window 200ms (configurable)
Fast rebuild (TS only) 30-80ms
Full rebuild (with Tailwind) 100-300ms
V8 pool creation (4 workers) 5-15ms
Atomic pool swap < 1ms
Client notification (SSE) < 1ms
Client notification (poll) ~2s
Total (fast + SSE) ~250ms
Total (full + poll) ~3.5s

Example: docs site with instant preview

# bext.config.toml
[server]
port = 3021

[build]
watch_dirs = ["server", "content"]
live_reload = true
debounce_ms = 300
// server/entry.ts







const router = defineRoutes({
  layout: ({ children }) => (
    <html>
      <head>
        {stylesheet("/styles.css")}
      </head>
      <body>
        <nav><a href="/">Home</a> | <a href="/docs">Docs</a></nav>
        <main>{children}</main>
        {clientRuntime({ navigation: true, liveReload: true })}
      </body>
    </html>
  ),
  routes: {
    "/": { page: () => <h1>Welcome</h1> },
    "/docs/[...path]": {
      page: (ctx) => {
        const content = pages.getPage("/docs/" + ctx.params.path);
        return content
          ? <article dangerouslySetInnerHTML={{ __html: content.html }} />
          : <p>Doc not found.</p>;
      },
    },
  },
});

createSite({ hostname: "docs.example.com", router });

Edit any file in server/ or content/ → the browser updates in under a second, with smooth transitions and no full page reload.