@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 +13 -11
- package/package.json +3 -2
- package/runtime/universal/fs/universal-fs.runtime.ts +253 -0
package/README.md
CHANGED
|
@@ -22,7 +22,7 @@ GET /md/projects/42 → plain Markdown
|
|
|
22
22
|
## Install
|
|
23
23
|
|
|
24
24
|
```bash
|
|
25
|
-
|
|
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
|
-
##
|
|
119
|
+
## Runtimes
|
|
120
120
|
|
|
121
|
-
emroute
|
|
122
|
-
|
|
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
|
-
|
|
125
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
+
}
|