@emkodev/emroute 1.6.1 → 1.6.2

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.
package/README.md CHANGED
@@ -22,7 +22,7 @@ GET /md/projects/42 → plain Markdown
22
22
  ## Install
23
23
 
24
24
  ```bash
25
- bun add @emkodev/emroute
25
+ npm add @emkodev/emroute # or bun add, pnpm add, yarn add
26
26
  ```
27
27
 
28
28
  > emroute ships TypeScript source. Your toolchain must handle `.ts` imports
@@ -116,18 +116,20 @@ export default new ProjectPage();
116
116
  - **Sitemap generation** — opt-in `sitemap.xml` from the routes manifest with support for dynamic route enumerators
117
117
  - **Dev server** — zero-config: auto-generates `main.ts`, `index.html`, and route/widget manifests. File watcher with hot reload and bundle serving
118
118
 
119
- ## Why Bun?
119
+ ## Runtimes
120
120
 
121
- emroute 1.5.x shipped on JSR (Deno's registry). Starting with 1.6.0, emroute
122
- publishes to npm and targets Bun as the primary runtime.
121
+ emroute ships two filesystem runtimes:
122
+
123
+ - **`UniversalFsRuntime`** — uses only `node:` APIs and esbuild. Works on Node,
124
+ Deno, and Bun. The default choice for getting started.
125
+ - **`BunFsRuntime`** — uses Bun-native APIs (`Bun.file()`, `Bun.write()`,
126
+ `Bun.Transpiler`) for better I/O performance in production on Bun.
123
127
 
124
- **TL;DR:** JSR's design freezes the entire module graph at publish time. This
125
- breaks dynamic `import()` of consumer dependencies, peer dependency
126
- deduplication, and runtime resolution of package entry points for bundling — all
127
- things a framework with plugin architecture needs. The npm/`node_modules` model
128
- handles them with zero friction.
128
+ emroute ships TypeScript source. Your runtime must handle `.ts` imports natively
129
+ (Bun, Deno) or via a loader (`tsx`, `node --experimental-strip-types`).
129
130
 
130
- Full analysis with documentation and issue references:
131
+ emroute 1.5.x shipped on JSR (Deno's registry). Starting with 1.6.0, emroute
132
+ publishes to npm. Full analysis:
131
133
  [ADR-0017 — Move to Bun ecosystem](doc/architecture/ADR-0017-move-to-bun-ecosystem.md).
132
134
 
133
135
  ## Getting Started
@@ -144,7 +146,7 @@ See [Setup](doc/01-setup.md) and [First Route](doc/02-first-route.md).
144
146
  - [Widgets](doc/06-widgets.md) — interactive islands with data lifecycle
145
147
  - [Server](doc/07-server.md) — `createEmrouteServer`, composition, static files
146
148
  - [Markdown renderers](doc/08-markdown-renderer.md) — pluggable parser interface and setup
147
- - [Runtime](doc/09-runtime.md) — abstract runtime, BunFsRuntime, BunSqliteRuntime
149
+ - [Runtime](doc/09-runtime.md) — abstract runtime, UniversalFsRuntime, BunFsRuntime, BunSqliteRuntime
148
150
  - [SPA modes](doc/10-spa-mode.md) — none, leaf, root, only
149
151
  - [Error handling](doc/11-error-handling.md) — widget errors, boundaries, status pages
150
152
  - [Shadow DOM](doc/12-shadow-dom.md) — unified architecture, SSR hydration
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@emkodev/emroute",
3
- "version": "1.6.1",
3
+ "version": "1.6.2",
4
4
  "description": "File-based router with triple rendering (SPA, SSR HTML, SSR Markdown). Zero dependencies.",
5
5
  "license": "BSD-3-Clause",
6
6
  "author": "emko.dev",
@@ -37,7 +37,8 @@
37
37
  "./runtime": "./runtime/abstract.runtime.ts",
38
38
  "./runtime/sitemap": "./runtime/sitemap.generator.ts",
39
39
  "./runtime/bun/fs": "./runtime/bun/fs/bun-fs.runtime.ts",
40
- "./runtime/bun/sqlite": "./runtime/bun/sqlite/bun-sqlite.runtime.ts"
40
+ "./runtime/bun/sqlite": "./runtime/bun/sqlite/bun-sqlite.runtime.ts",
41
+ "./runtime/universal/fs": "./runtime/universal/fs/universal-fs.runtime.ts"
41
42
  },
42
43
  "devDependencies": {
43
44
  "@eslint/js": "^10.0.1",
@@ -0,0 +1,253 @@
1
+ import { readFile, writeFile, stat, readdir, mkdir } from 'node:fs/promises';
2
+ import { createRequire } from 'node:module';
3
+ import { resolve } from 'node:path';
4
+ import {
5
+ CONTENT_TYPES,
6
+ DEFAULT_ROUTES_DIR,
7
+ DEFAULT_WIDGETS_DIR,
8
+ EMROUTE_EXTERNALS,
9
+ type FetchParams,
10
+ type FetchReturn,
11
+ ROUTES_MANIFEST_PATH,
12
+ Runtime,
13
+ type RuntimeConfig,
14
+ WIDGETS_MANIFEST_PATH,
15
+ } from '../../abstract.runtime.ts';
16
+ import { createManifestPlugin } from '../../../server/esbuild-manifest.plugin.ts';
17
+ import { createRuntimeLoaderPlugin } from '../../bun/esbuild-runtime-loader.plugin.ts';
18
+ import { generateMainTs } from '../../../server/codegen.util.ts';
19
+
20
+ /**
21
+ * Filesystem runtime using only `node:` APIs and esbuild. Works on Node,
22
+ * Deno, and Bun without platform-specific dependencies.
23
+ *
24
+ * Not recommended for production — `node:fs/promises` has higher overhead
25
+ * than platform-native alternatives (e.g. `Bun.file()`). Use
26
+ * {@link BunFsRuntime} on Bun for better I/O performance.
27
+ */
28
+ export class UniversalFsRuntime extends Runtime {
29
+ private readonly root: string;
30
+
31
+ constructor(root: string, config: RuntimeConfig = {}) {
32
+ if (config.entryPoint && !config.bundlePaths) {
33
+ config.bundlePaths = { emroute: '/emroute.js', app: '/app.js' };
34
+ }
35
+ super(config);
36
+ const abs = resolve(root);
37
+ this.root = abs.endsWith('/') ? abs.slice(0, -1) : abs;
38
+ }
39
+
40
+ handle(
41
+ resource: FetchParams[0],
42
+ init?: FetchParams[1],
43
+ ): FetchReturn {
44
+ const [pathname, method, body] = this.parse(resource, init);
45
+ const path = `${this.root}${pathname}`;
46
+
47
+ switch (method) {
48
+ case 'PUT':
49
+ return this.write(path, body);
50
+ default:
51
+ return this.read(path);
52
+ }
53
+ }
54
+
55
+ query(
56
+ resource: FetchParams[0],
57
+ options: FetchParams[1] & { as: 'text' },
58
+ ): Promise<string>;
59
+ query(
60
+ resource: FetchParams[0],
61
+ options?: FetchParams[1],
62
+ ): FetchReturn;
63
+ query(
64
+ resource: FetchParams[0],
65
+ options?: FetchParams[1] & { as?: 'text' },
66
+ ): Promise<Response | string> {
67
+ if (options?.as === 'text') {
68
+ const pathname = this.parsePath(resource);
69
+ return readFile(`${this.root}${pathname}`, 'utf-8');
70
+ }
71
+ return this.handle(resource, options);
72
+ }
73
+
74
+ private parsePath(resource: FetchParams[0]): string {
75
+ if (typeof resource === 'string') return decodeURIComponent(resource);
76
+ if (resource instanceof URL) return decodeURIComponent(resource.pathname);
77
+ return decodeURIComponent(new URL(resource.url).pathname);
78
+ }
79
+
80
+ private parse(
81
+ resource: FetchParams[0],
82
+ init?: RequestInit,
83
+ ): [string, string, BodyInit | null] {
84
+ const pathname = this.parsePath(resource);
85
+ if (typeof resource === 'string' || resource instanceof URL) {
86
+ return [pathname, init?.method ?? 'GET', init?.body ?? null];
87
+ }
88
+ return [
89
+ pathname,
90
+ init?.method ?? resource.method,
91
+ init?.body ?? resource.body,
92
+ ];
93
+ }
94
+
95
+ private async read(path: string): Promise<Response> {
96
+ try {
97
+ const info = await stat(path);
98
+
99
+ if (info.isDirectory()) {
100
+ return this.list(path);
101
+ }
102
+
103
+ const content = new Uint8Array(await readFile(path));
104
+ const ext = path.slice(path.lastIndexOf('.')).toLowerCase();
105
+ const headers: HeadersInit = {
106
+ 'Content-Type': CONTENT_TYPES.get(ext) ?? 'application/octet-stream',
107
+ 'Content-Length': content.byteLength.toString(),
108
+ };
109
+
110
+ if (info.mtime) {
111
+ headers['Last-Modified'] = info.mtime.toUTCString();
112
+ }
113
+
114
+ return new Response(content, { status: 200, headers });
115
+ } catch (error) {
116
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
117
+ const pathname = path.slice(this.root.length);
118
+ if (pathname === ROUTES_MANIFEST_PATH) return this.resolveRoutesManifest();
119
+ if (pathname === WIDGETS_MANIFEST_PATH) return this.resolveWidgetsManifest();
120
+ return new Response('Not Found', { status: 404 });
121
+ }
122
+ return new Response(`Internal Error: ${error}`, { status: 500 });
123
+ }
124
+ }
125
+
126
+ private async list(path: string): Promise<Response> {
127
+ const entries: string[] = [];
128
+ const dirents = await readdir(path, { withFileTypes: true });
129
+ for (const entry of dirents) {
130
+ entries.push(entry.name + (entry.isDirectory() ? '/' : ''));
131
+ }
132
+ return Response.json(entries);
133
+ }
134
+
135
+ private async write(path: string, body: BodyInit | null): Promise<Response> {
136
+ try {
137
+ const content = body
138
+ ? new Uint8Array(await new Response(body).arrayBuffer())
139
+ : new Uint8Array();
140
+ const dir = path.slice(0, path.lastIndexOf('/'));
141
+ if (dir) await mkdir(dir, { recursive: true });
142
+ await writeFile(path, content);
143
+ return new Response(null, { status: 204 });
144
+ } catch (error) {
145
+ return new Response(`Write failed: ${error}`, { status: 500 });
146
+ }
147
+ }
148
+
149
+ override loadModule(path: string): Promise<unknown> {
150
+ return import(this.root + path);
151
+ }
152
+
153
+ // ── Bundling ─────────────────────────────────────────────────────────
154
+
155
+ override async bundle(): Promise<void> {
156
+ if (this.config.spa === 'none') return;
157
+ const paths = this.config.bundlePaths;
158
+ if (!paths) return;
159
+
160
+ const esbuild = await UniversalFsRuntime.esbuild();
161
+ const builds: Promise<{ outputFiles: { path: string; contents: Uint8Array }[] }>[] = [];
162
+ const shared = { bundle: true, write: false, format: 'esm' as const, platform: 'browser' as const };
163
+ const runtimeLoader = createRuntimeLoaderPlugin({ runtime: this, root: this.root });
164
+
165
+ // Emroute SPA bundle — resolve from consumer's node_modules (no runtime loader needed)
166
+ const consumerRequire = createRequire(this.root + '/');
167
+ const spaEntry = consumerRequire.resolve('@emkodev/emroute/spa');
168
+ builds.push(esbuild.build({
169
+ ...shared,
170
+ entryPoints: [spaEntry],
171
+ outfile: `${this.root}${paths.emroute}`,
172
+ }));
173
+
174
+ // App bundle — generate main.ts if absent, virtual plugin resolves manifests
175
+ if (this.config.entryPoint) {
176
+ if ((await this.query(this.config.entryPoint)).status === 404) {
177
+ const hasRoutes = (await this.query((this.config.routesDir ?? DEFAULT_ROUTES_DIR) + '/')).status !== 404;
178
+ const hasWidgets = (await this.query((this.config.widgetsDir ?? DEFAULT_WIDGETS_DIR) + '/')).status !== 404;
179
+ const code = generateMainTs('root', hasRoutes, hasWidgets, '@emkodev/emroute');
180
+ await this.command(this.config.entryPoint, { body: code });
181
+ }
182
+ const manifestPlugin = createManifestPlugin({
183
+ runtime: this,
184
+ basePath: '/html',
185
+ resolveDir: this.root,
186
+ });
187
+ builds.push(esbuild.build({
188
+ ...shared,
189
+ entryPoints: [`${this.root}${this.config.entryPoint}`],
190
+ outfile: `${this.root}${paths.app}`,
191
+ external: [...EMROUTE_EXTERNALS],
192
+ plugins: [manifestPlugin, runtimeLoader],
193
+ }));
194
+ }
195
+
196
+ // Widgets bundle
197
+ if (paths.widgets) {
198
+ const widgetsTsPath = paths.widgets.replace('.js', '.ts');
199
+ if ((await this.query(widgetsTsPath)).status !== 404) {
200
+ builds.push(esbuild.build({
201
+ ...shared,
202
+ entryPoints: [`${this.root}${widgetsTsPath}`],
203
+ outfile: `${this.root}${paths.widgets}`,
204
+ external: [...EMROUTE_EXTERNALS],
205
+ plugins: [runtimeLoader],
206
+ }));
207
+ }
208
+ }
209
+
210
+ const results = await Promise.all(builds);
211
+
212
+ // Write all output files through the runtime
213
+ for (const result of results) {
214
+ for (const file of result.outputFiles) {
215
+ const runtimePath = file.path.startsWith(this.root)
216
+ ? file.path.slice(this.root.length)
217
+ : '/' + file.path;
218
+ await this.command(runtimePath, { body: file.contents as unknown as BodyInit });
219
+ }
220
+ }
221
+
222
+ await this.writeShell(paths);
223
+
224
+ await esbuild.stop();
225
+ UniversalFsRuntime._esbuild = null;
226
+ }
227
+
228
+ // ── Transpile / esbuild ───────────────────────────────────────────────
229
+
230
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
231
+ private static _esbuild: any = null;
232
+
233
+ private static async esbuild() {
234
+ if (!UniversalFsRuntime._esbuild) {
235
+ const consumerRequire = createRequire(process.cwd() + '/');
236
+ UniversalFsRuntime._esbuild = consumerRequire('esbuild');
237
+ }
238
+ return UniversalFsRuntime._esbuild;
239
+ }
240
+
241
+ static override async transpile(source: string): Promise<string> {
242
+ const esbuild = await UniversalFsRuntime.esbuild();
243
+ const result = await esbuild.transform(source, { loader: 'ts' });
244
+ return result.code;
245
+ }
246
+
247
+ static override async stopBundler(): Promise<void> {
248
+ if (UniversalFsRuntime._esbuild) {
249
+ await UniversalFsRuntime._esbuild.stop();
250
+ UniversalFsRuntime._esbuild = null;
251
+ }
252
+ }
253
+ }