Server Setup
The server consists of two parts: a runtime that reads and serves files (including on-the-fly TypeScript transpilation), and a server that handles requests and renders pages. An optional build step can ship pre-built client assets (emroute.js, app.js, importmap.json) for SPA modes.
Minimal server
import { Emroute } from '@emkodev/emroute/server';
import { UniversalFsRuntime } from '@emkodev/emroute/runtime/universal/fs';
import { renderMarkdown } from '@emkodev/emkoma/render';
const appRoot = import.meta.dirname!;
const runtime = new UniversalFsRuntime(appRoot);
const emroute = await Emroute.create({
spa: 'none',
markdownRenderer: { render: renderMarkdown },
}, runtime);
Bun.serve({
port: 1420,
async fetch(req) {
const response = await emroute.handleRequest(req);
if (response) return response;
return new Response('Not Found', { status: 404 });
},
});
With SPA (root mode)
BunFsRuntime serves .ts files as transpiled JavaScript on the fly, so a runtime build step is not required for development. For SPA modes, call buildClientBundles() once to produce the SPA shell assets:
import { Emroute } from '@emkodev/emroute/server';
import { buildClientBundles } from '@emkodev/emroute/server/build';
import { BunFsRuntime } from '@emkodev/emroute/runtime/bun/fs';
import { renderMarkdown } from '@emkodev/emkoma/render';
const appRoot = import.meta.dirname!;
const runtime = new BunFsRuntime(appRoot);
await buildClientBundles({
runtime,
root: appRoot,
spa: 'root',
// entryPoint: '/main.ts', // optional, defaults to '/main.ts'
});
const emroute = await Emroute.create({
spa: 'root',
markdownRenderer: { render: renderMarkdown },
}, runtime);
Bun.serve({
port: 1420,
async fetch(req) {
const response = await emroute.handleRequest(req);
if (response) return response;
return new Response('Not Found', { status: 404 });
},
});
buildClientBundles() produces the SPA shell assets:
- Copies
emroute.js(pre-built framework bundle) into the runtime - Transpiles the consumer's
main.tstoapp.js(or auto-generates a defaultmain.tsif none exists) - Copies
main.cssfrom disk into the runtime when it exists on disk and isn't already present in the runtime — if you've already seeded/main.cssviaruntime.command(), the build leaves it alone - Writes a merged
importmap.jsoncombining the framework's externals with any user-defined entries
When using a filesystem runtime, these assets land in your project root as emroute.js, app.js, and importmap.json. Add them to .gitignore — they're build outputs regenerated on every server start:
emroute.js
app.js
If you maintain your own importmap.json with custom entries, keep it checked in — buildClientBundles() reads it, merges in the framework's externals, and writes the result back.
Per-route module compilation (merging .ts source with its .html, .md, .css companions into a single .js artifact) happens at request time by the runtime when a .ts URL is requested — it's not part of the build step.
Adding files requires a restart. The route and widget manifests are scanned once and cached in the runtime. Editing an existing
.page.ts/.page.md/.page.html/.widget.*is picked up live, but adding a new file (or moving/renaming one) means restarting the server before the route is reachable.
Runtime config
The filesystem runtime takes the app root directory and an optional config object:
const runtime = new UniversalFsRuntime(appRoot, {
routesDir: '/routes', // Optional. Default: '/routes'
widgetsDir: '/widgets', // Optional. Default: '/widgets'
elementsDir: '/elements', // Optional. Default: '/elements'
});
All paths are relative to appRoot and start with /.
Server config
Emroute.create() accepts:
| Option | Type | Default | Description |
|---|---|---|---|
spa | 'none' | 'leaf' | 'root' | 'only' | 'root' | SPA mode (see below) |
title | string | 'emroute' | Default page <title> |
markdownRenderer | { render(md: string): string } | — | Converts markdown to HTML in <mark-down> elements |
extendContext | (base: ComponentContext) => ComponentContext | — | Inject services into every component's context |
basePath | { html: string, md: string, app: string } | { html: '/html', md: '/md', app: '/app' } | URL prefixes for SSR and SPA endpoints |
routeTree | RouteNode | — | Pre-built route tree (skips runtime scanning) |
moduleLoaders | Record<string, () => Promise<unknown>> | — | Pre-built module loaders (used in browser) |
shell | (ctx: ShellContext) => string | Promise<string> | — | Custom HTML shell. Receives { runtime, spa, basePath, title }. Falls back to a built-in default. |
widgets | — | Ignored. Widgets are resolved from the manifest via Runtime. |
handleRequest composability
handleRequest() returns Response | null. When it returns null, the request didn't match any route — you handle it:
Bun.serve({
async fetch(req) {
// Your API routes first
if (new URL(req.url).pathname.startsWith('/api/')) {
return handleApi(req);
}
// emroute handles everything else
const response = await emroute.handleRequest(req);
if (response) return response;
// Nothing matched
return new Response('Not Found', { status: 404 });
},
});
Consumer main.ts
When using any SPA mode except 'none', the build step bundles a consumer entry point. If the file doesn't exist, a default main.ts is auto-generated.
To customize setup (e.g., configuring a markdown renderer for client-side rendering of .md pages), create your own main.ts:
// main.ts
import { bootEmrouteApp, MarkdownElement } from '@emkodev/emroute/spa';
import { renderMarkdown } from '@emkodev/emkoma/render';
MarkdownElement.setRenderer({ render: renderMarkdown });
await bootEmrouteApp();
bootEmrouteApp() handles everything: fetches the route, widget, and element manifests as JSON from the runtime, registers widgets with lazy module loading, imports and registers discovered elements, creates the SPA router, and wires client-side navigation.
SPA modes
The spa option controls how the server handles requests:
| Mode | GET / | GET /about | /html/* | /md/* |
|---|---|---|---|---|
'none' | 302 → /html | 302 → /html/about | SSR HTML | SSR MD |
'leaf' | 302 → /html | 302 → /html/about | SSR HTML + JS | SSR MD |
'root' | 302 → /app | 302 → /app/about | SSR HTML + JS + SPA router | SSR MD |
'only' | 302 → /app | 302 → /app/about | 302 → /app/* | 302 → /app/* |
In root and only modes, bare paths redirect to /app/* (the SPA endpoint). In none and leaf modes, they redirect to /html/*.
'none'— SSR HTML only. No client-side JavaScript.'leaf'— SSR HTML with JS bundles. Widgets hydrate, but no emroute client-side router.'root'(default) — SSR HTML with JS bundles and emroute SPA router. After initial load, link clicks are handled client-side.'only'— SPA shell with JS bundles and router. No SSR content.
Extending context
Inject app-level services (RPC clients, auth, feature flags) so every component can access them:
const emroute = await Emroute.create({
// ...
extendContext: (base) => ({
...base,
rpc: myRpcClient,
auth: authService,
}),
}, runtime);
Access in components:
override async getData({ context }: this['DataArgs']) {
return context.rpc.getProjects();
}
For TypeScript support, augment the ComponentContext interface:
declare module '@emkodev/emroute' {
interface ComponentContext {
rpc: RpcClient;
auth: AuthService;
}
}
Markdown renderer
The markdownRenderer converts markdown to HTML for SSR HTML mode. Without it, .page.md content is wrapped in <mark-down> tags but not rendered — meaning ` `router-slot` blocks won't become <router-slot>` elements and nesting won't work for pages that use markdown-only nesting.
The renderer must implement { render(md: string): string } and handle emroute's fenced block conventions (` `router-slot` ,
`widget:name` `). See Markdown Renderers for the full guide, including setup instructions for marked and markdown-it.
SSR Markdown mode (/md/*) returns raw markdown text — slot replacement happens on the raw markdown before any rendering, so markdownRenderer is not involved.
Next: Markdown Renderers