@nexa-stack/framework 1.0.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 (81) hide show
  1. package/.env.example +46 -0
  2. package/LICENSE +21 -0
  3. package/README.md +72 -0
  4. package/bin/nexa.mjs +41 -0
  5. package/bin/nexa.ts +334 -0
  6. package/docs/AI.md +69 -0
  7. package/docs/ARCHITECTURE.md +74 -0
  8. package/docs/EXAMPLES.md +114 -0
  9. package/docs/FRAMEWORK.md +226 -0
  10. package/docs/LANGUAGE.md +39 -0
  11. package/docs/README.md +7 -0
  12. package/docs/READY.md +51 -0
  13. package/docs/REFERENCE.md +255 -0
  14. package/docs/START.md +98 -0
  15. package/docs/advanced.md +97 -0
  16. package/docs/authentication.md +54 -0
  17. package/docs/cli.md +15 -0
  18. package/docs/compare.md +51 -0
  19. package/docs/configuration.md +65 -0
  20. package/docs/database.md +396 -0
  21. package/docs/installation.md +57 -0
  22. package/docs/localization.md +47 -0
  23. package/docs/resources.md +75 -0
  24. package/docs/routing.md +59 -0
  25. package/docs/seeding.md +33 -0
  26. package/docs/services.md +146 -0
  27. package/package.json +77 -0
  28. package/packages/auth/src/auth.test.ts +23 -0
  29. package/packages/auth/src/auth.ts +287 -0
  30. package/packages/auth/src/index.ts +17 -0
  31. package/packages/cache/src/index.ts +203 -0
  32. package/packages/client/src/index.ts +49 -0
  33. package/packages/core/src/app.ts +97 -0
  34. package/packages/core/src/config.ts +55 -0
  35. package/packages/core/src/dev.ts +104 -0
  36. package/packages/core/src/fields.test.ts +81 -0
  37. package/packages/core/src/fields.ts +309 -0
  38. package/packages/core/src/index.ts +60 -0
  39. package/packages/core/src/lang.ts +79 -0
  40. package/packages/core/src/loader.ts +42 -0
  41. package/packages/core/src/migrate.ts +91 -0
  42. package/packages/core/src/policy.ts +36 -0
  43. package/packages/core/src/registry.ts +17 -0
  44. package/packages/core/src/reload.ts +92 -0
  45. package/packages/core/src/resource.test.ts +32 -0
  46. package/packages/core/src/resource.ts +87 -0
  47. package/packages/core/src/routes.ts +22 -0
  48. package/packages/core/src/runtime.ts +11 -0
  49. package/packages/database/src/builder.ts +266 -0
  50. package/packages/database/src/database.ts +252 -0
  51. package/packages/database/src/dialect.ts +186 -0
  52. package/packages/database/src/index.ts +6 -0
  53. package/packages/database/src/mysql.ts +114 -0
  54. package/packages/database/src/postgres.ts +117 -0
  55. package/packages/database/src/query.ts +115 -0
  56. package/packages/database/src/sqlite.ts +216 -0
  57. package/packages/database/src/types.ts +104 -0
  58. package/packages/events/src/index.ts +17 -0
  59. package/packages/export/src/index.ts +36 -0
  60. package/packages/log/src/index.ts +38 -0
  61. package/packages/mail/src/index.ts +130 -0
  62. package/packages/notifications/src/index.ts +84 -0
  63. package/packages/plugins/src/index.ts +39 -0
  64. package/packages/queue/src/index.ts +185 -0
  65. package/packages/queue/src/jobs.ts +9 -0
  66. package/packages/schedule/src/index.ts +64 -0
  67. package/packages/server/src/index.ts +1 -0
  68. package/packages/server/src/middleware.ts +143 -0
  69. package/packages/server/src/query.ts +40 -0
  70. package/packages/server/src/router.ts +813 -0
  71. package/packages/sms/src/index.ts +33 -0
  72. package/packages/storage/src/upload.ts +36 -0
  73. package/packages/testing/src/index.ts +67 -0
  74. package/packages/validation/src/index.ts +1 -0
  75. package/packages/validation/src/validate.test.ts +35 -0
  76. package/packages/validation/src/validate.ts +112 -0
  77. package/public/admin.html +369 -0
  78. package/public/compare.html +66 -0
  79. package/public/dev-bar.js +213 -0
  80. package/public/docs.html +315 -0
  81. package/public/index.html +66 -0
