SPA → PWA Architecture
Status: Merged modules,
createEmrouteApp/FetchRuntime(rootmode), and the ServiceWorker offline runtime (onlymode) 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, foronlymode), the sections above note the actual result.
Concept
The server is always there — the question is where it runs.
| Mode | Server location | JS on client | Base path |
|---|---|---|---|
none | remote | none | /html/, /md/ |
leaf | remote | widgets/hydration, no router | /html/, /md/ |
root | remote | createEmrouteApp + FetchRuntime | /app/ |
only | local (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/onlymodes)
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's — bootEmrouteApp() 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
.jsmodules - Deploy/migration: source runtime reads raw, target receives merged
.js
Spike result
Proven in spike/merge-module.spike.ts:
- Transpile
.ts→.jsviaBun.Transpiler - Append
export const __files = { key: \escaped content\} - Backticks,
${}, newlines all survive template literal round-trip - Blob URL import works,
__filesaccessible
Build vs Runtime Separation
bundle() and transpile() move OUT of Runtime.
Runtime = pure storage + serving:
handle()— raw passthroughquery()— read (Response or text)command()— writeloadModule()— import.jsfrom storage
Build tool (upstream, not runtime's concern):
- Scan source
.ts+ companions → merge → write.jsinto runtime - CLI:
emroute build - Dev server: watch + re-merge on change
- Browser IDE: user writes
.js, merge companions,command()into runtime
Breaking changes
SpaHtmlRouterremoved. Replaced bycreateEmrouteApp.HashRouterremoved. Can be recovered from git.base.renderer.tsremoved. DOM-based rendering pipeline gone.rootmode: client runsEmroute.create+FetchRuntimelocally.onlymode: client runs the sameFetchRuntimeasroot; offline is provided by an optional Service Worker running its ownEmroute.create+SwRuntime(Cache API + IndexedDB) — see below.noneandleafmodes: 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():
- Scans routes + widgets directories
- For each page/widget: compiles
.ts+ inlines companions → merged.js(export const __files = { html, md, css }) - Writes merged
.jsinto runtime viacommand() - 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 serversrc/renderer/spa/emroute.app.ts—EmrouteApp+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 asrootmode. - Offline reads/writes live entirely inside an optional Service Worker via
createEmrouteSW()(@emkodev/emroute/sw), which composes aSwRuntimeout ofCacheRuntime(framework assets: JS bundles, CSS, import maps) andIdbRuntime(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
onStalecallback — 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: fetchesprecachepaths into Cache API,contentpaths into IDBfetch: navigation requests go throughEmroute.handleRequest()(same server code, same rendering, same widget resolution) running inside the worker againstSwRuntime; static requests are served fromSwRuntimedirectly, network as last resortactivate: deletes oldemroute*-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.