@emkodev/emroute 1.6.6-beta.4 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/dist/emroute.js +1802 -1679
  2. package/dist/emroute.js.map +3 -3
  3. package/dist/runtime/abstract.runtime.d.ts +5 -0
  4. package/dist/runtime/abstract.runtime.js +7 -0
  5. package/dist/runtime/abstract.runtime.js.map +1 -1
  6. package/dist/runtime/bun/fs/bun-fs.runtime.d.ts +1 -0
  7. package/dist/runtime/bun/fs/bun-fs.runtime.js +4 -0
  8. package/dist/runtime/bun/fs/bun-fs.runtime.js.map +1 -1
  9. package/dist/runtime/bun/sqlite/bun-sqlite.runtime.d.ts +1 -0
  10. package/dist/runtime/bun/sqlite/bun-sqlite.runtime.js +4 -0
  11. package/dist/runtime/bun/sqlite/bun-sqlite.runtime.js.map +1 -1
  12. package/dist/runtime/sitemap.generator.js +3 -3
  13. package/dist/runtime/sitemap.generator.js.map +1 -1
  14. package/dist/server/build.util.d.ts +9 -2
  15. package/dist/server/build.util.js +134 -23
  16. package/dist/server/build.util.js.map +1 -1
  17. package/dist/server/codegen.util.d.ts +11 -9
  18. package/dist/server/codegen.util.js +21 -53
  19. package/dist/server/codegen.util.js.map +1 -1
  20. package/dist/src/element/component.element.d.ts +11 -0
  21. package/dist/src/element/component.element.js +65 -0
  22. package/dist/src/element/component.element.js.map +1 -1
  23. package/dist/src/element/markdown.element.d.ts +2 -0
  24. package/dist/src/element/markdown.element.js +4 -0
  25. package/dist/src/element/markdown.element.js.map +1 -1
  26. package/dist/src/renderer/spa/mod.d.ts +1 -1
  27. package/dist/src/renderer/spa/mod.js +1 -1
  28. package/dist/src/renderer/spa/mod.js.map +1 -1
  29. package/dist/src/renderer/spa/thin-client.d.ts +16 -0
  30. package/dist/src/renderer/spa/thin-client.js +79 -0
  31. package/dist/src/renderer/spa/thin-client.js.map +1 -1
  32. package/package.json +1 -1
  33. package/runtime/abstract.runtime.ts +8 -0
  34. package/runtime/bun/fs/bun-fs.runtime.ts +4 -0
  35. package/runtime/bun/sqlite/bun-sqlite.runtime.ts +5 -0
  36. package/runtime/sitemap.generator.ts +3 -3
  37. package/server/build.util.ts +155 -25
  38. package/server/codegen.util.ts +19 -66
  39. package/src/element/component.element.ts +75 -0
  40. package/src/element/markdown.element.ts +5 -0
  41. package/src/renderer/spa/mod.ts +1 -1
  42. package/src/renderer/spa/thin-client.ts +97 -0
@@ -5,16 +5,24 @@
5
5
  * separate concern from storage. Call `buildClientBundles()` before
6
6
  * `createEmrouteServer()` to produce emroute.js + app.js.
7
7
  *
8
- * Requires esbuild as a devDependency in the consumer project.
8
+ * Route tree and widget manifest are fetched as JSON at boot time by
9
+ * `bootEmrouteApp()` — no longer compiled into app.js.
10
+ *
11
+ * Per-file module merging: each .ts page/widget is transpiled to .js with
12
+ * companion files (.html, .md, .css) inlined as `export const __files`.
13
+ * The browser lazy-loads these individual .js files — no bundler needed.
9
14
  */
10
15
 
11
16
  import { readFile } from 'node:fs/promises';
12
17
  import { createRequire } from 'node:module';
13
18
  import { resolve } from 'node:path';
14
19
  import type { Runtime } from '../runtime/abstract.runtime.ts';
