SPA → PWA Architecture

Status: Merged modules, createEmrouteApp/FetchRuntime (root mode), and the ServiceWorker offline runtime (only mode) all shipped in 1.7–1.9. The "Implementation phases" section below is a historical design log kept for context — read it as how it was planned, not as a description of unbuilt work. Where the shipped design diverged from the plan (it did, for only mode), the sections above note the actual result.

Concept

The server is always there — the question is where it runs.

ModeServer locationJS on clientBase path
noneremotenone/html/, /md/
leafremotewidgets/hydration, no router/html/, /md/
rootremotecreateEmrouteApp + FetchRuntime/app/
onlylocal (SW optional)createEmrouteApp + FetchRuntime, same as root/app/

Three base paths, three audiences:

  • /html/ — SSR HTML for browsers without JS, progressive enhancement
  • /md/ — markdown for machines (curl, LLMs)
  • /app/ — PWA/SPA for browsers with JS (root/only modes)

createEmrouteApp = Emroute.create running in browser + Navigation API glue (~20 lines). Same server, same trie, same SSR renderers. The Navigation API intercepts /app/ link clicks, strips prefix, calls htmlRouter.render(), injects content into <router-slot>.

only mode's client-side code is identical to root'sbootEmrouteApp() always constructs a FetchRuntime and talks to the network, regardless of mode. There is no separate "offline runtime" the main thread switches to. Offline support in only mode comes entirely from an optional Service Worker registered by the consumer (createEmrouteSW, see "Phase 3" and "Phase 4" below) — it transparently intercepts the same fetch() calls at the network layer. The main thread never knows whether it's talking to a live server or a Service Worker serving from cache.

Merged Modules

Runtime only stores .js. No .ts at runtime.

Each page/widget is a single merged .js file: compiled component code with companion file contents (.html, .md, .css) inlined:

// compiled component...
export default page;

export const __files = {
  html: `<h1>About</h1>`,
  css: `.about { color: red }`
};

loadModule() returns { default: component, __files } — no separate file reads needed. buildComponentContext reads from __files.

Content scenarios

  • Developer: writes .ts + companions → build step → merged .js
  • End-user (browser IDE, CMS): writes .js + companions → merge → .js
  • Third-party widgets: distributed as merged .js modules
  • Deploy/migration: source runtime reads raw, target receives merged .js

Spike result

Proven in spike/merge-module.spike.ts:

  • Transpile .ts.js via Bun.Transpiler
  • Append export const __files = { key: \escaped content\ }
  • Backticks, ${}, newlines all survive template literal round-trip
  • Blob URL import works, __files accessible

Build vs Runtime Separation

bundle() and transpile() move OUT of Runtime.

Runtime = pure storage + serving:

  • handle() — raw passthrough
  • query() — read (Response or text)
  • command() — write
  • loadModule() — import .js from storage

Build tool (upstream, not runtime's concern):

  • Scan source .ts + companions → merge → write .js into runtime
  • CLI: emroute build
  • Dev server: watch + re-merge on change
  • Browser IDE: user writes .js, merge companions, command() into runtime

Breaking changes

  • SpaHtmlRouter removed. Replaced by createEmrouteApp.
  • HashRouter removed. Can be recovered from git.
  • base.renderer.ts removed. DOM-based rendering pipeline gone.
  • root mode: client runs Emroute.create + FetchRuntime locally.
  • only mode: client runs the same FetchRuntime as root; offline is provided by an optional Service Worker running its own Emroute.create + SwRuntime (Cache API + IndexedDB) — see below.
  • none and leaf modes: unaffected.
  • Bare path redirects: /about/app/about (was /html/about) in root/only.

Implementation phases

Historical design log. "Shipped" phases describe what actually exists today; "Planned, not built" phases were considered and abandoned or never started — don't rely on them.

Phase 1: Merged module build step — shipped (1.7)

bundle()/transpile() extracted from Runtime into buildClientBundles():

  1. Scans routes + widgets directories
  2. For each page/widget: compiles .ts + inlines companions → merged .js (export const __files = { html, md, css })
  3. Writes merged .js into runtime via command()
  4. Builds SPA entry bundles (emroute.js, app.js)

Pipeline reads __files off the loaded module first, falling back to separate companion reads when a module isn't pre-merged (dev mode).

Phase 2: FetchRuntime + createEmrouteApp (root mode) — shipped (1.7)

  • runtime/fetch.runtime.ts — fetches from remote server
  • src/renderer/spa/emroute.app.tsEmrouteApp + createEmrouteApp
  • Server serves shell at /app/*, redirects bare paths to /app/

Phase 3: Offline runtime (only mode) — shipped (1.9), different design than planned

The original plan was a UniversalBrowserRuntime used directly by the main thread, composing online + offline reads with a query()-time freshness check. That's not what shipped. Instead:

  • The main thread's runtime is unchanged — still plain FetchRuntime, same as root mode.
  • Offline reads/writes live entirely inside an optional Service Worker via createEmrouteSW() (@emkodev/emroute/sw), which composes a SwRuntime out of CacheRuntime (framework assets: JS bundles, CSS, import maps) and IdbRuntime (user content: pages, widgets, manifests).
  • SwRuntime.handle() reads Cache first, falls back to IDB; writes (PUT/DELETE) always go to IDB.
  • There is no freshness check and no onStale callback — reads are cache/IDB-first with no online comparison. See Phase 5.

Phase 4: Service Worker shell (only mode, optional) — shipped (1.9)

Consumer writes sw.ts calling createEmrouteSW({ cacheName, precache, content }):

  • install: fetches precache paths into Cache API, content paths into IDB
  • fetch: navigation requests go through Emroute.handleRequest() (same server code, same rendering, same widget resolution) running inside the worker against SwRuntime; static requests are served from SwRuntime directly, network as last resort
  • activate: deletes old emroute*-prefixed caches, claims clients

Phase 5: Update strategy — planned, not built

No part of this shipped. There is no background freshness check, no onStale callback, no user-facing "new content available" prompt, and no automatic cache eviction. Cache busting today is manual: bump cacheName in createEmrouteSW() options to force a clean install. Treat this as an open gap, not a documented API.