bunigniter 0.2.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/LICENSE +21 -0
- package/README.md +229 -0
- package/dist/LICENSE +21 -0
- package/dist/README.md +229 -0
- package/dist/base/controller.ts +324 -0
- package/dist/base/index.ts +5 -0
- package/dist/base/service.ts +21 -0
- package/dist/cli/index.ts +318 -0
- package/dist/cli/list-routes.ts +72 -0
- package/dist/cli/repl.ts +461 -0
- package/dist/cli/templates.ts +283 -0
- package/dist/client/index.ts +159 -0
- package/dist/db/drizzle.ts +550 -0
- package/dist/db/validators.ts +229 -0
- package/dist/edge-builder.ts +120 -0
- package/dist/edge.ts +69 -0
- package/dist/helpers/cache.ts +173 -0
- package/dist/helpers/cors.ts +103 -0
- package/dist/helpers/csrf.ts +155 -0
- package/dist/helpers/debug.ts +158 -0
- package/dist/helpers/env.ts +147 -0
- package/dist/helpers/handler.ts +158 -0
- package/dist/helpers/http.ts +194 -0
- package/dist/helpers/image.ts +217 -0
- package/dist/helpers/jwt.ts +147 -0
- package/dist/helpers/logger.ts +96 -0
- package/dist/helpers/mail.ts +272 -0
- package/dist/helpers/middleware-loader.ts +116 -0
- package/dist/helpers/middleware.ts +57 -0
- package/dist/helpers/modules.ts +115 -0
- package/dist/helpers/openapi.ts +140 -0
- package/dist/helpers/pagination.ts +159 -0
- package/dist/helpers/queue.ts +186 -0
- package/dist/helpers/request-context.ts +13 -0
- package/dist/helpers/request.ts +376 -0
- package/dist/helpers/schedule.ts +173 -0
- package/dist/helpers/session-middleware.ts +89 -0
- package/dist/helpers/session.ts +286 -0
- package/dist/helpers/sse.ts +90 -0
- package/dist/helpers/throttle.ts +156 -0
- package/dist/helpers/upload.ts +417 -0
- package/dist/helpers/validator.ts +287 -0
- package/dist/helpers/ws.ts +123 -0
- package/dist/index.ts +221 -0
- package/dist/package.json +70 -0
- package/dist/router/file-router.ts +541 -0
- package/dist/router/server-router.ts +103 -0
- package/dist/view/page.ts +96 -0
- package/dist/view/renderer.tsx +390 -0
- package/dist/view/view-response.ts +10 -0
- package/package.json +70 -0
|
@@ -0,0 +1,541 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File Router — CodeIgniter-style file-path routing.
|
|
3
|
+
*
|
|
4
|
+
* Maps file paths to URL routes automatically:
|
|
5
|
+
* ```
|
|
6
|
+
* pages/
|
|
7
|
+
* ├── users.ts → GET/POST /api/users
|
|
8
|
+
* ├── users/
|
|
9
|
+
* │ └── [id].ts → GET /api/users/:id
|
|
10
|
+
* ├── auth/
|
|
11
|
+
* │ └── login.ts → GET/POST /api/auth/login
|
|
12
|
+
* └── index.ts → GET /api
|
|
13
|
+
* ```
|
|
14
|
+
*
|
|
15
|
+
* Convention:
|
|
16
|
+
* - `pages/users.ts` → `/api/users`
|
|
17
|
+
* - `pages/users/[id].ts` → `/api/users/:id`
|
|
18
|
+
* - `pages/index.ts` → `/api/`
|
|
19
|
+
* - Exported class extending `Controller` is auto-registered
|
|
20
|
+
* - Method names map to HTTP verbs: `index`=GET, `show`=GET/:id, `create`=POST, `update`=PUT, `destroy`=DELETE
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { readdirSync, statSync, existsSync } from "node:fs";
|
|
24
|
+
import { join, basename } from "node:path";
|
|
25
|
+
import { type Elysia, t } from "elysia";
|
|
26
|
+
import type { Controller } from "../base/controller";
|
|
27
|
+
import type { DbClient } from "../db/drizzle";
|
|
28
|
+
import type { Cache } from "../helpers/cache";
|
|
29
|
+
import type { Queue } from "../helpers/queue";
|
|
30
|
+
import type { Upload } from "../helpers/upload";
|
|
31
|
+
import type { Mail } from "../helpers/mail";
|
|
32
|
+
import { Session } from "../helpers/session";
|
|
33
|
+
import { PageResponse } from "../view/page";
|
|
34
|
+
import { ViewResponse } from "../view/view-response";
|
|
35
|
+
import { renderView } from "../view/renderer";
|
|
36
|
+
import { generateToolbar, getStore } from "../helpers/debug";
|
|
37
|
+
import { setRequestContext } from "../helpers/request-context";
|
|
38
|
+
|
|
39
|
+
export interface FileRouterOptions {
|
|
40
|
+
/** Directory containing route files. Default: `routes` */
|
|
41
|
+
directory?: string;
|
|
42
|
+
|
|
43
|
+
/** Views directory for module support. Overrides global views. */
|
|
44
|
+
viewsDir?: string;
|
|
45
|
+
|
|
46
|
+
/** URL prefix for all routes. Default: `/api` */
|
|
47
|
+
prefix?: string;
|
|
48
|
+
|
|
49
|
+
/** Database instance to inject into controllers. */
|
|
50
|
+
db?: DbClient;
|
|
51
|
+
|
|
52
|
+
/** Named databases (multi-database support). */
|
|
53
|
+
dbs?: Record<string, DbClient>;
|
|
54
|
+
|
|
55
|
+
/** Cache instance. */
|
|
56
|
+
cache?: Cache;
|
|
57
|
+
|
|
58
|
+
/** Queue instance. */
|
|
59
|
+
queue?: Queue;
|
|
60
|
+
|
|
61
|
+
/** Upload instance. */
|
|
62
|
+
upload?: Upload;
|
|
63
|
+
|
|
64
|
+
/** Mail instance. */
|
|
65
|
+
mail?: Mail;
|
|
66
|
+
|
|
67
|
+
/** Called when a controller is registered (for DI/decoration). */
|
|
68
|
+
onRegister?: (controller: Controller) => void;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
interface LoaderExport {
|
|
72
|
+
loader?: (ctx: any) => Promise<Record<string, any>>;
|
|
73
|
+
action?:
|
|
74
|
+
| ((ctx: any, args?: any) => Promise<void>)
|
|
75
|
+
| { config?: any; fn?: any };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Render a page component to HTML (server-side). */
|
|
79
|
+
function renderPage(component: string, props: Record<string, any>): string {
|
|
80
|
+
// Build props JSON to embed in HTML shell
|
|
81
|
+
const propsJson = escapeHtml(JSON.stringify(props));
|
|
82
|
+
return `<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>${escapeHtml(component)}</title></head><body><div id="app" data-page='${propsJson}'></div></body></html>`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function escapeHtml(s: string): string {
|
|
86
|
+
return s
|
|
87
|
+
.replace(/&/g, "&")
|
|
88
|
+
.replace(/'/g, "'")
|
|
89
|
+
.replace(/"/g, """)
|
|
90
|
+
.replace(/</g, "<")
|
|
91
|
+
.replace(/>/g, ">");
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Default method-to-verb mapping (CodeIgniter-style). */
|
|
95
|
+
const METHOD_MAP: Record<string, string> = Object.assign(Object.create(null), {
|
|
96
|
+
index: "GET",
|
|
97
|
+
show: "GET",
|
|
98
|
+
create: "POST",
|
|
99
|
+
store: "POST",
|
|
100
|
+
update: "PUT",
|
|
101
|
+
destroy: "DELETE",
|
|
102
|
+
edit: "GET",
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
/** Methods that need a param ID. */
|
|
106
|
+
const ID_METHODS = new Set(["show", "update", "destroy", "edit"]);
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Scan a directory and auto-register routes.
|
|
110
|
+
*
|
|
111
|
+
* @example
|
|
112
|
+
* ```ts
|
|
113
|
+
* // routes/users.ts — handles GET /api/users, POST /api/users
|
|
114
|
+
* export class Users extends Controller {
|
|
115
|
+
* async index() { return this.json(await this.db.query('SELECT * FROM users')) }
|
|
116
|
+
* async show(id: number) { return this.json(await this.db.query('SELECT * FROM users WHERE id = ?', [id])) }
|
|
117
|
+
* async create() { const body = await this.body; ... }
|
|
118
|
+
* }
|
|
119
|
+
* ```
|
|
120
|
+
*/
|
|
121
|
+
export async function registerFileRoutes(
|
|
122
|
+
app: Elysia,
|
|
123
|
+
options: FileRouterOptions = {},
|
|
124
|
+
): Promise<void> {
|
|
125
|
+
const dir = options.directory ?? "routes";
|
|
126
|
+
const prefix = options.prefix ?? "/api";
|
|
127
|
+
|
|
128
|
+
// Ensure directory exists
|
|
129
|
+
try {
|
|
130
|
+
statSync(dir);
|
|
131
|
+
} catch {
|
|
132
|
+
console.warn(`[router] routes directory not found: ${dir}`);
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// First pass: register .server.ts loader/action routes
|
|
137
|
+
const serverFiles = scanDir(dir, ".server.ts");
|
|
138
|
+
for (const file of serverFiles) {
|
|
139
|
+
const fullPath = join(dir, file);
|
|
140
|
+
const serverMod = (await import(
|
|
141
|
+
/* @vite-ignore */ join(process.cwd(), dir, file)
|
|
142
|
+
)) as LoaderExport;
|
|
143
|
+
const componentName = file.replace(/\.server\.ts$/, "");
|
|
144
|
+
const urlPath = filePathToUrl(componentName + ".ts", prefix);
|
|
145
|
+
|
|
146
|
+
if (serverMod.loader) {
|
|
147
|
+
const handler = async (_ctx: any) => {
|
|
148
|
+
const props = await (serverMod.loader as Function)(_ctx);
|
|
149
|
+
// Serve as HTML shell (SSR placeholder) + JSON props
|
|
150
|
+
return new Response(renderPage(componentName, props), {
|
|
151
|
+
headers: { "content-type": "text/html; charset=utf-8" },
|
|
152
|
+
});
|
|
153
|
+
};
|
|
154
|
+
registerRoute(app, "GET", urlPath, handler, null as any, options);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (serverMod.action) {
|
|
158
|
+
const action = serverMod.action as any;
|
|
159
|
+
const handler = async (_ctx: any) => {
|
|
160
|
+
let body;
|
|
161
|
+
try {
|
|
162
|
+
body = await _ctx.request.json();
|
|
163
|
+
} catch {
|
|
164
|
+
body = {};
|
|
165
|
+
}
|
|
166
|
+
if (action.fn) {
|
|
167
|
+
await action.fn(_ctx, { body });
|
|
168
|
+
} else {
|
|
169
|
+
await action(_ctx, { body });
|
|
170
|
+
}
|
|
171
|
+
return new Response(null, { status: 204 });
|
|
172
|
+
};
|
|
173
|
+
registerRoute(app, "POST", urlPath, handler, null as any, options);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Second pass: register Controller routes
|
|
178
|
+
const files = scanDir(dir, ".ts");
|
|
179
|
+
|
|
180
|
+
for (const file of files) {
|
|
181
|
+
if (file.endsWith(".server.ts")) continue;
|
|
182
|
+
|
|
183
|
+
const fullPath = join(process.cwd(), dir, file);
|
|
184
|
+
const mod = await import(/* @vite-ignore */ fullPath);
|
|
185
|
+
|
|
186
|
+
// Find the Controller subclass
|
|
187
|
+
const ControllerClass = findController(mod);
|
|
188
|
+
|
|
189
|
+
// If file has ws.handle() calls without a Controller, still import it
|
|
190
|
+
// (WS handlers register via side-effect at module load time)
|
|
191
|
+
if (!ControllerClass) continue;
|
|
192
|
+
|
|
193
|
+
const controller = new ControllerClass() as Controller;
|
|
194
|
+
|
|
195
|
+
// Inject services
|
|
196
|
+
if (options.db) {
|
|
197
|
+
Object.defineProperty(controller, "db", {
|
|
198
|
+
value: options.db,
|
|
199
|
+
writable: false,
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
if (options.dbs) {
|
|
203
|
+
Object.defineProperty(controller, "dbs", {
|
|
204
|
+
value: options.dbs,
|
|
205
|
+
writable: false,
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
if (options.cache) {
|
|
209
|
+
Object.defineProperty(controller, "cache", {
|
|
210
|
+
value: options.cache,
|
|
211
|
+
writable: false,
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
if (options.queue) {
|
|
215
|
+
Object.defineProperty(controller, "queue", {
|
|
216
|
+
value: options.queue,
|
|
217
|
+
writable: false,
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
if (options.upload) {
|
|
221
|
+
Object.defineProperty(controller, "upload", {
|
|
222
|
+
value: options.upload,
|
|
223
|
+
writable: false,
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
if (options.mail) {
|
|
227
|
+
Object.defineProperty(controller, "mail", {
|
|
228
|
+
value: options.mail,
|
|
229
|
+
writable: false,
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Call onRegister hook
|
|
234
|
+
options.onRegister?.(controller);
|
|
235
|
+
|
|
236
|
+
// Convert file path to URL path
|
|
237
|
+
const urlPath = filePathToUrl(file, prefix);
|
|
238
|
+
|
|
239
|
+
// Register routes for each Controller method
|
|
240
|
+
const isIndex = basename(file, ".ts") === "index";
|
|
241
|
+
|
|
242
|
+
for (const method of ["index", "show", "create", "update", "destroy"]) {
|
|
243
|
+
if (typeof (controller as any)[method] !== "function") continue;
|
|
244
|
+
const verb = METHOD_MAP[method];
|
|
245
|
+
const handler = (controller as any)[method].bind(controller);
|
|
246
|
+
const methodPath = ID_METHODS.has(method)
|
|
247
|
+
? isIndex
|
|
248
|
+
? `${prefix}/:id`
|
|
249
|
+
: `${urlPath}/:id`
|
|
250
|
+
: isIndex
|
|
251
|
+
? prefix
|
|
252
|
+
: urlPath;
|
|
253
|
+
|
|
254
|
+
registerRoute(app, verb, methodPath, handler, controller, options);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// ─── Internal Helpers ──────────────────────────────────────────
|
|
260
|
+
|
|
261
|
+
let startTime = 0;
|
|
262
|
+
|
|
263
|
+
function formatBytes2(bytes: number): string {
|
|
264
|
+
if (bytes === 0) return "0 MB";
|
|
265
|
+
const mb = bytes / (1024 * 1024);
|
|
266
|
+
return `${mb.toFixed(1)} MB`;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** Inject debug toolbar into HTML if enabled. */
|
|
270
|
+
async function injectDebug(
|
|
271
|
+
html: string,
|
|
272
|
+
ctx: any,
|
|
273
|
+
controller: any,
|
|
274
|
+
status: number,
|
|
275
|
+
): Promise<string> {
|
|
276
|
+
const dbgParam = new URL(
|
|
277
|
+
ctx.request?.url ?? "http://localhost",
|
|
278
|
+
).searchParams.get("debug");
|
|
279
|
+
const isDebug = dbgParam === "1" || process.env.DEBUG === "true";
|
|
280
|
+
if (!isDebug || !html.includes("</body>")) return html;
|
|
281
|
+
|
|
282
|
+
try {
|
|
283
|
+
const debugData = getStore(ctx);
|
|
284
|
+
debugData.status = status;
|
|
285
|
+
debugData.duration =
|
|
286
|
+
Math.round((performance.now() - startTime) * 100) / 100;
|
|
287
|
+
debugData.memory = formatBytes2((process as any).memoryUsage?.()?.rss ?? 0);
|
|
288
|
+
debugData.timestamp = new Date().toLocaleString();
|
|
289
|
+
if (controller?.session) debugData.session = controller.session.all();
|
|
290
|
+
if (ctx.request?.headers) {
|
|
291
|
+
const h: Record<string, string> = {};
|
|
292
|
+
for (const [k, v] of ctx.request.headers.entries()) h[k] = v;
|
|
293
|
+
debugData.headers = h;
|
|
294
|
+
}
|
|
295
|
+
const toolbar = await generateToolbar(debugData);
|
|
296
|
+
if (toolbar && toolbar.length > 50) {
|
|
297
|
+
const bodyIdx = html.lastIndexOf("</body>");
|
|
298
|
+
if (bodyIdx > 0) {
|
|
299
|
+
return html.slice(0, bodyIdx) + toolbar + "\n" + html.slice(bodyIdx);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
return html;
|
|
303
|
+
} catch (e) {
|
|
304
|
+
console.error("[debug] toolbar error:", e, (e as Error).stack);
|
|
305
|
+
}
|
|
306
|
+
return html;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function scanDir(dir: string, ext: string, baseDir = ""): string[] {
|
|
310
|
+
const files: string[] = [];
|
|
311
|
+
const entries = readdirSync(dir, { withFileTypes: true });
|
|
312
|
+
for (const entry of entries) {
|
|
313
|
+
const relPath = baseDir ? `${baseDir}/${entry.name}` : entry.name;
|
|
314
|
+
if (entry.isDirectory()) {
|
|
315
|
+
files.push(...scanDir(join(dir, entry.name), ext, relPath));
|
|
316
|
+
} else if (
|
|
317
|
+
entry.isFile() &&
|
|
318
|
+
entry.name.endsWith(ext) &&
|
|
319
|
+
!entry.name.startsWith("_")
|
|
320
|
+
) {
|
|
321
|
+
files.push(relPath);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
return files.sort();
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function filePathToUrl(file: string, prefix: string): string {
|
|
328
|
+
const url = file
|
|
329
|
+
.replace(/\.ts$/, "")
|
|
330
|
+
.replace(/\[\.\.\.\]/g, "*")
|
|
331
|
+
.replace(/\[([^\]]+)\]/g, ":$1")
|
|
332
|
+
.replace(/\/index$/, "")
|
|
333
|
+
.replace(/\\/g, "/");
|
|
334
|
+
|
|
335
|
+
return `${prefix}${url ? `/${url}` : ""}`;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function findController(
|
|
339
|
+
mod: Record<string, any>,
|
|
340
|
+
): (new () => Controller) | null {
|
|
341
|
+
for (const key of Object.keys(mod)) {
|
|
342
|
+
const val = mod[key];
|
|
343
|
+
if (
|
|
344
|
+
typeof val === "function" &&
|
|
345
|
+
val.prototype &&
|
|
346
|
+
val.prototype.constructor
|
|
347
|
+
) {
|
|
348
|
+
// Check if it extends Controller
|
|
349
|
+
let proto = val.prototype;
|
|
350
|
+
while (proto) {
|
|
351
|
+
if (proto.constructor.name === "Controller") return val;
|
|
352
|
+
proto = Object.getPrototypeOf(proto);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
return null;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function registerRoute(
|
|
360
|
+
app: Elysia,
|
|
361
|
+
verb: string,
|
|
362
|
+
path: string,
|
|
363
|
+
handler: (...args: any[]) => any,
|
|
364
|
+
controller: Controller,
|
|
365
|
+
options: FileRouterOptions,
|
|
366
|
+
): void {
|
|
367
|
+
const lowerVerb = verb.toLowerCase() as
|
|
368
|
+
| "get"
|
|
369
|
+
| "post"
|
|
370
|
+
| "put"
|
|
371
|
+
| "delete"
|
|
372
|
+
| "patch";
|
|
373
|
+
|
|
374
|
+
// Determine if this route needs an ID param based on the path
|
|
375
|
+
const needsId = path.endsWith("/:id");
|
|
376
|
+
|
|
377
|
+
// Wrap handler: inject ctx + session + auth into controller
|
|
378
|
+
const wrappedHandler = async (_ctx: any) => {
|
|
379
|
+
setRequestContext(_ctx);
|
|
380
|
+
startTime = performance.now();
|
|
381
|
+
let session: Session | null = null;
|
|
382
|
+
const cookieName = "nexus_session";
|
|
383
|
+
|
|
384
|
+
if (controller) {
|
|
385
|
+
(controller as any).ctx = _ctx;
|
|
386
|
+
|
|
387
|
+
// Set upload body for auto-detection
|
|
388
|
+
if ((controller as any).upload) {
|
|
389
|
+
(controller as any).upload.body = _ctx.body;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// Create session from cookie
|
|
393
|
+
session = new Session();
|
|
394
|
+
const cookieHeader = _ctx.request?.headers?.get("cookie") ?? "";
|
|
395
|
+
const match = cookieHeader.match(new RegExp(cookieName + "=([^;]+)"));
|
|
396
|
+
session.load(match?.[1]);
|
|
397
|
+
(controller as any).session = session;
|
|
398
|
+
|
|
399
|
+
// Create auth facade
|
|
400
|
+
(controller as any).auth = {
|
|
401
|
+
user: () => session?.get("user"),
|
|
402
|
+
login: (user: any) => {
|
|
403
|
+
session?.set("user", user);
|
|
404
|
+
session?.regenerate();
|
|
405
|
+
},
|
|
406
|
+
logout: () => {
|
|
407
|
+
session?.delete("user");
|
|
408
|
+
session?.clear();
|
|
409
|
+
},
|
|
410
|
+
check: () => !!session?.get("user"),
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
try {
|
|
415
|
+
// Handle _method override for HTML forms (PUT/DELETE via POST)
|
|
416
|
+
const body = _ctx.body ?? {};
|
|
417
|
+
const overrideMethod = body?._method?.toUpperCase();
|
|
418
|
+
if (
|
|
419
|
+
overrideMethod &&
|
|
420
|
+
["PUT", "DELETE", "PATCH"].includes(overrideMethod)
|
|
421
|
+
) {
|
|
422
|
+
_ctx.__method = overrideMethod;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// Call _before() lifecycle hook (can short-circuit with a Response)
|
|
426
|
+
if (controller) {
|
|
427
|
+
const beforeResult = (controller as any)._before?.();
|
|
428
|
+
if (beforeResult instanceof Response) {
|
|
429
|
+
return beforeResult;
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// Call handler — pass context for server routes, ID for Controller routes
|
|
434
|
+
const id = _ctx.params?.id ? Number(_ctx.params.id) : undefined;
|
|
435
|
+
let result;
|
|
436
|
+
if (controller) {
|
|
437
|
+
result = needsId ? await handler(id) : await handler();
|
|
438
|
+
} else {
|
|
439
|
+
result = await handler(_ctx);
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// Save session cookie AFTER handler runs
|
|
443
|
+
if (session) {
|
|
444
|
+
const serialized = session.serialize();
|
|
445
|
+
if (serialized) {
|
|
446
|
+
if (!_ctx.set.headers) _ctx.set.headers = {};
|
|
447
|
+
_ctx.set.headers["Set-Cookie"] =
|
|
448
|
+
`${cookieName}=${serialized.value}; Max-Age=${serialized.maxAge}; Path=/; HttpOnly; SameSite=Lax`;
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
if (result instanceof Response) return result;
|
|
453
|
+
|
|
454
|
+
// Handle ViewResponse — SSR render (React or Rendu HTML)
|
|
455
|
+
if (result instanceof ViewResponse) {
|
|
456
|
+
const viewBase = options.viewsDir
|
|
457
|
+
? join(process.cwd(), options.viewsDir)
|
|
458
|
+
: undefined;
|
|
459
|
+
const htmlOrRes = await renderView(
|
|
460
|
+
result.name,
|
|
461
|
+
result.props,
|
|
462
|
+
result.options,
|
|
463
|
+
viewBase,
|
|
464
|
+
);
|
|
465
|
+
if (htmlOrRes instanceof Response) {
|
|
466
|
+
let text = await htmlOrRes.text();
|
|
467
|
+
text = await injectDebug(text, _ctx, controller, htmlOrRes.status);
|
|
468
|
+
return new Response(text, {
|
|
469
|
+
status: htmlOrRes.status,
|
|
470
|
+
headers: { "content-type": "text/html; charset=utf-8" },
|
|
471
|
+
});
|
|
472
|
+
}
|
|
473
|
+
const html = await injectDebug(htmlOrRes, _ctx, controller, 200);
|
|
474
|
+
return new Response(html, {
|
|
475
|
+
headers: { "content-type": "text/html; charset=utf-8" },
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// Handle PageResponse — Inertia-style page
|
|
480
|
+
if (result instanceof PageResponse) {
|
|
481
|
+
const isInertia =
|
|
482
|
+
_ctx.headers?.["x-inertia"] === "true" ||
|
|
483
|
+
_ctx.request?.headers?.get("X-Inertia") === "true";
|
|
484
|
+
|
|
485
|
+
if (isInertia) {
|
|
486
|
+
return new Response(result.toInertiaJson(controller?._sharedProps), {
|
|
487
|
+
status: result.options.status ?? 200,
|
|
488
|
+
headers: {
|
|
489
|
+
"content-type": "application/json",
|
|
490
|
+
"x-inertia": "true",
|
|
491
|
+
},
|
|
492
|
+
});
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// First load: full HTML shell
|
|
496
|
+
const url = _ctx.request?.url ?? "/";
|
|
497
|
+
let html = result.toHtml(controller?._sharedProps, url);
|
|
498
|
+
|
|
499
|
+
// Client script inject
|
|
500
|
+
if (html.includes("</body>")) {
|
|
501
|
+
const publicPath = join(process.cwd(), "public", "app.js");
|
|
502
|
+
if (existsSync(publicPath)) {
|
|
503
|
+
html = html.replace(
|
|
504
|
+
"</body>",
|
|
505
|
+
'<script src="/public/app.js"></script>\n</body>',
|
|
506
|
+
);
|
|
507
|
+
}
|
|
508
|
+
html = await injectDebug(
|
|
509
|
+
html,
|
|
510
|
+
_ctx,
|
|
511
|
+
controller,
|
|
512
|
+
result.options.status ?? 200,
|
|
513
|
+
);
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
return new Response(html, {
|
|
517
|
+
status: result.options.status ?? 200,
|
|
518
|
+
headers: { "content-type": "text/html; charset=utf-8" },
|
|
519
|
+
});
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
if (result !== undefined && result !== null) {
|
|
523
|
+
const status = (result as any)._status ?? 200;
|
|
524
|
+
return new Response(JSON.stringify(result), {
|
|
525
|
+
status,
|
|
526
|
+
headers: { "content-type": "application/json" },
|
|
527
|
+
});
|
|
528
|
+
}
|
|
529
|
+
return new Response(null, { status: 204 });
|
|
530
|
+
} catch (err) {
|
|
531
|
+
console.error(`[ERROR] ${verb} ${path}:`, (err as Error).message);
|
|
532
|
+
return new Response(JSON.stringify({ error: (err as Error).message }), {
|
|
533
|
+
status: 500,
|
|
534
|
+
headers: { "content-type": "application/json" },
|
|
535
|
+
});
|
|
536
|
+
}
|
|
537
|
+
};
|
|
538
|
+
|
|
539
|
+
// Elysia v2.0: .get(path, handler) — schema precedes handler in v2
|
|
540
|
+
(app as any)[lowerVerb](path, wrappedHandler);
|
|
541
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server Router — Void-style file-based routing with method exports.
|
|
3
|
+
*
|
|
4
|
+
* Scans `routes/` directory. Each file exports
|
|
5
|
+
* HTTP method constants (`GET`, `POST`, etc.) created via `defineHandler`.
|
|
6
|
+
*
|
|
7
|
+
* Directory structure:
|
|
8
|
+
* ```
|
|
9
|
+
* routes/
|
|
10
|
+
* ├── api/
|
|
11
|
+
* │ ├── hello.ts → GET|POST /api/hello
|
|
12
|
+
* │ └── users/
|
|
13
|
+
* │ ├── index.ts → GET|POST /api/users
|
|
14
|
+
* │ └── [id].ts → GET|PUT|DELETE /api/users/:id
|
|
15
|
+
* └── webhooks/
|
|
16
|
+
* └── stripe.ts → POST /webhooks/stripe
|
|
17
|
+
* ```
|
|
18
|
+
*
|
|
19
|
+
* @example
|
|
20
|
+
* ```ts
|
|
21
|
+
* // routes/api/users.ts
|
|
22
|
+
* import { defineHandler } from 'nexusts'
|
|
23
|
+
*
|
|
24
|
+
* export const GET = defineHandler(async (c) => {
|
|
25
|
+
* return db.select().from(users)
|
|
26
|
+
* })
|
|
27
|
+
*
|
|
28
|
+
* export const POST = defineHandler.withValidator({
|
|
29
|
+
* body: insertUserSchema
|
|
30
|
+
* })(async (c, { body }) => {
|
|
31
|
+
* return db.insert(users).values(body).returning()
|
|
32
|
+
* })
|
|
33
|
+
* ```
|
|
34
|
+
*/
|
|
35
|
+
import { readdirSync, statSync, existsSync } from 'node:fs'
|
|
36
|
+
import { join, basename } from 'node:path'
|
|
37
|
+
import { Elysia } from 'elysia'
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Register all routes from the `routes/` directory.
|
|
41
|
+
* Each file exports `GET`, `POST`, `PUT`, `DELETE`, `PATCH` named constants.
|
|
42
|
+
*/
|
|
43
|
+
export async function registerServerRoutes(app: Elysia, dir: string = 'routes', prefix: string = ''): Promise<void> {
|
|
44
|
+
if (!existsSync(dir)) return
|
|
45
|
+
|
|
46
|
+
const files = scanRouteFiles(dir)
|
|
47
|
+
|
|
48
|
+
for (const file of files) {
|
|
49
|
+
const fullPath = join(process.cwd(), dir, file)
|
|
50
|
+
let mod
|
|
51
|
+
try {
|
|
52
|
+
mod = await import(fullPath)
|
|
53
|
+
} catch (e: any) {
|
|
54
|
+
console.error('[server-router] Error importing', file, ':', e.message)
|
|
55
|
+
continue
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Build URL path from file path
|
|
59
|
+
const urlPath = routeFilePathToUrl(file, prefix)
|
|
60
|
+
|
|
61
|
+
// Register each exported HTTP method
|
|
62
|
+
const methods = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'] as const
|
|
63
|
+
for (const method of methods) {
|
|
64
|
+
const handler = mod[method]
|
|
65
|
+
if (typeof handler !== 'function') continue
|
|
66
|
+
|
|
67
|
+
const lower = method.toLowerCase() as 'get' | 'post' | 'put' | 'delete' | 'patch'
|
|
68
|
+
;(app as any)[lower](urlPath, async (ctx: any) => {
|
|
69
|
+
return handler(ctx)
|
|
70
|
+
})
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Scan for route files, excluding _prefix and .server.ts. */
|
|
76
|
+
function scanRouteFiles(baseDir: string, relativeDir = ''): string[] {
|
|
77
|
+
const dir = join(baseDir, relativeDir)
|
|
78
|
+
const files: string[] = []
|
|
79
|
+
const entries = readdirSync(dir, { withFileTypes: true })
|
|
80
|
+
|
|
81
|
+
for (const entry of entries) {
|
|
82
|
+
if (entry.name.startsWith('_') || entry.name.startsWith('.')) continue
|
|
83
|
+
|
|
84
|
+
if (entry.isDirectory()) {
|
|
85
|
+
files.push(...scanRouteFiles(baseDir, relativeDir ? `${relativeDir}/${entry.name}` : entry.name))
|
|
86
|
+
} else if (entry.isFile() && entry.name.endsWith('.ts') && !entry.name.endsWith('.server.ts') && !entry.name.endsWith('.test.ts')) {
|
|
87
|
+
files.push(relativeDir ? `${relativeDir}/${entry.name}` : entry.name)
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return files.sort()
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Convert file path to URL path, matching Void conventions. */
|
|
95
|
+
function routeFilePathToUrl(file: string, prefix: string): string {
|
|
96
|
+
let url = file
|
|
97
|
+
.replace(/\.ts$/, '')
|
|
98
|
+
.replace(/\/index$/, '')
|
|
99
|
+
.replace(/\[\.\.\.\]/g, '*')
|
|
100
|
+
.replace(/\[(\w+)\]/g, ':$1')
|
|
101
|
+
|
|
102
|
+
return `${prefix}/${url}`
|
|
103
|
+
}
|