15
- import { DEFAULT_ROUTES_DIR, DEFAULT_WIDGETS_DIR } from '../runtime/abstract.runtime.ts';
16
- import { createManifestPlugin } from './esbuild-manifest.plugin.ts';
17
- import { createRuntimeLoaderPlugin } from '../runtime/bun/esbuild-runtime-loader.plugin.ts';
20
+ import {
21
+ ROUTES_MANIFEST_PATH,
22
+ WIDGETS_MANIFEST_PATH,
23
+ } from '../runtime/abstract.runtime.ts';
24
+ import type { RouteNode } from '../src/type/route-tree.type.ts';
25
+ import type { WidgetManifestEntry } from '../src/type/widget.type.ts';
18
26
  import { generateMainTs } from './codegen.util.ts';
19
27
  import type { SpaMode } from '../src/type/widget.type.ts';
20
28
 
@@ -48,8 +56,10 @@ const DEFAULT_BUNDLE_PATHS = { emroute: '/emroute.js', app: '/app.js' };
48
56
  * Build client bundles and write them into the runtime.
49
57
  *
50
58
  * Produces:
59
+ * - Merged .js modules — each .ts page/widget transpiled with companions inlined
60
+ * - Updated manifests — route tree and widget manifest reference .js paths
51
61
  * - emroute.js — pre-built from dist/ (no esbuild needed for this)
52
- * - app.js — consumer entry point with routeTree, FetchRuntime, createEmrouteApp
62
+ * - app.js — consumer entry point (esbuild only touches consumer code)
53
63
  * - index.html — shell with import map + script tags (if not already present)
54
64
  */