@@ -0,0 +1,813 @@
1
+ import { parseListQuery } from "./query.js";
2
+ import { t, getUi } from "../../core/src/lang.js";
3
+ import type { ResourceDefinition } from "../../core/src/resource.js";
4
+ import type { Database } from "../../database/src/database.js";
5
+ import { validate } from "../../validation/src/validate.js";
6
+ import {
7
+ hasRole,
8
+ login,
9
+ register,
10
+ userFromRequest,
11
+ type AuthUser,
12
+ } from "../../auth/src/auth.js";
13
+ import { checkPolicy, type PolicyAction } from "../../core/src/policy.js";
14
+ import { saveUpload, storagePath, isImageExt } from "../../storage/src/upload.js";
15
+ import { emit } from "../../events/src/index.js";
16
+ import {
17
+ unreadNotifications,
18
+ markNotificationRead,
19
+ markAllRead,
20
+ notify,
21
+ } from "../../notifications/src/index.js";
22
+ import { exportResponse } from "../../export/src/index.js";
23
+ import { runMiddleware, checkAuthRateLimit } from "./middleware.js";
24
+ import { hook } from "../../plugins/src/index.js";
25
+ import { cache } from "../../cache/src/index.js";
26
+ import { config, isAuthRegisterEnabled } from "../../core/src/config.js";
27
+ import { defaultLocale } from "../../core/src/lang.js";
28
+ import { getResources } from "../../core/src/registry.js";
29
+ import {
30
+ isDevMode,
31
+ getDevStatus,
32
+ groupRouteKey,
33
+ recordDevRequest,
34
+ } from "../../core/src/dev.js";
35
+ import { extname, join } from "path";
36
+ import { existsSync } from "fs";
37
+ import { readFile } from "fs/promises";
38
+ import { createServer as createHttpServer, type Server as HttpServer } from "node:http";
39
+
40
+ type Handler = (req: Request, user: AuthUser | null) => Promise<Response> | Response;
41
+
42
+ function json(data: unknown, status = 200): Response {
43
+ return Response.json(data, { status });
44
+ }
45
+
46
+ function error(message: string, status = 400, errors?: unknown): Response {
47
+ return json({ error: message, errors }, status);
48
+ }
49
+
50
+ function listCacheTtl(): number {
51
+ return Number(config("LIST_CACHE_TTL") || 0);
52
+ }
53
+
54
+ async function invalidateListCache(resourceName: string): Promise<void> {
55
+ if (listCacheTtl() <= 0) return;
56
+ await cache.forgetPrefix(`list:${resourceName}:`);
57
+ }
58
+
59
+ export class Router {
60
+ private routes: Map<string, Handler> = new Map();
61
+ private resources: ResourceDefinition[] = [];
62
+ private resourceRouteKeys = new Set<string>();
63
+ private customRouteKeys = new Set<string>();
64
+
65
+ constructor(
66
+ private db: Database,
67
+ private staticDir?: string,
68
+ private fallbackStaticDir?: string
69
+ ) {}
70
+
71
+ private authorize(
72
+ resource: ResourceDefinition,
73
+ action: PolicyAction,
74
+ user: AuthUser | null,
75
+ item?: Record<string, unknown>
76
+ ): Response | null {
77
+ if (resource.authRole && !hasRole(user, resource.authRole)) {
78
+ return error("Unauthorized", 401);
79
+ }
80
+ if (!checkPolicy(resource.policy, action, user, item)) {
81
+ return error("Forbidden", 403);
82
+ }
83
+ return null;
84
+ }
85
+
86
+ private setResourceRoute(key: string, handler: Handler): void {
87
+ this.resourceRouteKeys.add(key);
88
+ this.routes.set(key, handler);
89
+ }
90
+
91
+ /** Drop resource API routes and re-register from the current registry. */
92
+ reloadResources(): void {
93
+ for (const key of this.resourceRouteKeys) this.routes.delete(key);
94
+ this.resourceRouteKeys.clear();
95
+ this.resources = [];
96
+ for (const def of getResources()) this.registerResource(def);
97
+ }
98
+
99
+ registerResource(resource: ResourceDefinition): void {
100
+ if (!resource.hasCrud || !resource.hasApi) return;
101
+ this.resources.push(resource);
102
+
103
+ const base = `/api/${resource.name.toLowerCase()}`;
104
+
105
+ this.setResourceRoute(`GET ${base}`, async (req, user) => {
106
+ const denied = this.authorize(resource, "view", user);
107
+ if (denied) return denied;
108
+
109
+ const url = new URL(req.url);
110
+ const ttl = listCacheTtl();
111
+ const cacheKey = `list:${resource.name}:${url.search}`;
112
+ if (ttl > 0) {
113
+ const hit = await cache.get(cacheKey);
114
+ if (hit) return json(hit);
115
+ }
116
+
117
+ const opts = parseListQuery(url, resource);
118
+ const result = await this.db.paginate(
119
+ resource.table,
120
+ resource,
121
+ opts,
122
+ `${url.origin}${base}`
123
+ );
124
+ result.data = await this.enrichMany(resource, result.data);
125
+ if (ttl > 0) await cache.set(cacheKey, result, ttl);
126
+ return json(result);
127
+ });
128
+
129
+ this.setResourceRoute(`GET ${base}/export`, async (req, user) => {
130
+ const denied = this.authorize(resource, "view", user);
131
+ if (denied) return denied;
132
+ const url = new URL(req.url);
133
+ const format = url.searchParams.get("format") === "json" ? "json" : "csv";
134
+ const opts = parseListQuery(url, resource);
135
+ opts.limit = 10000;
136
+ opts.offset = 0;
137
+ const rows = await this.db.findAll(resource.table, opts);
138
+ return exportResponse(rows, format, resource.name);
139
+ });
140
+
141
+ this.setResourceRoute(`GET ${base}/:id`, async (req, user) => {
142
+ const denied = this.authorize(resource, "view", user);
143
+ if (denied) return denied;
144
+
145
+ const id = extractId(req.url);
146
+ if (id === null) return error("Invalid id", 400);
147
+
148
+ const item = await this.db.findById(resource.table, id);
149
+ if (!item) return error("Not found", 404);
150
+ if (resource.softDelete && item.deleted_at) return error("Not found", 404);
151
+
152
+ return json({ data: await this.enrich(resource, item) });
153
+ });
154
+
155
+ this.setResourceRoute(`POST ${base}`, async (req, user) => {
156
+ const denied = this.authorize(resource, "create", user);
157
+ if (denied) return denied;
158
+
159
+ let body: Record<string, unknown>;
160
+ try {
161
+ body = (await req.json()) as Record<string, unknown>;
162
+ } catch {
163
+ return error("Invalid JSON body", 400);
164
+ }
165
+
166
+ const result = validate(resource.fields, body);
167
+ if (!result.valid) return error("Validation failed", 422, result.errors);
168
+
169
+ const item = await this.db.insert(resource.table, result.data);
170
+ await invalidateListCache(resource.name);
171
+ await emit(`${resource.name}.created`, { id: item.id, data: item });
172
+ await hook("resource.created", { resource: resource.name, data: item });
173
+ return json({ data: await this.enrich(resource, item) }, 201);
174
+ });
175
+
176
+ this.setResourceRoute(`PUT ${base}/:id`, async (req, user) => {
177
+ const id = extractId(req.url);
178
+ if (id === null) return error("Invalid id", 400);
179
+
180
+ const existing = await this.db.findById(resource.table, id);
181
+ if (!existing) return error("Not found", 404);
182
+
183
+ const denied = this.authorize(resource, "update", user, existing);
184
+ if (denied) return denied;
185
+
186
+ let body: Record<string, unknown>;
187
+ try {
188
+ body = (await req.json()) as Record<string, unknown>;
189
+ } catch {
190
+ return error("Invalid JSON body", 400);
191
+ }
192
+
193
+ const result = validate(resource.fields, body, true);
194
+ if (!result.valid) return error("Validation failed", 422, result.errors);
195
+
196
+ const item = await this.db.update(resource.table, id, result.data);
197
+ await invalidateListCache(resource.name);
198
+ await emit(`${resource.name}.updated`, { id, data: item });
199
+ return json({ data: await this.enrich(resource, item) });
200
+ });
201
+
202
+ this.setResourceRoute(`DELETE ${base}/:id`, async (req, user) => {
203
+ const id = extractId(req.url);
204
+ if (id === null) return error("Invalid id", 400);
205
+
206
+ const existing = await this.db.findById(resource.table, id);
207
+ if (!existing) return error("Not found", 404);
208
+
209
+ const denied = this.authorize(resource, "delete", user, existing);
210
+ if (denied) return denied;
211
+
212
+ const force = new URL(req.url).searchParams.get("force") === "1";
213
+ let deleted: boolean;
214
+ if (resource.softDelete && !force) {
215
+ deleted = await this.db.softDelete(resource.table, id);
216
+ } else {
217
+ deleted = await this.db.delete(resource.table, id);
218
+ }
219
+ if (!deleted) return error("Not found", 404);
220
+
221
+ await invalidateListCache(resource.name);
222
+ await emit(`${resource.name}.deleted`, { id, data: existing, soft: resource.softDelete && !force });
223
+ return json({ data: { id, deleted: true, soft: resource.softDelete && !force } });
224
+ });
225
+
226
+ if (resource.softDelete) {
227
+ this.setResourceRoute(`POST ${base}/:id/restore`, async (req, user) => {
228
+ const denied = this.authorize(resource, "update", user);
229
+ if (denied) return denied;
230
+ const parts = new URL(req.url).pathname.split("/");
231
+ const id = Number(parts[parts.length - 2]);
232
+ if (!Number.isInteger(id) || id <= 0) return error("Invalid id", 400);
233
+ const ok = await this.db.restore(resource.table, id);
234
+ if (!ok) return error("Not found", 404);
235
+ await invalidateListCache(resource.name);
236
+ const item = await this.db.findById(resource.table, id);
237
+ return json({ data: await this.enrich(resource, item!) });
238
+ });
239
+ }
240
+ }
241
+
242
+ registerAuthRoutes(): void {
243
+ this.routes.set("POST /api/auth/register", async (req) => {
244
+ const limited = checkAuthRateLimit(req);
245
+ if (limited) return limited;
246
+ if (!isAuthRegisterEnabled()) {
247
+ return error("Registration is disabled", 403);
248
+ }
249
+ let body: Record<string, unknown>;
250
+ try {
251
+ body = (await req.json()) as Record<string, unknown>;
252
+ } catch {
253
+ return error("Invalid JSON body", 400);
254
+ }
255
+
256
+ const email = String(body.email ?? "");
257
+ const password = String(body.password ?? "");
258
+ if (!email || !password) return error("email and password required", 422);
259
+
260
+ try {
261
+ const user = await register(email, password);
262
+ return json({ data: user }, 201);
263
+ } catch {
264
+ return error("Email already exists", 409);
265
+ }
266
+ });
267
+
268
+ this.routes.set("POST /api/auth/login", async (req) => {
269
+ const limited = checkAuthRateLimit(req);
270
+ if (limited) return limited;
271
+ let body: Record<string, unknown>;
272
+ try {
273
+ body = (await req.json()) as Record<string, unknown>;
274
+ } catch {
275
+ return error("Invalid JSON body", 400);
276
+ }
277
+
278
+ const result = await login(String(body.email ?? ""), String(body.password ?? ""));
279
+ if (!result) return error("Invalid credentials", 401);
280
+
281
+ return json({ data: result });
282
+ });
283
+
284
+ this.routes.set("POST /api/auth/forgot-password", async (req) => {
285
+ const limited = checkAuthRateLimit(req);
286
+ if (limited) return limited;
287
+ let body: Record<string, unknown>;
288
+ try {
289
+ body = (await req.json()) as Record<string, unknown>;
290
+ } catch {
291
+ return error("Invalid JSON body", 400);
292
+ }
293
+ const email = String(body.email ?? "");
294
+ if (!email) return error("email required", 422);
295
+ const { forgotPassword } = await import("../../auth/src/auth.js");
296
+ await forgotPassword(email);
297
+ return json({ data: { sent: true } });
298
+ });
299
+
300
+ this.routes.set("POST /api/auth/reset-password", async (req) => {
301
+ const limited = checkAuthRateLimit(req);
302
+ if (limited) return limited;
303
+ let body: Record<string, unknown>;
304
+ try {
305
+ body = (await req.json()) as Record<string, unknown>;
306
+ } catch {
307
+ return error("Invalid JSON body", 400);
308
+ }
309
+ const email = String(body.email ?? "");
310
+ const token = String(body.token ?? "");
311
+ const password = String(body.password ?? "");
312
+ if (!email || !token || !password) {
313
+ return error("email, token and password required", 422);
314
+ }
315
+ const { resetPassword } = await import("../../auth/src/auth.js");
316
+ const ok = await resetPassword(email, token, password);
317
+ if (!ok) return error("Invalid or expired token", 422);
318
+ return json({ data: { reset: true } });
319
+ });
320
+
321
+ this.routes.set("POST /api/auth/verify-email", async (req) => {
322
+ let body: Record<string, unknown> = {};
323
+ try {
324
+ body = (await req.json()) as Record<string, unknown>;
325
+ } catch {
326
+ /* allow empty — also accept query */
327
+ }
328
+ const url = new URL(req.url);
329
+ const email = String(body.email ?? url.searchParams.get("email") ?? "");
330
+ const token = String(body.token ?? url.searchParams.get("token") ?? "");
331
+ if (!email || !token) return error("email and token required", 422);
332
+ const { verifyEmail } = await import("../../auth/src/auth.js");
333
+ const ok = await verifyEmail(email, token);
334
+ if (!ok) return error("Invalid or expired token", 422);
335
+ return json({ data: { verified: true } });
336
+ });
337
+
338
+ this.routes.set("GET /api/auth/verify-email", async (req) => {
339
+ const url = new URL(req.url);
340
+ const email = String(url.searchParams.get("email") ?? "");
341
+ const token = String(url.searchParams.get("token") ?? "");
342
+ if (!email || !token) return error("email and token required", 422);
343
+ const { verifyEmail } = await import("../../auth/src/auth.js");
344
+ const ok = await verifyEmail(email, token);
345
+ if (!ok) return error("Invalid or expired token", 422);
346
+ return json({ data: { verified: true } });
347
+ });
348
+
349
+ this.routes.set("POST /api/auth/resend-verification", async (req) => {
350
+ let body: Record<string, unknown>;
351
+ try {
352
+ body = (await req.json()) as Record<string, unknown>;
353
+ } catch {
354
+ return error("Invalid JSON body", 400);
355
+ }
356
+ const email = String(body.email ?? "");
357
+ if (!email) return error("email required", 422);
358
+ const { resendVerification } = await import("../../auth/src/auth.js");
359
+ await resendVerification(email);
360
+ return json({ data: { sent: true } });
361
+ });
362
+
363
+ this.routes.set("POST /api/upload", async (req, user) => {
364
+ if (!user) return error("Unauthorized", 401);
365
+
366
+ let form: FormData;
367
+ try {
368
+ form = await req.formData();
369
+ } catch {
370
+ return error("Invalid form data", 400);
371
+ }
372
+
373
+ const file = form.get("file");
374
+ if (!(file instanceof File)) return error("file is required", 422);
375
+
376
+ const type = String(form.get("type") ?? "file");
377
+ const ext = extname(file.name);
378
+ if (type === "image" && !isImageExt(ext)) {
379
+ return error("Only image files allowed", 422);
380
+ }
381
+
382
+ try {
383
+ const url = await saveUpload(file);
384
+ return json({ data: { url, name: file.name, size: file.size } }, 201);
385
+ } catch (e) {
386
+ return error(e instanceof Error ? e.message : "Upload failed", 422);
387
+ }
388
+ });
389
+ }
390
+
391
+ registerNotificationRoutes(): void {
392
+ this.routes.set("GET /api/notifications", async (_req, user) => {
393
+ if (!user) return error("Unauthorized", 401);
394
+ const data = await unreadNotifications(user.id);
395
+ return json({ data });
396
+ });
397
+
398
+ this.routes.set("POST /api/notifications", async (req, user) => {
399
+ if (!user) return error("Unauthorized", 401);
400
+ let body: Record<string, unknown>;
401
+ try {
402
+ body = (await req.json()) as Record<string, unknown>;
403
+ } catch {
404
+ return error("Invalid JSON body", 400);
405
+ }
406
+
407
+ const title = String(body.title ?? "").trim();
408
+ if (!title) return error("title required", 422);
409
+
410
+ let targetId = user.id;
411
+ if (body.user_id != null && body.user_id !== "") {
412
+ if (user.role !== "admin") return error("Forbidden", 403);
413
+ targetId = Number(body.user_id);
414
+ if (!Number.isInteger(targetId) || targetId <= 0) return error("Invalid user_id", 422);
415
+ }
416
+
417
+ const viaRaw = body.via;
418
+ const via =
419
+ Array.isArray(viaRaw) && viaRaw.every((v) => v === "database" || v === "mail")
420
+ ? (viaRaw as ("database" | "mail")[])
421
+ : (["database"] as ("database" | "mail")[]);
422
+
423
+ await notify(
424
+ { id: targetId, email: body.email ? String(body.email) : user.email },
425
+ {
426
+ title,
427
+ body: body.body != null ? String(body.body) : "",
428
+ type: body.type != null ? String(body.type) : "app",
429
+ data:
430
+ body.data && typeof body.data === "object" && !Array.isArray(body.data)
431
+ ? (body.data as Record<string, unknown>)
432
+ : undefined,
433
+ via,
434
+ }
435
+ );
436
+
437
+ return json({ data: { sent: true, user_id: targetId } }, 201);
438
+ });
439
+
440
+ this.routes.set("POST /api/notifications/read-all", async (_req, user) => {
441
+ if (!user) return error("Unauthorized", 401);
442
+ await markAllRead(user.id);
443
+ return json({ data: { ok: true } });
444
+ });
445
+
446
+ this.routes.set("POST /api/notifications/:id/read", async (req, user) => {
447
+ if (!user) return error("Unauthorized", 401);
448
+ const parts = new URL(req.url).pathname.split("/");
449
+ const id = Number(parts[parts.length - 2]);
450
+ if (!Number.isInteger(id) || id <= 0) return error("Invalid id", 400);
451
+ const ok = await markNotificationRead(id, user.id);
452
+ if (!ok) return error("Not found", 404);
453
+ return json({ data: { id, read: true } });
454
+ });
455
+ }
456
+
457
+ /** Mount routes registered via `route()` from app code. */
458
+ registerCustomRoutes(handlers: { method: string; path: string; handler: Handler }[]): void {
459
+ for (const key of this.customRouteKeys) this.routes.delete(key);
460
+ this.customRouteKeys.clear();
461
+ for (const r of handlers) {
462
+ const key = `${r.method} ${r.path}`;
463
+ this.customRouteKeys.add(key);
464
+ this.routes.set(key, r.handler);
465
+ }
466
+ }
467
+
468
+ getRouteManifest(): ReturnType<typeof groupRouteKey>[] {
469
+ return [...this.routes.keys()]
470
+ .map((key) => groupRouteKey(key, this.resourceRouteKeys, this.customRouteKeys))
471
+ .sort((a, b) => a.path.localeCompare(b.path) || a.method.localeCompare(b.method));
472
+ }
473
+
474
+ private staticDirs(): string[] {
475
+ return [this.staticDir, this.fallbackStaticDir].filter(Boolean) as string[];
476
+ }
477
+
478
+ private async servePublicFile(path: string, contentType: string): Promise<Response | null> {
479
+ for (const dir of this.staticDirs()) {
480
+ const full = join(dir, path);
481
+ if (!existsSync(full)) continue;
482
+ const buf = await readFile(full);
483
+ return new Response(buf, { headers: { "Content-Type": contentType } });
484
+ }
485
+ return null;
486
+ }
487
+
488
+ private async serveHtmlPage(fullPath: string): Promise<Response> {
489
+ let html = await readFile(fullPath, "utf8");
490
+ if (isDevMode() && !html.includes("dev-bar.js")) {
491
+ html = html.replace(
492
+ "</body>",
493
+ '<script src="/dev-bar.js" defer></script>\n</body>'
494
+ );
495
+ }
496
+ return new Response(html, {
497
+ headers: { "Content-Type": "text/html; charset=utf-8" },
498
+ });
499
+ }
500
+
501
+ private serializeRow(
502
+ resource: ResourceDefinition,
503
+ item: Record<string, unknown>
504
+ ): Record<string, unknown> {
505
+ const row = { ...item };
506
+ for (const [name, field] of Object.entries(resource.fields)) {
507
+ if (field.type !== "boolean" || !(name in row)) continue;
508
+ const v = row[name];
509
+ if (v === null || v === undefined) {
510
+ row[name] = null;
511
+ continue;
512
+ }
513
+ row[name] = v === 1 || v === true || v === "1" || v === "true";
514
+ }
515
+ return row;
516
+ }
517
+
518
+ private async enrich(resource: ResourceDefinition, item: Record<string, unknown>) {
519
+ const [row] = await this.enrichMany(resource, [item]);
520
+ return row ?? item;
521
+ }
522
+
523
+ private async enrichMany(
524
+ resource: ResourceDefinition,
525
+ items: Record<string, unknown>[]
526
+ ): Promise<Record<string, unknown>[]> {
527
+ if (items.length === 0) return items;
528
+ const rels = Object.entries(resource.fields)
529
+ .filter(([, f]) => !!f.relation)
530
+ .map(([name]) => name);
531
+ if (rels.length === 0) return items.map((i) => this.serializeRow(resource, i));
532
+
533
+ const ids = items.map((i) => Number(i.id)).filter((id) => id > 0);
534
+ if (ids.length === 0) return items.map((i) => this.serializeRow(resource, i));
535
+
536
+ const loaded = await this.db.from(resource.table).whereIn("id", ids).with(...rels).get();
537
+ const map = new Map(loaded.map((r) => [Number(r.id), r]));
538
+ return items.map((i) => this.serializeRow(resource, map.get(Number(i.id)) ?? i));
539
+ }
540
+
541
+ async handle(req: Request): Promise<Response> {
542
+ const url = new URL(req.url);
543
+ const path = url.pathname.replace(/\/+$/, "") || "/";
544
+ const user = userFromRequest(req);
545
+ const t0 = performance.now();
546
+
547
+ const res = await runMiddleware(req, user, async () => {
548
+ if (req.method === "OPTIONS") {
549
+ return new Response(null, { status: 204 });
550
+ }
551
+
552
+ if (isDevMode()) {
553
+ if (req.method === "GET" && path === "/dev-bar.js") {
554
+ const file = await this.servePublicFile("dev-bar.js", "application/javascript; charset=utf-8");
555
+ if (file) return file;
556
+ }
557
+
558
+ if (req.method === "GET" && path === "/api/dev/status") {
559
+ const status = getDevStatus(this.routes.size);
560
+ status.dialect = this.db.dialect;
561
+ return json({ data: status });
562
+ }
563
+
564
+ if (req.method === "GET" && path === "/api/dev/routes") {
565
+ return json({
566
+ data: {
567
+ resources: this.resources.map((r) => ({
568
+ name: r.name,
569
+ table: r.table,
570
+ has_api: r.hasApi,
571
+ has_admin: r.hasAdmin,
572
+ soft_delete: r.softDelete,
573
+ })),
574
+ routes: this.getRouteManifest(),
575
+ },
576
+ });
577
+ }
578
+ }
579
+
580
+ // Load balancer / k8s probes — no auth
581
+ if (req.method === "GET" && (path === "/health" || path === "/api/health")) {
582
+ return json({ ok: true, status: "healthy" });
583
+ }
584
+ if (req.method === "GET" && (path === "/ready" || path === "/api/ready")) {
585
+ try {
586
+ await this.db.getOne("SELECT 1 as ok");
587
+ return json({ ok: true, status: "ready", dialect: this.db.dialect });
588
+ } catch {
589
+ return error("Database not ready", 503);
590
+ }
591
+ }
592
+
593
+ if (req.method === "GET" && path.startsWith("/storage/uploads/")) {
594
+ const filePath = storagePath(path);
595
+ if (!filePath || !existsSync(filePath)) return error("Not found", 404);
596
+ const buf = await readFile(filePath);
597
+ return new Response(buf);
598
+ }
599
+
600
+ if (req.method === "GET" && !path.startsWith("/api")) {
601
+ const page = path === "/" ? "index" : path.slice(1);
602
+ if (!page.includes("..") && /^[a-zA-Z0-9/_-]*$/.test(page)) {
603
+ for (const dir of this.staticDirs()) {
604
+ const full = join(dir, `${page}.html`);
605
+ if (existsSync(full)) {
606
+ return this.serveHtmlPage(full);
607
+ }
608
+ }
609
+ }
610
+ }
611
+
612
+ if (req.method === "GET" && path === "/api/docs") {
613
+ const { readdirSync } = await import("fs");
614
+ const { join } = await import("path");
615
+ const dir = join(process.cwd(), "docs");
616
+ let files: string[] = [];
617
+ try {
618
+ files = readdirSync(dir)
619
+ .filter((f) => f.endsWith(".md"))
620
+ .map((f) => f.replace(/\.md$/, ""));
621
+ } catch {
622
+ files = [];
623
+ }
624
+ return json({ data: files });
625
+ }
626
+
627
+ if (req.method === "GET" && path.startsWith("/api/docs/")) {
628
+ const { readFileSync, existsSync } = await import("fs");
629
+ const { join } = await import("path");
630
+ const slug = path.slice("/api/docs/".length).replace(/[^a-zA-Z0-9_-]/g, "");
631
+ if (!slug) return error("Invalid doc", 400);
632
+ const file = join(process.cwd(), "docs", `${slug}.md`);
633
+ if (!existsSync(file)) return error("Not found", 404);
634
+ const content = readFileSync(file, "utf-8");
635
+ const title = content.match(/^#\s+(.+)$/m)?.[1] ?? slug;
636
+ return json({ data: { slug, title, content }, slug, title, content });
637
+ }
638
+
639
+ if (req.method === "GET" && path === "/api/schema") {
640
+ const locale = url.searchParams.get("lang") ?? defaultLocale();
641
+ const ui = getUi(locale);
642
+ // Login page may fetch UI strings without a token
643
+ if (!user) {
644
+ return json({ data: [], ui });
645
+ }
646
+ const schemas = this.resources
647
+ .filter((r) => r.hasAdmin)
648
+ .map((r) => ({
649
+ name: r.name,
650
+ label: r.labelKey ? t(r.labelKey, locale) : r.label,
651
+ auth: r.authRole,
652
+ softDelete: r.softDelete,
653
+ fields: Object.entries(r.fields).map(([name, f]) => ({
654
+ name,
655
+ label: f.label ?? name,
656
+ type: f.type,
657
+ required: f.required,
658
+ relation: f.relation ?? null,
659
+ options: f.options ?? null,
660
+ })),
661
+ }));
662
+ return json({ data: schemas, ui });
663
+ }
664
+
665
+ if (req.method === "GET" && path === "/api/dashboard") {
666
+ if (!user) return error("Unauthorized", 401);
667
+ const locale = url.searchParams.get("lang") ?? defaultLocale();
668
+ const stats = await Promise.all(
669
+ this.resources
670
+ .filter((r) => r.hasAdmin)
671
+ .map(async (r) => ({
672
+ name: r.name,
673
+ label: r.labelKey ? t(r.labelKey, locale) : r.label,
674
+ count: (
675
+ await this.db.findAll(r.table, {
676
+ limit: 10000,
677
+ softDelete: r.softDelete ? "exclude" : undefined,
678
+ })
679
+ ).length,
680
+ }))
681
+ );
682
+
683
+ return json({ data: { stats } });
684
+ }
685
+
686
+ if (req.method === "GET" && path === "/api") {
687
+ const endpoints = [...this.routes.keys()]
688
+ .filter((key) => key.startsWith("GET ") || key.startsWith("POST "))
689
+ .map((key) => key.replace(/^(GET|POST|PUT|DELETE) /, "$1 "));
690
+
691
+ return json({ message: "Nexa", endpoints, resources: this.resources.map((r) => r.name) });
692
+ }
693
+
694
+ const exactKey = `${req.method} ${path}`;
695
+ if (this.routes.has(exactKey)) {
696
+ return this.routes.get(exactKey)!(req, user);
697
+ }
698
+
699
+ for (const [key, handler] of this.routes) {
700
+ const [method, pattern] = key.split(" ");
701
+ if (method !== req.method) continue;
702
+
703
+ const regex = pattern.replace(/:\w+/g, "(\\d+)");
704
+ if (new RegExp(`^${regex}$`).test(path)) {
705
+ return handler(req, user);
706
+ }
707
+ }
708
+
709
+ return error("Not found", 404);
710
+ });
711
+
712
+ recordDevRequest(req.method, path, res.status, performance.now() - t0);
713
+ return res;
714
+ }
715
+ }
716
+
717
+ function extractId(url: string): number | null {
718
+ const parts = new URL(url).pathname.split("/");
719
+ const id = Number(parts[parts.length - 1]);
720
+ return Number.isInteger(id) && id > 0 ? id : null;
721
+ }
722
+
723
+ export interface NexaServer {
724
+ port: number;
725
+ close: () => void;
726
+ stop: () => void;
727
+ }
728
+
729
+ export function createServer(router: Router, port = 3333): Promise<NexaServer> {
730
+ return new Promise(async (resolve, reject) => {
731
+ let tryPort = port;
732
+ let lastErr: unknown;
733
+
734
+ for (let attempt = 0; attempt < 10; attempt++) {
735
+ try {
736
+ const server = await listenOnce(router, tryPort);
737
+ if (tryPort !== port) {
738
+ console.warn(`[nexa] Port ${port} in use — listening on ${tryPort}`);
739
+ }
740
+ resolve(server);
741
+ return;
742
+ } catch (err: unknown) {
743
+ lastErr = err;
744
+ const code = (err as { code?: string })?.code;
745
+ if (code === "EADDRINUSE" && attempt < 9) {
746
+ tryPort++;
747
+ continue;
748
+ }
749
+ reject(err);
750
+ return;
751
+ }
752
+ }
753
+ reject(lastErr ?? new Error(`Could not bind a port near ${port}`));
754
+ });
755
+ }
756
+
757
+ function listenOnce(router: Router, port: number): Promise<NexaServer> {
758
+ return new Promise((resolve, reject) => {
759
+ const server: HttpServer = createHttpServer(async (req, res) => {
760
+ try {
761
+ const host = req.headers.host || `localhost:${port}`;
762
+ const url = `http://${host}${req.url || "/"}`;
763
+ const chunks: Buffer[] = [];
764
+ for await (const chunk of req) {
765
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
766
+ }
767
+ const bodyBuf = Buffer.concat(chunks);
768
+ const headers = new Headers();
769
+ for (const [key, value] of Object.entries(req.headers)) {
770
+ if (value === undefined) continue;
771
+ if (Array.isArray(value)) {
772
+ for (const v of value) headers.append(key, v);
773
+ } else {
774
+ headers.set(key, value);
775
+ }
776
+ }
777
+ const method = req.method || "GET";
778
+ const request = new Request(url, {
779
+ method,
780
+ headers,
781
+ body: method === "GET" || method === "HEAD" ? undefined : bodyBuf,
782
+ duplex: "half",
783
+ } as RequestInit);
784
+
785
+ const response = await router.handle(request);
786
+ res.statusCode = response.status;
787
+ response.headers.forEach((value, key) => {
788
+ if (key.toLowerCase() === "transfer-encoding") return;
789
+ res.setHeader(key, value);
790
+ });
791
+ const ab = await response.arrayBuffer();
792
+ res.end(Buffer.from(ab));
793
+ } catch (e) {
794
+ res.statusCode = 500;
795
+ res.setHeader("Content-Type", "application/json");
796
+ res.end(JSON.stringify({ error: e instanceof Error ? e.message : String(e) }));
797
+ }
798
+ });
799
+
800
+ const onError = (err: Error) => {
801
+ server.close();
802
+ reject(err);
803
+ };
804
+ server.once("error", onError);
805
+ server.listen(port, () => {
806
+ server.off("error", onError);
807
+ const close = () => {
808
+ server.close();
809
+ };
810
+ resolve({ port, close, stop: close });
811
+ });
812
+ });
813
+ }