@moriajs/core 0.1.1 → 0.3.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.
- package/.turbo/turbo-build.log +1 -1
- package/dist/app.d.ts +4 -1
- package/dist/app.d.ts.map +1 -1
- package/dist/app.js +30 -3
- package/dist/app.js.map +1 -1
- package/dist/config.d.ts +13 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js.map +1 -1
- package/dist/index.d.ts +7 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -1
- package/dist/index.js.map +1 -1
- package/dist/middleware.d.ts +61 -0
- package/dist/middleware.d.ts.map +1 -0
- package/dist/middleware.js +117 -0
- package/dist/middleware.js.map +1 -0
- package/dist/router.d.ts +81 -0
- package/dist/router.d.ts.map +1 -0
- package/dist/router.js +220 -0
- package/dist/router.js.map +1 -0
- package/dist/vite.d.ts +24 -0
- package/dist/vite.d.ts.map +1 -0
- package/dist/vite.js +63 -0
- package/dist/vite.js.map +1 -0
- package/package.json +8 -2
- package/src/app.ts +36 -3
- package/src/config.ts +17 -0
- package/src/index.ts +7 -1
- package/src/middleware.ts +159 -0
- package/src/router.ts +294 -0
- package/src/vite.ts +85 -0
package/src/router.ts
ADDED
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File-based routing for MoriaJS.
|
|
3
|
+
*
|
|
4
|
+
* Scans `src/routes/` for route files and auto-registers them with Fastify.
|
|
5
|
+
*
|
|
6
|
+
* Convention:
|
|
7
|
+
* src/routes/api/hello.ts → GET /api/hello (API handler)
|
|
8
|
+
* src/routes/api/users/[id].ts → GET /api/users/:id (API handler)
|
|
9
|
+
* src/routes/pages/index.ts → GET / (SSR page)
|
|
10
|
+
* src/routes/pages/about.ts → GET /about (SSR page)
|
|
11
|
+
*
|
|
12
|
+
* API routes export named HTTP method functions:
|
|
13
|
+
* export function GET(request, reply) { ... }
|
|
14
|
+
*
|
|
15
|
+
* Page routes export a Mithril component + optional data loader:
|
|
16
|
+
* export default { view() { return m('h1', 'Hello') } }
|
|
17
|
+
* export async function getServerData(request) { return { user: ... } }
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
|
|
21
|
+
import { glob } from 'glob';
|
|
22
|
+
import path from 'node:path';
|
|
23
|
+
import { pathToFileURL } from 'node:url';
|
|
24
|
+
import type { MoriaConfig } from './config.js';
|
|
25
|
+
import { scanMiddleware, getMiddlewareChain, type MoriaMiddleware } from './middleware.js';
|
|
26
|
+
|
|
27
|
+
/** Supported HTTP methods in route files. */
|
|
28
|
+
const HTTP_METHODS = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS'] as const;
|
|
29
|
+
type HttpMethod = (typeof HTTP_METHODS)[number];
|
|
30
|
+
|
|
31
|
+
/** Handler function exported from a route file. */
|
|
32
|
+
export type RouteHandler = (request: FastifyRequest, reply: FastifyReply) => unknown | Promise<unknown>;
|
|
33
|
+
|
|
34
|
+
/** Data loader for page routes — runs on server before render. */
|
|
35
|
+
export type GetServerData = (request: FastifyRequest) => unknown | Promise<unknown>;
|
|
36
|
+
|
|
37
|
+
/** Discovered route entry. */
|
|
38
|
+
export interface RouteEntry {
|
|
39
|
+
/** File path relative to routes dir */
|
|
40
|
+
filePath: string;
|
|
41
|
+
/** URL path pattern (e.g., /api/users/:id) */
|
|
42
|
+
urlPath: string;
|
|
43
|
+
/** Route type: 'api' or 'page' */
|
|
44
|
+
type: 'api' | 'page';
|
|
45
|
+
/** HTTP method → handler map (API routes) */
|
|
46
|
+
methods: Partial<Record<Lowercase<HttpMethod>, RouteHandler>>;
|
|
47
|
+
/** Mithril component (page routes only) */
|
|
48
|
+
component?: unknown;
|
|
49
|
+
/** Server data loader (page routes only) */
|
|
50
|
+
getServerData?: GetServerData;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Options for route registration.
|
|
55
|
+
*/
|
|
56
|
+
export interface RegisterRoutesOptions {
|
|
57
|
+
/** Application mode */
|
|
58
|
+
mode?: 'development' | 'production';
|
|
59
|
+
/** MoriaJS config for renderer options */
|
|
60
|
+
config?: Partial<MoriaConfig>;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Convert a file path to a URL path.
|
|
65
|
+
*
|
|
66
|
+
* - Strips file extension
|
|
67
|
+
* - Converts `[param]` → `:param`
|
|
68
|
+
* - Converts `[...slug]` → `*`
|
|
69
|
+
* - Converts `index` → `/`
|
|
70
|
+
*
|
|
71
|
+
* @example
|
|
72
|
+
* filePathToUrlPath('api/users/[id].ts') → '/api/users/:id'
|
|
73
|
+
* filePathToUrlPath('pages/index.ts') → '/'
|
|
74
|
+
* filePathToUrlPath('pages/about.ts') → '/about'
|
|
75
|
+
*/
|
|
76
|
+
export function filePathToUrlPath(filePath: string): string {
|
|
77
|
+
// Remove extension
|
|
78
|
+
let route = filePath.replace(/\.(ts|js|mts|mjs)$/, '');
|
|
79
|
+
|
|
80
|
+
// Normalize separators
|
|
81
|
+
route = route.replace(/\\/g, '/');
|
|
82
|
+
|
|
83
|
+
// Convert [param] → :param
|
|
84
|
+
route = route.replace(/\[([^\].]+)\]/g, ':$1');
|
|
85
|
+
|
|
86
|
+
// Convert [...slug] → *
|
|
87
|
+
route = route.replace(/\[\.\.\.([^\]]+)\]/g, '*');
|
|
88
|
+
|
|
89
|
+
// Handle pages prefix — strip "pages" and make root-relative
|
|
90
|
+
if (route.startsWith('pages/')) {
|
|
91
|
+
route = route.slice(5); // remove "pages"
|
|
92
|
+
}
|
|
93
|
+
// Handle api prefix — keep "api"
|
|
94
|
+
// (no transformation needed)
|
|
95
|
+
|
|
96
|
+
// Handle index files → parent path
|
|
97
|
+
route = route.replace(/\/index$/, '');
|
|
98
|
+
|
|
99
|
+
// Ensure leading slash
|
|
100
|
+
if (!route.startsWith('/')) {
|
|
101
|
+
route = '/' + route;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Root case
|
|
105
|
+
if (route === '') {
|
|
106
|
+
route = '/';
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return route;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Scan a directory for route files and return discovered routes.
|
|
114
|
+
*
|
|
115
|
+
* @param routesDir - Absolute path to the routes directory (e.g., `<project>/src/routes`)
|
|
116
|
+
*/
|
|
117
|
+
export async function scanRoutes(routesDir: string): Promise<RouteEntry[]> {
|
|
118
|
+
const pattern = '**/*.{ts,js,mts,mjs}';
|
|
119
|
+
const files = await glob(pattern, {
|
|
120
|
+
cwd: routesDir,
|
|
121
|
+
posix: true,
|
|
122
|
+
ignore: ['**/_*', '**/*.d.ts', '**/*.test.*', '**/*.spec.*'],
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
const routes: RouteEntry[] = [];
|
|
126
|
+
|
|
127
|
+
for (const file of files) {
|
|
128
|
+
const urlPath = filePathToUrlPath(file);
|
|
129
|
+
const type: 'api' | 'page' = file.startsWith('api/') ? 'api' : 'page';
|
|
130
|
+
|
|
131
|
+
const absolutePath = path.resolve(routesDir, file);
|
|
132
|
+
const fileUrl = pathToFileURL(absolutePath).href;
|
|
133
|
+
|
|
134
|
+
// Dynamically import the route module
|
|
135
|
+
let mod: Record<string, unknown>;
|
|
136
|
+
try {
|
|
137
|
+
mod = await import(fileUrl);
|
|
138
|
+
} catch (err) {
|
|
139
|
+
console.warn(`[moria] Failed to load route: ${file}`, err);
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (type === 'page') {
|
|
144
|
+
// ─── Page route: expects default Mithril component ───────
|
|
145
|
+
const component = mod.default;
|
|
146
|
+
const getServerData = typeof mod.getServerData === 'function'
|
|
147
|
+
? mod.getServerData as GetServerData
|
|
148
|
+
: undefined;
|
|
149
|
+
|
|
150
|
+
// Page routes can also export raw HTTP handlers (backward compat)
|
|
151
|
+
if (component && typeof component === 'object' && 'view' in component) {
|
|
152
|
+
routes.push({
|
|
153
|
+
filePath: file,
|
|
154
|
+
urlPath,
|
|
155
|
+
type: 'page',
|
|
156
|
+
methods: {},
|
|
157
|
+
component,
|
|
158
|
+
getServerData,
|
|
159
|
+
});
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Fallback: if default export is a function, treat as API-style handler
|
|
164
|
+
if (typeof component === 'function') {
|
|
165
|
+
routes.push({
|
|
166
|
+
filePath: file,
|
|
167
|
+
urlPath,
|
|
168
|
+
type: 'page',
|
|
169
|
+
methods: { get: component as RouteHandler },
|
|
170
|
+
});
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Also check for named HTTP method exports
|
|
175
|
+
const methods: Partial<Record<Lowercase<HttpMethod>, RouteHandler>> = {};
|
|
176
|
+
for (const method of HTTP_METHODS) {
|
|
177
|
+
const handler = mod[method] ?? mod[method.toLowerCase()];
|
|
178
|
+
if (typeof handler === 'function') {
|
|
179
|
+
methods[method.toLowerCase() as Lowercase<HttpMethod>] = handler as RouteHandler;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
if (Object.keys(methods).length > 0) {
|
|
183
|
+
routes.push({ filePath: file, urlPath, type: 'page', methods });
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
console.warn(`[moria] Page route has no component or handlers: ${file}`);
|
|
188
|
+
} else {
|
|
189
|
+
// ─── API route: expects named HTTP method exports ────────
|
|
190
|
+
const methods: Partial<Record<Lowercase<HttpMethod>, RouteHandler>> = {};
|
|
191
|
+
for (const method of HTTP_METHODS) {
|
|
192
|
+
const handler = mod[method] ?? mod[method.toLowerCase()];
|
|
193
|
+
if (typeof handler === 'function') {
|
|
194
|
+
methods[method.toLowerCase() as Lowercase<HttpMethod>] = handler as RouteHandler;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Also support default export as GET handler
|
|
199
|
+
if (typeof mod.default === 'function' && !methods.get) {
|
|
200
|
+
methods.get = mod.default as RouteHandler;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (Object.keys(methods).length === 0) {
|
|
204
|
+
console.warn(`[moria] Route file has no handlers: ${file}`);
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
routes.push({ filePath: file, urlPath, type: 'api', methods });
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return routes;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Register discovered routes with a Fastify server.
|
|
217
|
+
*
|
|
218
|
+
* Page routes with Mithril components are auto-wrapped with SSR rendering.
|
|
219
|
+
* API routes are registered directly as Fastify handlers.
|
|
220
|
+
* Middleware from `_middleware.ts` files is attached as `preHandler` hooks.
|
|
221
|
+
*/
|
|
222
|
+
export async function registerRoutes(
|
|
223
|
+
server: FastifyInstance,
|
|
224
|
+
routesDir: string,
|
|
225
|
+
options: RegisterRoutesOptions = {}
|
|
226
|
+
): Promise<RouteEntry[]> {
|
|
227
|
+
const routes = await scanRoutes(routesDir);
|
|
228
|
+
const mode = options.mode ?? 'development';
|
|
229
|
+
const config = options.config ?? {};
|
|
230
|
+
|
|
231
|
+
// ─── Scan file-based middleware ──────────────────────────
|
|
232
|
+
const middlewareEntries = await scanMiddleware(routesDir);
|
|
233
|
+
if (middlewareEntries.length > 0) {
|
|
234
|
+
server.log.info(
|
|
235
|
+
`Found ${middlewareEntries.length} middleware file(s): ${middlewareEntries.map((m) => m.scope || '/').join(', ')}`
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
for (const route of routes) {
|
|
240
|
+
// Resolve middleware chain for this route
|
|
241
|
+
const chain: MoriaMiddleware[] = getMiddlewareChain(route.filePath, middlewareEntries);
|
|
242
|
+
const preHandler = chain.length > 0 ? chain : undefined;
|
|
243
|
+
|
|
244
|
+
// ─── Page route with Mithril component → SSR handler ─────
|
|
245
|
+
if (route.component) {
|
|
246
|
+
const component = route.component;
|
|
247
|
+
const getServerData = route.getServerData;
|
|
248
|
+
const clientEntry = config.vite?.clientEntry ?? '/src/entry-client.ts';
|
|
249
|
+
|
|
250
|
+
server.route({
|
|
251
|
+
method: 'GET',
|
|
252
|
+
url: route.urlPath,
|
|
253
|
+
preHandler,
|
|
254
|
+
handler: async (request: FastifyRequest, reply: FastifyReply) => {
|
|
255
|
+
// Load server data if available
|
|
256
|
+
let initialData: Record<string, unknown> | undefined;
|
|
257
|
+
if (getServerData) {
|
|
258
|
+
initialData = (await getServerData(request)) as Record<string, unknown>;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Dynamic import of renderer (avoids circular deps)
|
|
262
|
+
const { renderToString } = await import('@moriajs/renderer');
|
|
263
|
+
|
|
264
|
+
const html = await renderToString(component, {
|
|
265
|
+
title: (component as { title?: string }).title ?? 'MoriaJS App',
|
|
266
|
+
initialData,
|
|
267
|
+
mode,
|
|
268
|
+
clientEntry,
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
reply.type('text/html');
|
|
272
|
+
return html;
|
|
273
|
+
},
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
server.log.info(`Page: GET ${route.urlPath} → ${route.filePath} (SSR)`);
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// ─── API / raw handler routes ────────────────────────────
|
|
281
|
+
for (const [method, handler] of Object.entries(route.methods)) {
|
|
282
|
+
server.route({
|
|
283
|
+
method: method.toUpperCase() as Uppercase<string>,
|
|
284
|
+
url: route.urlPath,
|
|
285
|
+
preHandler,
|
|
286
|
+
handler: handler as RouteHandler,
|
|
287
|
+
});
|
|
288
|
+
server.log.info(`Route: ${method.toUpperCase()} ${route.urlPath} → ${route.filePath}`);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
server.log.info(`Registered ${routes.length} file-based route(s)`);
|
|
293
|
+
return routes;
|
|
294
|
+
}
|
package/src/vite.ts
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vite integration for MoriaJS.
|
|
3
|
+
*
|
|
4
|
+
* Provides Vite dev server in middleware mode (development)
|
|
5
|
+
* and static file serving for production builds.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { FastifyInstance } from 'fastify';
|
|
9
|
+
import type { ViteDevServer } from 'vite';
|
|
10
|
+
import { type MoriaConfig } from './config.js';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Attach Vite dev server to Fastify in middleware mode.
|
|
14
|
+
* This enables HMR and on-the-fly module transformation.
|
|
15
|
+
*/
|
|
16
|
+
export async function createViteDevMiddleware(
|
|
17
|
+
server: FastifyInstance,
|
|
18
|
+
config: Partial<MoriaConfig> = {}
|
|
19
|
+
): Promise<ViteDevServer> {
|
|
20
|
+
const { createServer: createViteServer } = await import('vite');
|
|
21
|
+
const middie = (await import('@fastify/middie')).default;
|
|
22
|
+
|
|
23
|
+
// Register Express-style middleware support
|
|
24
|
+
await server.register(middie);
|
|
25
|
+
|
|
26
|
+
const vite = await createViteServer({
|
|
27
|
+
root: config.rootDir ?? process.cwd(),
|
|
28
|
+
configFile: config.vite?.configFile,
|
|
29
|
+
server: {
|
|
30
|
+
middlewareMode: true,
|
|
31
|
+
hmr: true,
|
|
32
|
+
},
|
|
33
|
+
appType: 'custom',
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
// Use Vite's connect middleware stack
|
|
37
|
+
server.use(vite.middlewares);
|
|
38
|
+
|
|
39
|
+
server.log.info('Vite dev server attached (HMR enabled)');
|
|
40
|
+
|
|
41
|
+
// Ensure Vite is closed when Fastify shuts down
|
|
42
|
+
server.addHook('onClose', async () => {
|
|
43
|
+
await vite.close();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
return vite;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Serve production-built client assets via @fastify/static.
|
|
51
|
+
*/
|
|
52
|
+
export async function serveProductionAssets(
|
|
53
|
+
server: FastifyInstance,
|
|
54
|
+
config: Partial<MoriaConfig> = {}
|
|
55
|
+
): Promise<void> {
|
|
56
|
+
const path = await import('node:path');
|
|
57
|
+
const fastifyStatic = (await import('@fastify/static')).default;
|
|
58
|
+
|
|
59
|
+
const distDir = path.resolve(config.rootDir ?? process.cwd(), 'dist', 'client');
|
|
60
|
+
|
|
61
|
+
await server.register(fastifyStatic, {
|
|
62
|
+
root: distDir,
|
|
63
|
+
prefix: '/assets/',
|
|
64
|
+
decorateReply: false,
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
server.log.info(`Serving static assets from ${distDir}`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Generate the HTML shell for a page, with appropriate script tags
|
|
72
|
+
* depending on dev vs production mode.
|
|
73
|
+
*/
|
|
74
|
+
export function getHtmlScripts(mode: 'development' | 'production', config: Partial<MoriaConfig> = {}): string {
|
|
75
|
+
if (mode === 'development') {
|
|
76
|
+
const clientEntry = config.vite?.clientEntry ?? '/src/entry-client.ts';
|
|
77
|
+
return [
|
|
78
|
+
`<script type="module" src="/@vite/client"></script>`,
|
|
79
|
+
`<script type="module" src="${clientEntry}"></script>`,
|
|
80
|
+
].join('\n ');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Production: reference the built bundle
|
|
84
|
+
return `<script type="module" src="/assets/entry-client.js"></script>`;
|
|
85
|
+
}
|