55
65
  export async function buildClientBundles(options: BuildOptions): Promise<void> {
@@ -58,26 +68,25 @@ export async function buildClientBundles(options: BuildOptions): Promise<void> {
58
68
 
59
69
  const paths = options.bundlePaths ?? DEFAULT_BUNDLE_PATHS;
60
70
 
71
+ // Merge .ts modules → .js with inlined companions, update manifests
72
+ await mergeModules(runtime);
73
+
74
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
75
+ const esbuild = await loadEsbuild() as any;
76
+
61
77
  // Copy pre-built emroute.js from the package dist/
62
78
  const consumerRequire = createRequire(root + '/');
63
79
  const emrouteJsPath = resolvePrebuiltBundle(consumerRequire);
64
80
  const emrouteJs = await readFile(emrouteJsPath);
65
81
  await runtime.command(paths.emroute, { body: emrouteJs });
66
82
 
67
- // App bundle — generate main.ts if absent, virtual plugin resolves manifests
68
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
69
- const esbuild = await loadEsbuild() as any;
83
+ // App bundle — consumer's main.ts bundled with esbuild.
70
84
  const ep = entryPoint ?? '/main.ts';
71
85
  if ((await runtime.query(ep)).status === 404) {
72
- const hasRoutes = (await runtime.query((runtime.config.routesDir ?? DEFAULT_ROUTES_DIR) + '/')).status !== 404;
73
- const hasWidgets = (await runtime.query((runtime.config.widgetsDir ?? DEFAULT_WIDGETS_DIR) + '/')).status !== 404;
74
- const code = generateMainTs(spa, hasRoutes, hasWidgets, '@emkodev/emroute');
86
+ const code = generateMainTs(spa, '@emkodev/emroute');
75
87
  await runtime.command(ep, { body: code });
76
88
  }
77
89
 
78
- const manifestPlugin = createManifestPlugin({ runtime, resolveDir: root });
79
- const runtimeLoader = createRuntimeLoaderPlugin({ runtime, root });
80
-
81
90
  const result = await esbuild.build({
82
91
  bundle: true,
83
92
  write: false,
@@ -86,7 +95,6 @@ export async function buildClientBundles(options: BuildOptions): Promise<void> {
86
95
  entryPoints: [`${root}${ep}`],
87
96
  outfile: `${root}${paths.app}`,
88
97
  external: [...EMROUTE_EXTERNALS],
89
- plugins: [manifestPlugin, runtimeLoader],
90
98
  });
91
99
 
92
100
  for (const file of result.outputFiles) {
@@ -97,7 +105,7 @@ export async function buildClientBundles(options: BuildOptions): Promise<void> {
97
105
  }
98
106
 
99
107
  // Write shell (index.html) if not already present
100
- await writeShell(runtime, paths, ep);
108
+ await writeShell(runtime, paths);
101
109
 
102
110
  await esbuild.stop();
103
111
  }
@@ -125,7 +133,6 @@ function resolvePrebuiltBundle(require: NodeRequire): string {
125
133
  async function writeShell(
126
134
  runtime: Runtime,
127
135
  paths: { emroute: string; app: string },
128
- entryPoint: string,
129
136
  ): Promise<void> {
130
137
  if ((await runtime.query('/index.html')).status !== 404) return;
131
138
 
@@ -135,13 +142,6 @@ async function writeShell(
135
142
  }
136
143
  const importMap = JSON.stringify({ imports }, null, 2);
137
144
 
138
- const scripts = [
139
- `<script type="importmap">\n${importMap}\n </script>`,
140
- ];
141
- if (entryPoint) {
142
- scripts.push(`<script type="module" src="${paths.app}"></script>`);
143
- }
144
-
145
145
  const html = `<!DOCTYPE html>
146
146
  <html>
147
147
  <head>
@@ -152,13 +152,143 @@ async function writeShell(
152
152
  </head>
153
153
  <body>
154
154
  <router-slot></router-slot>
155
- ${scripts.join('\n ')}
155
+ <script type="importmap">
156
+ ${importMap}
157
+ </script>
158
+ <script type="module" src="${paths.app}"></script>
156
159
  </body>
157
160
  </html>`;
158
161
 
159
162
  await runtime.command('/index.html', { body: html });
160
163
  }
161
164
 
165
+ // ── Module merging ───────────────────────────────────────────────────
166
+
167
+ /** Escape backticks and ${} for safe embedding in a JS template literal. */
168
+ function escapeTemplateLiteral(s: string): string {
169
+ return s.replace(/\\/g, '\\\\').replace(/`/g, '\\`').replace(/\$\{/g, '\\${');
170
+ }
171
+
172
+ /** Convert a .ts path to .js (e.g. "routes/about.page.ts" → "routes/about.page.js"). */
173
+ function tsToJs(path: string): string {
174
+ return path.replace(/\.ts$/, '.js');
175
+ }
176
+
177
+ /**
178
+ * Read a file from runtime as text. Returns undefined if not found.
179
+ */
180
+ async function readText(runtime: Runtime, path: string): Promise<string | undefined> {
181
+ const abs = path.startsWith('/') ? path : '/' + path;
182
+ const response = await runtime.query(abs);
183
+ if (response.status === 404) return undefined;
184
+ return response.text();
185
+ }
186
+
187
+ /**
188
+ * Transpile a .ts module, inline companion files as `__files` export,
189
+ * and write the resulting .js back to the runtime.
190
+ *
191
+ * Returns the .js output path.
192
+ */
193
+ async function transpileAndMerge(
194
+ runtime: Runtime,
195
+ tsPath: string,
196
+ companionPaths?: { html?: string; md?: string; css?: string },
197
+ ): Promise<string> {
198
+ const source = await readText(runtime, tsPath);
199
+ if (!source) throw new Error(`[emroute] Module not found: ${tsPath}`);
200
+
201
+ const js = await runtime.transpile(source);
202
+ const jsPath = tsToJs(tsPath);
203
+
204
+ // Read companion files and build __files export
205
+ const entries: string[] = [];
206
+ if (companionPaths) {
207
+ for (const [key, filePath] of Object.entries(companionPaths)) {
208
+ if (!filePath) continue;
209
+ const content = await readText(runtime, filePath);
210
+ if (content !== undefined) {
211
+ entries.push(` ${key}: \`${escapeTemplateLiteral(content)}\``);
212
+ }
213
+ }
214
+ }
215
+
216
+ const merged = entries.length > 0
217
+ ? `${js}\nexport const __files = {\n${entries.join(',\n')}\n};\n`
218
+ : js;
219
+
220
+ const absJsPath = jsPath.startsWith('/') ? jsPath : '/' + jsPath;
221
+ await runtime.command(absJsPath, { body: merged });
222
+ return jsPath;
223
+ }
224
+
225
+ /**
226
+ * Walk the route tree and widget manifest, transpile+merge each .ts module,
227
+ * update the manifests to reference .js paths, and write them back.
228
+ */
229
+ async function mergeModules(runtime: Runtime): Promise<void> {
230
+ // Read route tree
231
+ const routesResponse = await runtime.query(ROUTES_MANIFEST_PATH);
232
+ if (routesResponse.status === 404) return;
233
+ const routeTree: RouteNode = await routesResponse.json();
234
+
235
+ // Read widget manifest
236
+ const widgetsResponse = await runtime.query(WIDGETS_MANIFEST_PATH);
237
+ const widgetEntries: WidgetManifestEntry[] = widgetsResponse.status !== 404
238
+ ? await widgetsResponse.json()
239
+ : [];
240
+
241
+ // Merge route modules
242
+ async function walkRoutes(node: RouteNode): Promise<void> {
243
+ if (node.files?.ts) {
244
+ const companions = {
245
+ html: node.files.html,
246
+ md: node.files.md,
247
+ css: node.files.css,
248
+ };
249
+ node.files.js = await transpileAndMerge(runtime, node.files.ts, companions);
250
+ delete node.files.ts;
251
+ delete node.files.html;
252
+ delete node.files.md;
253
+ delete node.files.css;
254
+ }
255
+
256
+ if (node.redirect && node.redirect.endsWith('.ts')) {
257
+ await transpileAndMerge(runtime, node.redirect);
258
+ node.redirect = tsToJs(node.redirect);
259
+ }
260
+
261
+ if (node.errorBoundary && node.errorBoundary.endsWith('.ts')) {
262
+ await transpileAndMerge(runtime, node.errorBoundary);
263
+ node.errorBoundary = tsToJs(node.errorBoundary);
264
+ }
265
+
266
+ if (node.children) {
267
+ for (const child of Object.values(node.children)) await walkRoutes(child);
268
+ }
269
+ if (node.dynamic) await walkRoutes(node.dynamic.child);
270
+ if (node.wildcard) await walkRoutes(node.wildcard.child);
271
+ }
272
+
273
+ await walkRoutes(routeTree);
274
+
275
+ // Merge widget modules
276
+ for (const entry of widgetEntries) {
277
+ if (entry.modulePath.endsWith('.ts')) {
278
+ entry.modulePath = await transpileAndMerge(runtime, entry.modulePath, entry.files);
279
+ delete entry.files;
280
+ }
281
+ }
282
+
283
+ // Write updated manifests back
284
+ await runtime.command(ROUTES_MANIFEST_PATH, {
285
+ body: JSON.stringify(routeTree),
286
+ });
287
+ await runtime.command(WIDGETS_MANIFEST_PATH, {
288
+ body: JSON.stringify(widgetEntries),
289
+ });
290
+ }
291
+
162
292
  // ── esbuild loader ────────────────────────────────────────────────────
163
293
 
164
294
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -2,87 +2,40 @@
2
2
  * Code Generation Utilities
3
3
  *
4
4
  * Generates a default main.ts entry point for SPA bootstrapping.
5
- * Manifest data is resolved at bundle time via the esbuild virtual
6
- * manifest plugin (`emroute:routes`, `emroute:widgets`).
5
+ * The generated code simply calls `bootEmrouteApp()` which handles
6
+ * fetching manifests, creating the runtime, and wiring navigation.
7
7
  */
8
8
 
9
- import type { BasePath } from '../src/route/route.core.ts';
10
9
  import type { SpaMode } from '../src/type/widget.type.ts';
11
10
 
12
11
  /**
13
- * Generate a main.ts entry point for SPA bootstrapping.
12
+ * Generate a minimal main.ts entry point.
14
13
  *
15
- * For `root`/`only` modes: creates an EmrouteServer with FetchRuntime
16
- * in the browser and wires it to Navigation API via `createEmrouteApp`.
14
+ * For `root`/`only` modes: calls `bootEmrouteApp()` which fetches
15
+ * manifests as JSON, creates FetchRuntime, registers widgets lazily,
16
+ * and wires Navigation API.
17
17
  *
18
- * Imports route tree and widget manifests from virtual `emroute:` specifiers
19
- * that the esbuild manifest plugin resolves at bundle time.
18
+ * For `leaf` mode: just imports the SPA module (registers custom elements).
19
+ *
20
+ * Consumer can replace this with a hand-written main.ts that sets up
21
+ * MarkdownElement renderer, registers custom elements, etc.
20
22
  */
21
23
  export function generateMainTs(
22
24
  spa: SpaMode,
23
- hasRoutes: boolean,
24
- hasWidgets: boolean,
25
25
  importPath: string,
26
- basePath?: BasePath,
27
26
  ): string {
28
- const imports: string[] = [];
29
- const body: string[] = [];
30
-
31
27
  const spaImport = `${importPath}/spa`;
32
28
 
33
- if (hasWidgets) {
34
- imports.push(`import { ComponentElement, WidgetRegistry } from '${spaImport}';`);
35
- imports.push(`import { widgetsManifest } from 'emroute:widgets';`);
36
- body.push('const widgets = new WidgetRegistry();');
37
- body.push('for (const entry of widgetsManifest.widgets) {');
38
- body.push(
39
- ' const mod = await widgetsManifest.moduleLoaders![entry.modulePath]() as Record<string, unknown>;',
40
- );
41
- body.push(' for (const exp of Object.values(mod)) {');
42
- body.push(" if (exp && typeof exp === 'object' && 'getData' in exp) {");
43
- body.push(' widgets.add(exp as any);');
44
- body.push(' ComponentElement.register(exp as any, entry.files);');
45
- body.push(' break;');
46
- body.push(' }');
47
- body.push(" if (typeof exp === 'function' && exp.prototype?.getData) {");
48
- body.push(' const instance = new (exp as new () => any)();');
49
- body.push(' widgets.add(instance);');
50
- body.push(
51
- ' ComponentElement.registerClass(exp as new () => any, entry.name, entry.files);',
52
- );
53
- body.push(' break;');
54
- body.push(' }');
55
- body.push(' }');
56
- body.push('}');
57
- }
58
-
59
- if ((spa === 'root' || spa === 'only') && hasRoutes) {
60
- imports.push(`import { routeTree, moduleLoaders } from 'emroute:routes';`);
61
- imports.push(`import { createEmrouteServer } from '${importPath}/server';`);
62
- imports.push(`import { FetchRuntime } from '${importPath}/runtime/fetch';`);
63
- imports.push(`import { createEmrouteApp } from '${spaImport}';`);
64
-
65
- body.push('const runtime = new FetchRuntime(location.origin);');
66
-
67
- // Merge route + widget module loaders so the browser never calls runtime.loadModule()
68
- const loadersExpr = hasWidgets
69
- ? '{ ...moduleLoaders, ...widgetsManifest.moduleLoaders }'
70
- : 'moduleLoaders';
71
-
72
- const configParts = ['routeTree', `moduleLoaders: ${loadersExpr}`];
73
- if (hasWidgets) configParts.push('widgets');
74
- if (basePath) {
75
- configParts.push(`basePath: { html: '${basePath.html}', md: '${basePath.md}', app: '${basePath.app}' }`);
76
- }
77
- body.push(`const server = await createEmrouteServer({ ${configParts.join(', ')} }, runtime);`);
29
+ if (spa === 'root' || spa === 'only') {
30
+ return `/** Auto-generated entry point do not edit. */
31
+ import { bootEmrouteApp } from '${spaImport}';
78
32
 
79
- const appOpts = basePath
80
- ? `{ basePath: { html: '${basePath.html}', md: '${basePath.md}', app: '${basePath.app}' } }`
81
- : '';
82
- body.push(`await createEmrouteApp(server${appOpts ? ', ' + appOpts : ''});`);
33
+ await bootEmrouteApp();
34
+ `;
83
35
  }
84
36
 
85
- return `/** Auto-generated entry point do not edit. */\n${imports.join('\n')}\n\n${
86
- body.join('\n')
87
- }\n`;
37
+ // leaf mode just import spa module to register custom elements
38
+ return `/** Auto-generated entry point — do not edit. */
39
+ import '${spaImport}';
40
+ `;
88
41
  }
@@ -27,6 +27,12 @@ export class ComponentElement<TParams, TData> extends HTMLElementBase {
27
27
  /** Shared file content cache — deduplicates fetches across all widget instances. */
28
28
  private static fileCache = new Map<string, Promise<string | undefined>>();
29
29
 
30
+ /** Lazy module loaders keyed by tag name — set by registerLazy(). */
31
+ private static lazyLoaders = new Map<string, () => Promise<unknown>>();
32
+
33
+ /** Cached module promises for lazy-loaded widgets — avoids re-fetching. */
34
+ private static lazyModules = new Map<string, Promise<unknown>>();
35
+
30
36
  /** App-level context provider set once during router initialization. */
31
37
  private static extendContext: ContextProvider | undefined;
32
38
 
@@ -111,6 +117,42 @@ export class ComponentElement<TParams, TData> extends HTMLElementBase {
111
117
  customElements.define(tagName, BoundElement);
112
118
  }
113
119
 
120
+ /**
121
+ * Register a widget lazily: define the custom element immediately (so SSR
122
+ * content via Declarative Shadow DOM is adopted), but defer loading the
123
+ * module until connectedCallback fires. Once loaded, the real component
124
+ * replaces the placeholder and hydration proceeds normally.
125
+ */
126
+ static registerLazy(
127
+ name: string,
128
+ files: WidgetFiles | undefined,
129
+ loader: () => Promise<unknown>,
130
+ ): void {
131
+ const tagName = `widget-${name}`;
132
+ if (!globalThis.customElements || customElements.get(tagName)) return;
133
+
134
+ ComponentElement.lazyLoaders.set(tagName, loader);
135
+
136
+ // Placeholder component — replaced by the real one once the module loads.
137
+ // Cast needed because Component is abstract; the real module replaces this.
138
+ const placeholder = {
139
+ name,
140
+ getData: () => Promise.resolve(null),
141
+ renderHTML: () => '',
142
+ renderMarkdown: () => '',
143
+ renderError: () => '',
144
+ renderMarkdownError: () => '',
145
+ } as unknown as Component<unknown, unknown>;
146
+
147
+ const BoundElement = class extends ComponentElement<unknown, unknown> {
148
+ constructor() {
149
+ super(placeholder, files);
150
+ }
151
+ };
152
+
153
+ customElements.define(tagName, BoundElement);
154
+ }
155
+
114
156
  /**
115
157
  * Promise that resolves when component is ready (data loaded and rendered).
116
158
  * Used by router to wait for async components.
@@ -124,6 +166,39 @@ export class ComponentElement<TParams, TData> extends HTMLElementBase {
124
166
  }
125
167
 
126
168
  async connectedCallback(): Promise<void> {
169
+ // Lazy module loading — resolve actual component before proceeding
170
+ const tagName = this.tagName.toLowerCase();
171
+ const lazyLoader = ComponentElement.lazyLoaders.get(tagName);
172
+ if (lazyLoader) {
173
+ try {
174
+ let modulePromise = ComponentElement.lazyModules.get(tagName);
175
+ if (!modulePromise) {
176
+ modulePromise = lazyLoader();
177
+ ComponentElement.lazyModules.set(tagName, modulePromise);
178
+ }
179
+ const mod = await modulePromise as Record<string, unknown>;
180
+ for (const exp of Object.values(mod)) {
181
+ if (exp && typeof exp === 'object' && 'getData' in exp) {
182
+ const WidgetClass = exp.constructor as new () => Component<TParams, TData>;
183
+ this.component = new WidgetClass();
184
+ break;
185
+ }
186
+ if (typeof exp === 'function' && (exp as { prototype?: { getData?: unknown } }).prototype?.getData) {
187
+ this.component = new (exp as new () => Component<TParams, TData>)();
188
+ break;
189
+ }
190
+ }
191
+ } catch {
192
+ // Module failed to load (e.g. raw .ts served without transpilation).
193
+ // SSR content is already visible — skip hydration gracefully.
194
+ if (this.hasAttribute(SSR_ATTR)) {
195
+ this.removeAttribute(SSR_ATTR);
196
+ this.signalReady();
197
+ return;
198
+ }
199
+ }
200
+ }
201
+
127
202
  this.component.element = this;
128
203
  this.style.contentVisibility = 'auto';
129
204
  this.abortController = new AbortController();
@@ -30,6 +30,11 @@ export class MarkdownElement extends HTMLElementBase {
30
30
  MarkdownElement.rendererInitPromise = renderer.init ? renderer.init() : null;
31
31
  }
32
32
 
33
+ /** Get the current renderer (if set). Used by bootEmrouteApp to pass through to createEmrouteServer. */
34
+ static getConfiguredRenderer(): MarkdownRenderer | null {
35
+ return MarkdownElement.renderer;
36
+ }
37
+
33
38
  /**
34
39
  * Get the current renderer, waiting for init if needed.
35
40
  */
@@ -12,7 +12,7 @@ import { MarkdownElement } from '../../element/markdown.element.ts';
12
12
  import { ComponentElement } from '../../element/component.element.ts';
13
13
  import { WidgetRegistry } from '../../widget/widget.registry.ts';
14
14
 
15
- export { createEmrouteApp, EmrouteApp, type EmrouteAppOptions } from './thin-client.ts';
15
+ export { bootEmrouteApp, createEmrouteApp, EmrouteApp, type BootOptions, type EmrouteAppOptions } from './thin-client.ts';
16
16
  export { ComponentElement, MarkdownElement, RouterSlot, WidgetRegistry };
17
17
  export type { SpaMode, WidgetsManifest } from '../../type/widget.type.ts';
18
18
 
@@ -9,9 +9,17 @@
9
9
  */
10
10
 
11
11
  import type { EmrouteServer } from '../../../server/server-api.type.ts';
12
+ import { createEmrouteServer } from '../../../server/emroute.server.ts';
13
+ import { FetchRuntime } from '../../../runtime/fetch.runtime.ts';
14
+ import { ROUTES_MANIFEST_PATH, WIDGETS_MANIFEST_PATH } from '../../../runtime/abstract.runtime.ts';
15
+ import type { RouteNode } from '../../type/route-tree.type.ts';
12
16
  import type { NavigateOptions } from '../../type/route.type.ts';
17
+ import type { WidgetManifestEntry } from '../../type/widget.type.ts';
13
18
  import { assertSafeRedirect, type BasePath, DEFAULT_BASE_PATH } from '../../route/route.core.ts';
14
19
  import { escapeHtml } from '../../util/html.util.ts';
20
+ import { ComponentElement } from '../../element/component.element.ts';
21
+ import { MarkdownElement } from '../../element/markdown.element.ts';
22
+ import { WidgetRegistry } from '../../widget/widget.registry.ts';
15
23
 
16
24
  /** Options for `createEmrouteApp`. */
17
25
  export interface EmrouteAppOptions {
@@ -163,3 +171,92 @@ export async function createEmrouteApp(
163
171
  g.__emroute_app = app;
164
172
  return app;
165
173
  }
174
+
175
+ // ── Boot ──────────────────────────────────────────────────────────────
176
+
177
+ /** Options for `bootEmrouteApp`. */
178
+ export interface BootOptions extends EmrouteAppOptions {
179
+ /** Override the server origin (defaults to `location.origin`). */
180
+ origin?: string;
181
+ }
182
+
183
+ /**
184
+ * Boot the browser app from runtime manifests.
185
+ *
186
+ * Fetches route tree and widget manifest as JSON, creates lazy module
187
+ * loaders via FetchRuntime, registers widgets for deferred hydration,
188
+ * and wires the Navigation API.
189
+ *
190
+ * Consumer `main.ts` calls this after setting up MarkdownElement renderer,
191
+ * custom elements, etc.
192
+ */
193
+ export async function bootEmrouteApp(options?: BootOptions): Promise<EmrouteApp> {
194
+ const origin = options?.origin ?? location.origin;
195
+ const runtime = new FetchRuntime(origin);
196
+
197
+ // Fetch route tree
198
+ const routesResponse = await runtime.handle(ROUTES_MANIFEST_PATH);
199
+ if (!routesResponse.ok) {
200
+ throw new Error(`[emroute] Failed to fetch ${ROUTES_MANIFEST_PATH}: ${routesResponse.status}`);
201
+ }
202
+ const routeTree: RouteNode = await routesResponse.json();
203
+
204
+ // Fetch widget manifest (optional — app may have no widgets)
205
+ const widgetsResponse = await runtime.handle(WIDGETS_MANIFEST_PATH);
206
+ const widgetEntries: WidgetManifestEntry[] = widgetsResponse.ok
207
+ ? await widgetsResponse.json()
208
+ : [];
209
+
210
+ // Build lazy module loaders for all route + widget modules
211
+ const moduleLoaders = buildLazyLoaders(routeTree, widgetEntries, runtime);
212
+
213
+ // Register widgets eagerly (tag defined immediately, module loads on connectedCallback)
214
+ const widgets = new WidgetRegistry();
215
+ for (const entry of widgetEntries) {
216
+ ComponentElement.registerLazy(entry.name, entry.files, moduleLoaders[entry.modulePath]);
217
+ }
218
+
219
+ // Create the server (reuses the same createEmrouteServer as SSR)
220
+ const server = await createEmrouteServer({
221
+ routeTree,
222
+ widgets,
223
+ moduleLoaders,
224
+ markdownRenderer: MarkdownElement.getConfiguredRenderer() ?? undefined,
225
+ }, runtime);
226
+
227
+ return createEmrouteApp(server, options);
228
+ }
229
+
230
+ /**
231
+ * Walk the route tree and widget entries to build a map of
232
+ * `path → () => runtime.loadModule(path)` lazy loaders.
233
+ */
234
+ function buildLazyLoaders(
235
+ tree: RouteNode,
236
+ widgetEntries: WidgetManifestEntry[],
237
+ runtime: FetchRuntime,
238
+ ): Record<string, () => Promise<unknown>> {
239
+ const paths = new Set<string>();
240
+
241
+ function walk(node: RouteNode): void {
242
+ const modulePath = node.files?.ts ?? node.files?.js;
243
+ if (modulePath) paths.add(modulePath);
244
+ if (node.redirect) paths.add(node.redirect);
245
+ if (node.errorBoundary) paths.add(node.errorBoundary);
246
+ if (node.children) {
247
+ for (const child of Object.values(node.children)) walk(child);
248
+ }
249
+ if (node.dynamic) walk(node.dynamic.child);
250
+ if (node.wildcard) walk(node.wildcard.child);
251
+ }
252
+
253
+ walk(tree);
254
+ for (const entry of widgetEntries) paths.add(entry.modulePath);
255
+
256
+ const loaders: Record<string, () => Promise<unknown>> = {};
257
+ for (const path of paths) {
258
+ const absolute = path.startsWith('/') ? path : '/' + path;
259
+ loaders[path] = () => runtime.loadModule(absolute);
260
+ }
261
+ return loaders;
262
+ }