millas 0.2.10 → 0.2.12-beta

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 (48) hide show
  1. package/package.json +17 -3
  2. package/src/auth/AuthController.js +42 -133
  3. package/src/auth/AuthMiddleware.js +12 -23
  4. package/src/auth/RoleMiddleware.js +7 -17
  5. package/src/commands/migrate.js +46 -31
  6. package/src/commands/serve.js +266 -37
  7. package/src/container/Application.js +88 -8
  8. package/src/container/MillasApp.js +6 -14
  9. package/src/controller/Controller.js +79 -300
  10. package/src/errors/ErrorRenderer.js +640 -0
  11. package/src/facades/Admin.js +49 -0
  12. package/src/facades/Auth.js +46 -0
  13. package/src/facades/Cache.js +17 -0
  14. package/src/facades/Database.js +43 -0
  15. package/src/facades/Events.js +24 -0
  16. package/src/facades/Http.js +54 -0
  17. package/src/facades/Log.js +56 -0
  18. package/src/facades/Mail.js +40 -0
  19. package/src/facades/Queue.js +23 -0
  20. package/src/facades/Storage.js +17 -0
  21. package/src/facades/Validation.js +69 -0
  22. package/src/http/MillasRequest.js +253 -0
  23. package/src/http/MillasResponse.js +196 -0
  24. package/src/http/RequestContext.js +176 -0
  25. package/src/http/ResponseDispatcher.js +144 -0
  26. package/src/http/helpers.js +164 -0
  27. package/src/http/index.js +13 -0
  28. package/src/index.js +55 -2
  29. package/src/logger/internal.js +76 -0
  30. package/src/logger/patchConsole.js +135 -0
  31. package/src/middleware/CorsMiddleware.js +22 -30
  32. package/src/middleware/LogMiddleware.js +27 -59
  33. package/src/middleware/Middleware.js +24 -15
  34. package/src/middleware/MiddlewarePipeline.js +30 -67
  35. package/src/middleware/MiddlewareRegistry.js +126 -0
  36. package/src/middleware/ThrottleMiddleware.js +22 -26
  37. package/src/orm/fields/index.js +124 -56
  38. package/src/orm/migration/ModelInspector.js +7 -3
  39. package/src/orm/model/Model.js +96 -6
  40. package/src/orm/query/QueryBuilder.js +141 -3
  41. package/src/providers/LogServiceProvider.js +88 -18
  42. package/src/providers/ProviderRegistry.js +14 -1
  43. package/src/providers/ServiceProvider.js +40 -8
  44. package/src/router/Router.js +155 -223
  45. package/src/scaffold/maker.js +24 -59
  46. package/src/scaffold/templates.js +13 -12
  47. package/src/validation/BaseValidator.js +193 -0
  48. package/src/validation/Validator.js +680 -0
@@ -0,0 +1,640 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const os = require('os');
6
+
7
+ /**
8
+ * ErrorRenderer
9
+ *
10
+ * Renders errors as either JSON (API requests) or a rich HTML error page
11
+ * (browser requests in development) inspired by Laravel's Ignition and
12
+ * Django's debug page.
13
+ *
14
+ * In production, browser errors show a clean minimal page with no internals.
15
+ * JSON responses are always clean regardless of environment.
16
+ *
17
+ * Features (dev HTML mode):
18
+ * - Error type, message, and status code
19
+ * - Source file with the error line highlighted in context
20
+ * - Full stack trace with collapsible frames
21
+ * - Request details (method, URL, headers, body, query)
22
+ * - Environment info (Node version, env name, app name)
23
+ * - Skips node_modules frames (collapsed by default)
24
+ */
25
+ class ErrorRenderer {
26
+
27
+ /**
28
+ * Express error-handler middleware factory.
29
+ * Use instead of the inline error handler in Router.mountFallbacks().
30
+ *
31
+ * app.use(ErrorRenderer.handler());
32
+ */
33
+ static handler() {
34
+ return function millaErrorHandler(err, req, res, next) { // eslint-disable-line no-unused-vars
35
+ ErrorRenderer.render(err, req, res);
36
+ };
37
+ }
38
+
39
+ /**
40
+ * 404 handler middleware factory.
41
+ *
42
+ * app.use(ErrorRenderer.notFound());
43
+ */
44
+ static notFound() {
45
+ return function millaNotFound(req, res) {
46
+ const err = Object.assign(new Error(`Cannot ${req.method} ${req.path}`), {
47
+ status: 404,
48
+ statusCode: 404,
49
+ });
50
+ ErrorRenderer.render(err, req, res);
51
+ };
52
+ }
53
+
54
+ /**
55
+ * Render an error to the response — JSON or HTML based on Accept header.
56
+ */
57
+ static render(err, req, res) {
58
+ const status = err.status || err.statusCode || 500;
59
+ const isDev = process.env.NODE_ENV !== 'production';
60
+ const wantsHtml = ErrorRenderer._wantsHtml(req);
61
+
62
+ res.status(status);
63
+
64
+ if (!wantsHtml) {
65
+ // ── JSON response ──────────────────────────────────────────────────────
66
+ const body = {
67
+ error: status >= 500 ? 'Internal Server Error' : err.message,
68
+ message: err.message,
69
+ status,
70
+ ...(err.errors && { errors: err.errors }),
71
+ ...(isDev && status >= 500 && { stack: err.stack }),
72
+ };
73
+ return res.json(body);
74
+ }
75
+
76
+ if (!isDev || status < 500 && !err._forceDebug) {
77
+ // ── Production / 4xx HTML ──────────────────────────────────────────────
78
+ return res.send(ErrorRenderer._renderSimple(status, err.message));
79
+ }
80
+
81
+ // ── Development HTML — full debug page ────────────────────────────────────
82
+ res.send(ErrorRenderer._renderDebug(err, req, status));
83
+ }
84
+
85
+ // ─── HTML renderers ────────────────────────────────────────────────────────
86
+
87
+ static _renderSimple(status, message) {
88
+ const title = ErrorRenderer._statusTitle(status);
89
+ return `<!DOCTYPE html>
90
+ <html lang="en">
91
+ <head>
92
+ <meta charset="UTF-8">
93
+ <meta name="viewport" content="width=device-width,initial-scale=1">
94
+ <title>${status} ${title}</title>
95
+ <style>
96
+ *{box-sizing:border-box;margin:0;padding:0}
97
+ body{font-family:system-ui,sans-serif;background:#f8f9fa;display:flex;align-items:center;justify-content:center;min-height:100vh;padding:24px}
98
+ .card{background:#fff;border:1px solid #e5e7eb;border-radius:12px;padding:48px;max-width:480px;width:100%;text-align:center;box-shadow:0 4px 24px rgba(0,0,0,.06)}
99
+ .code{font-size:72px;font-weight:800;color:#111;line-height:1}
100
+ .title{font-size:20px;font-weight:600;color:#374151;margin:12px 0 8px}
101
+ .msg{font-size:14px;color:#6b7280}
102
+ </style>
103
+ </head>
104
+ <body>
105
+ <div class="card">
106
+ <div class="code">${status}</div>
107
+ <div class="title">${title}</div>
108
+ <div class="msg">${_esc(message)}</div>
109
+ </div>
110
+ </body>
111
+ </html>`;
112
+ }
113
+
114
+ static _renderDebug(err, req, status) {
115
+ const frames = ErrorRenderer._parseStack(err);
116
+ const appFrames = frames.filter(f => !f.isVendor);
117
+ const firstApp = appFrames[0] || frames[0];
118
+ const source = firstApp ? ErrorRenderer._getSourceContext(firstApp.file, firstApp.line, 8) : null;
119
+ const title = `${err.name || 'Error'}: ${err.message}`;
120
+ const statusTitle = ErrorRenderer._statusTitle(status);
121
+
122
+ // Request details
123
+ const reqInfo = {
124
+ method: req.method,
125
+ url: req.originalUrl || req.url,
126
+ headers: req.headers,
127
+ query: req.query,
128
+ body: req.body,
129
+ ip: req.ip || req.connection?.remoteAddress,
130
+ };
131
+
132
+ // Environment info
133
+ const envInfo = {
134
+ 'Node.js': process.version,
135
+ 'Environment': process.env.NODE_ENV || 'development',
136
+ 'App': process.env.APP_NAME || path.basename(process.cwd()),
137
+ 'Platform': `${os.type()} ${os.arch()}`,
138
+ 'PID': process.pid,
139
+ 'Uptime': `${Math.floor(process.uptime())}s`,
140
+ };
141
+
142
+ return `<!DOCTYPE html>
143
+ <html lang="en">
144
+ <head>
145
+ <meta charset="UTF-8">
146
+ <meta name="viewport" content="width=device-width,initial-scale=1">
147
+ <title>${_esc(title)}</title>
148
+ <style>
149
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
150
+
151
+ :root {
152
+ /* ── Light mode (system default) ── */
153
+ --bg: #f4f5f7;
154
+ --surface: #ffffff;
155
+ --surface2: #f0f1f4;
156
+ --surface3: #e8eaef;
157
+ --border: #dde0e8;
158
+ --text: #111827;
159
+ --muted: #6b7280;
160
+ --xmuted: #9ca3af;
161
+ --red: #dc2626;
162
+ --orange: #ea580c;
163
+ --yellow: #d97706;
164
+ --green: #16a34a;
165
+ --blue: #2563eb;
166
+ --purple: #7c3aed;
167
+ --cyan: #0891b2;
168
+ --hl-bg: #fef2f2;
169
+ --hl-border:#fecaca;
170
+ --hl-text: #b91c1c;
171
+
172
+ /* HTTP method tag colors — light */
173
+ --tag-get-bg: #f0fdf4; --tag-get-fg: #15803d;
174
+ --tag-post-bg: #eff6ff; --tag-post-fg: #1d4ed8;
175
+ --tag-put-bg: #fff7ed; --tag-put-fg: #c2410c;
176
+ --tag-patch-bg: #f5f3ff; --tag-patch-fg: #6d28d9;
177
+ --tag-delete-bg: #fef2f2; --tag-delete-fg: #b91c1c;
178
+
179
+ /* Status badge — light */
180
+ --badge-500-bg: #fef2f2; --badge-500-fg: #b91c1c; --badge-500-border: #fecaca;
181
+ --badge-4xx-bg: #fffbeb; --badge-4xx-fg: #b45309; --badge-4xx-border: #fde68a;
182
+ }
183
+
184
+ @media (prefers-color-scheme: dark) {
185
+ :root {
186
+ --bg: #0f0f13;
187
+ --surface: #18181f;
188
+ --surface2: #1e1e27;
189
+ --surface3: #25252f;
190
+ --border: #2a2a36;
191
+ --text: #e2e2ef;
192
+ --muted: #8b8ba0;
193
+ --xmuted: #555568;
194
+ --red: #f87171;
195
+ --orange: #fb923c;
196
+ --yellow: #fbbf24;
197
+ --green: #4ade80;
198
+ --blue: #60a5fa;
199
+ --purple: #a78bfa;
200
+ --cyan: #22d3ee;
201
+ --hl-bg: #2d1f1f;
202
+ --hl-border:#7f1d1d;
203
+ --hl-text: #fca5a5;
204
+
205
+ --tag-get-bg: #052e16; --tag-get-fg: #4ade80;
206
+ --tag-post-bg: #172554; --tag-post-fg: #60a5fa;
207
+ --tag-put-bg: #1c1917; --tag-put-fg: #fb923c;
208
+ --tag-patch-bg: #2e1065; --tag-patch-fg: #a78bfa;
209
+ --tag-delete-bg: #450a0a; --tag-delete-fg: #f87171;
210
+
211
+ --badge-500-bg: #450a0a; --badge-500-fg: #fca5a5; --badge-500-border: #991b1b;
212
+ --badge-4xx-bg: #451a03; --badge-4xx-fg: #fde68a; --badge-4xx-border: #92400e;
213
+ }
214
+ }
215
+
216
+ body {
217
+ font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
218
+ background: var(--bg);
219
+ color: var(--text);
220
+ font-size: 14px;
221
+ line-height: 1.5;
222
+ min-height: 100vh;
223
+ }
224
+
225
+ /* ── Header ── */
226
+ .header {
227
+ background: var(--surface);
228
+ border-bottom: 1px solid var(--border);
229
+ padding: 20px 28px;
230
+ display: flex;
231
+ align-items: flex-start;
232
+ gap: 16px;
233
+ }
234
+ .status-badge {
235
+ flex-shrink: 0;
236
+ background: var(--badge-500-bg);
237
+ color: var(--badge-500-fg);
238
+ border: 1px solid var(--badge-500-border);
239
+ border-radius: 8px;
240
+ padding: 6px 14px;
241
+ font-size: 18px;
242
+ font-weight: 800;
243
+ font-variant-numeric: tabular-nums;
244
+ }
245
+ .status-4xx .status-badge {
246
+ background: var(--badge-4xx-bg);
247
+ color: var(--badge-4xx-fg);
248
+ border-color: var(--badge-4xx-border);
249
+ }
250
+ .header-body { flex: 1; min-width: 0; }
251
+ .error-type { font-size: 12px; font-weight: 600; color: var(--red); text-transform: uppercase; letter-spacing: .5px; margin-bottom: 4px; }
252
+ .error-msg { font-size: 20px; font-weight: 700; color: var(--text); word-break: break-word; line-height: 1.3; }
253
+ .error-file { font-size: 12px; color: var(--muted); margin-top: 6px; font-family: 'Cascadia Code', 'Fira Code', monospace; }
254
+
255
+ /* ── Layout ── */
256
+ .layout {
257
+ display: grid;
258
+ grid-template-columns: 1fr 300px;
259
+ gap: 0;
260
+ min-height: calc(100vh - 80px);
261
+ }
262
+ @media (max-width: 900px) {
263
+ .layout { grid-template-columns: 1fr; }
264
+ }
265
+ .main { border-right: 1px solid var(--border); overflow: hidden; }
266
+ .aside { background: var(--surface); }
267
+
268
+ /* ── Section ── */
269
+ .section { border-bottom: 1px solid var(--border); }
270
+ .section-header {
271
+ padding: 14px 20px;
272
+ background: var(--surface2);
273
+ font-size: 11px;
274
+ font-weight: 700;
275
+ text-transform: uppercase;
276
+ letter-spacing: .8px;
277
+ color: var(--muted);
278
+ display: flex;
279
+ align-items: center;
280
+ gap: 8px;
281
+ cursor: pointer;
282
+ user-select: none;
283
+ }
284
+ .section-header:hover { background: var(--surface3); }
285
+ .section-header .toggle { margin-left: auto; color: var(--xmuted); font-size: 10px; transition: transform .2s; }
286
+ .section-header.collapsed .toggle { transform: rotate(-90deg); }
287
+ .section-body { }
288
+ .section-body.hidden { display: none; }
289
+
290
+ /* ── Source viewer ── */
291
+ .source {
292
+ font-family: 'Cascadia Code', 'Fira Code', 'SF Mono', 'Consolas', monospace;
293
+ font-size: 13px;
294
+ line-height: 1.6;
295
+ overflow-x: auto;
296
+ }
297
+ .source-line {
298
+ display: flex;
299
+ align-items: stretch;
300
+ min-height: 22px;
301
+ }
302
+ .source-line.hl {
303
+ background: var(--hl-bg);
304
+ border-left: 3px solid var(--red);
305
+ }
306
+ .source-line:not(.hl) { border-left: 3px solid transparent; }
307
+ .line-no {
308
+ flex-shrink: 0;
309
+ width: 52px;
310
+ text-align: right;
311
+ padding: 0 12px;
312
+ color: var(--xmuted);
313
+ font-size: 12px;
314
+ user-select: none;
315
+ }
316
+ .source-line.hl .line-no { color: var(--red); }
317
+ .line-code {
318
+ flex: 1;
319
+ padding: 0 16px 0 8px;
320
+ white-space: pre;
321
+ color: var(--text);
322
+ word-break: break-all;
323
+ }
324
+ .source-line.hl .line-code { color: var(--hl-text); }
325
+
326
+ /* ── Stack trace ── */
327
+ .frame {
328
+ padding: 10px 20px;
329
+ border-bottom: 1px solid var(--border);
330
+ cursor: pointer;
331
+ transition: background .1s;
332
+ }
333
+ .frame:hover { background: var(--surface2); }
334
+ .frame.vendor { opacity: .45; }
335
+ .frame.active { background: var(--surface2); border-left: 3px solid var(--blue); }
336
+ .frame-fn { font-family: monospace; font-size: 13px; color: var(--blue); font-weight: 600; }
337
+ .frame-file { font-size: 12px; color: var(--muted); margin-top: 2px; font-family: monospace; }
338
+ .frame-file span { color: var(--text); }
339
+ .vendor-group {
340
+ padding: 8px 20px;
341
+ font-size: 11px;
342
+ color: var(--xmuted);
343
+ cursor: pointer;
344
+ border-bottom: 1px solid var(--border);
345
+ }
346
+ .vendor-group:hover { color: var(--muted); }
347
+
348
+ /* ── Request / env panels ── */
349
+ .info-table { width: 100%; border-collapse: collapse; }
350
+ .info-table td {
351
+ padding: 8px 16px;
352
+ font-size: 12.5px;
353
+ border-bottom: 1px solid var(--border);
354
+ vertical-align: top;
355
+ }
356
+ .info-table td:first-child {
357
+ color: var(--muted);
358
+ font-weight: 600;
359
+ white-space: nowrap;
360
+ width: 38%;
361
+ font-family: monospace;
362
+ }
363
+ .info-table td:last-child { color: var(--text); word-break: break-all; }
364
+ .tag {
365
+ display: inline-block;
366
+ padding: 1px 7px;
367
+ border-radius: 99px;
368
+ font-size: 11px;
369
+ font-weight: 700;
370
+ font-family: monospace;
371
+ }
372
+ .tag-get { background: var(--tag-get-bg); color: var(--tag-get-fg); }
373
+ .tag-post { background: var(--tag-post-bg); color: var(--tag-post-fg); }
374
+ .tag-put { background: var(--tag-put-bg); color: var(--tag-put-fg); }
375
+ .tag-patch { background: var(--tag-patch-bg); color: var(--tag-patch-fg); }
376
+ .tag-delete { background: var(--tag-delete-bg); color: var(--tag-delete-fg); }
377
+ pre.json-val {
378
+ background: var(--surface3);
379
+ border: 1px solid var(--border);
380
+ border-radius: 6px;
381
+ padding: 10px 12px;
382
+ font-size: 12px;
383
+ font-family: monospace;
384
+ overflow-x: auto;
385
+ white-space: pre-wrap;
386
+ word-break: break-all;
387
+ color: var(--cyan);
388
+ max-height: 200px;
389
+ overflow-y: auto;
390
+ }
391
+
392
+ /* ── Scrollbar ── */
393
+ ::-webkit-scrollbar { width: 6px; height: 6px; }
394
+ ::-webkit-scrollbar-track { background: transparent; }
395
+ ::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
396
+ </style>
397
+ </head>
398
+ <body>
399
+
400
+ <!-- Header -->
401
+ <div class="header${status < 500 ? ' status-4xx' : ''}">
402
+ <div class="status-badge">${status}</div>
403
+ <div class="header-body">
404
+ <div class="error-type">${_esc(err.name || 'Error')} &nbsp;·&nbsp; ${_esc(statusTitle)}</div>
405
+ <div class="error-msg">${_esc(err.message)}</div>
406
+ ${firstApp ? `<div class="error-file">${_esc(ErrorRenderer._shortPath(firstApp.file))} : ${firstApp.line}</div>` : ''}
407
+ </div>
408
+ </div>
409
+
410
+ <div class="layout">
411
+
412
+ <!-- ── Main column ── -->
413
+ <div class="main">
414
+
415
+ ${source ? `
416
+ <!-- Source viewer -->
417
+ <div class="section">
418
+ <div class="section-header" onclick="toggle(this)">
419
+ <span>Source</span>
420
+ <span style="color:var(--muted);font-size:11px;font-weight:400;font-family:monospace">${_esc(ErrorRenderer._shortPath(firstApp.file))}</span>
421
+ <span class="toggle">▾</span>
422
+ </div>
423
+ <div class="section-body source">
424
+ ${source.map(l => `<div class="source-line${l.highlight ? ' hl' : ''}">
425
+ <span class="line-no">${l.no}</span>
426
+ <span class="line-code">${_esc(l.text)}</span>
427
+ </div>`).join('')}
428
+ </div>
429
+ </div>` : ''}
430
+
431
+ <!-- Stack trace -->
432
+ <div class="section">
433
+ <div class="section-header" onclick="toggle(this)">
434
+ <span>Stack Trace</span>
435
+ <span style="color:var(--muted);font-size:11px;font-weight:400">${frames.length} frames</span>
436
+ <span class="toggle">▾</span>
437
+ </div>
438
+ <div class="section-body" id="stack-body">
439
+ ${ErrorRenderer._renderFrames(frames)}
440
+ </div>
441
+ </div>
442
+
443
+ <!-- Request -->
444
+ <div class="section">
445
+ <div class="section-header" onclick="toggle(this)">
446
+ <span>Request</span>
447
+ <span class="tag tag-${reqInfo.method.toLowerCase()}">${reqInfo.method}</span>
448
+ <span style="color:var(--muted);font-size:11px;font-weight:400;font-family:monospace">${_esc(reqInfo.url)}</span>
449
+ <span class="toggle">▾</span>
450
+ </div>
451
+ <div class="section-body">
452
+ <table class="info-table">
453
+ <tr><td>URL</td><td style="font-family:monospace">${_esc(reqInfo.url)}</td></tr>
454
+ <tr><td>IP</td><td>${_esc(reqInfo.ip || '—')}</td></tr>
455
+ ${reqInfo.query && Object.keys(reqInfo.query).length ? `<tr><td>Query</td><td><pre class="json-val">${_esc(JSON.stringify(reqInfo.query, null, 2))}</pre></td></tr>` : ''}
456
+ ${reqInfo.body && Object.keys(reqInfo.body || {}).length ? `<tr><td>Body</td><td><pre class="json-val">${_esc(JSON.stringify(reqInfo.body, null, 2))}</pre></td></tr>` : ''}
457
+ </table>
458
+ <details style="padding:0">
459
+ <summary style="padding:8px 16px;font-size:11px;color:var(--xmuted);cursor:pointer;list-style:none">Show all headers ▾</summary>
460
+ <table class="info-table">
461
+ ${Object.entries(reqInfo.headers).map(([k, v]) =>
462
+ `<tr><td>${_esc(k)}</td><td style="font-family:monospace;font-size:11px">${_esc(String(v))}</td></tr>`
463
+ ).join('')}
464
+ </table>
465
+ </details>
466
+ </div>
467
+ </div>
468
+
469
+ ${err.errors ? `
470
+ <!-- Validation errors -->
471
+ <div class="section">
472
+ <div class="section-header" onclick="toggle(this)">
473
+ <span>Validation Errors</span>
474
+ <span class="toggle">▾</span>
475
+ </div>
476
+ <div class="section-body">
477
+ <pre class="json-val" style="margin:12px 16px">${_esc(JSON.stringify(err.errors, null, 2))}</pre>
478
+ </div>
479
+ </div>` : ''}
480
+
481
+ </div>
482
+
483
+ <!-- ── Aside ── -->
484
+ <div class="aside">
485
+
486
+ <!-- Env info -->
487
+ <div class="section">
488
+ <div class="section-header" style="cursor:default">
489
+ <span>Environment</span>
490
+ </div>
491
+ <div class="section-body">
492
+ <table class="info-table">
493
+ ${Object.entries(envInfo).map(([k, v]) =>
494
+ `<tr><td>${_esc(k)}</td><td>${_esc(String(v))}</td></tr>`
495
+ ).join('')}
496
+ </table>
497
+ </div>
498
+ </div>
499
+
500
+ <!-- All frames as mini list in aside -->
501
+ <div class="section">
502
+ <div class="section-header" style="cursor:default">
503
+ <span>App Frames</span>
504
+ </div>
505
+ <div class="section-body">
506
+ ${frames.filter(f => !f.isVendor).map((f, i) => `
507
+ <div class="frame${i === 0 ? ' active' : ''}" style="padding:8px 16px" onclick="jumpToFrame(${frames.indexOf(f)})">
508
+ <div class="frame-fn">${_esc(f.fn)}</div>
509
+ <div class="frame-file" style="font-size:11px">${_esc(ErrorRenderer._shortPath(f.file))} <span>:${f.line}</span></div>
510
+ </div>`).join('')}
511
+ </div>
512
+ </div>
513
+
514
+ <!-- Millas watermark -->
515
+ <div style="padding:16px;text-align:center;color:var(--xmuted);font-size:11px;border-top:1px solid var(--border);margin-top:auto">
516
+ Millas Framework &nbsp;·&nbsp; Dev Error Page
517
+ </div>
518
+
519
+ </div>
520
+ </div>
521
+
522
+ <script>
523
+ function toggle(header) {
524
+ const body = header.nextElementSibling;
525
+ header.classList.toggle('collapsed');
526
+ body.classList.toggle('hidden');
527
+ }
528
+
529
+ function jumpToFrame(idx) {
530
+ const frames = document.querySelectorAll('#stack-body .frame');
531
+ frames.forEach((f, i) => f.classList.toggle('active', i === idx));
532
+ }
533
+ </script>
534
+ </body>
535
+ </html>`;
536
+ }
537
+
538
+ // ─── Stack trace helpers ───────────────────────────────────────────────────
539
+
540
+ static _parseStack(err) {
541
+ const lines = (err.stack || '').split('\n').slice(1);
542
+ return lines.map(line => {
543
+ const m1 = line.match(/^\s+at (.+?) \((.+?):(\d+):(\d+)\)$/);
544
+ const m2 = line.match(/^\s+at (.+?):(\d+):(\d+)$/);
545
+ if (m1) return { fn: m1[1], file: m1[2], line: +m1[3], col: +m1[4], isVendor: _isVendor(m1[2]) };
546
+ if (m2) return { fn: '<anonymous>', file: m2[1], line: +m2[2], col: +m2[3], isVendor: _isVendor(m2[1]) };
547
+ return null;
548
+ }).filter(Boolean);
549
+ }
550
+
551
+ static _getSourceContext(file, lineNo, context = 7) {
552
+ try {
553
+ if (!file || file.startsWith('node:') || !fs.existsSync(file)) return null;
554
+ const src = fs.readFileSync(file, 'utf8').split('\n');
555
+ const start = Math.max(0, lineNo - context - 1);
556
+ const end = Math.min(src.length, lineNo + context);
557
+ return src.slice(start, end).map((text, i) => ({
558
+ no: start + i + 1,
559
+ text: text.replace(/\t/g, ' '),
560
+ highlight: (start + i + 1) === lineNo,
561
+ }));
562
+ } catch { return null; }
563
+ }
564
+
565
+ static _renderFrames(frames) {
566
+ let html = '';
567
+ let vendorCount = 0;
568
+ let vendorBuf = [];
569
+
570
+ const flush = () => {
571
+ if (vendorBuf.length) {
572
+ html += `<div class="vendor-group" onclick="this.nextElementSibling.style.display=this.nextElementSibling.style.display==='none'?'block':'none'">
573
+ ▸ ${vendorBuf.length} vendor frame${vendorBuf.length > 1 ? 's' : ''} (node_modules / internals)
574
+ </div>
575
+ <div style="display:none">${vendorBuf.join('')}</div>`;
576
+ vendorBuf = [];
577
+ }
578
+ };
579
+
580
+ frames.forEach((f, i) => {
581
+ const frameHtml = `<div class="frame${i === 0 ? ' active' : ''}${f.isVendor ? ' vendor' : ''}" onclick="jumpToFrame(${i})">
582
+ <div class="frame-fn">${_esc(f.fn)}</div>
583
+ <div class="frame-file">${_esc(ErrorRenderer._shortPath(f.file))} <span>:${f.line}:${f.col}</span></div>
584
+ </div>`;
585
+
586
+ if (f.isVendor && i > 0) {
587
+ vendorBuf.push(frameHtml);
588
+ } else {
589
+ flush();
590
+ html += frameHtml;
591
+ }
592
+ });
593
+
594
+ flush();
595
+ return html;
596
+ }
597
+
598
+ static _shortPath(file) {
599
+ if (!file) return '';
600
+ const cwd = process.cwd();
601
+ if (file.startsWith(cwd)) return file.slice(cwd.length + 1);
602
+ return file.replace(/.*node_modules/, '…/node_modules');
603
+ }
604
+
605
+ static _statusTitle(status) {
606
+ const titles = {
607
+ 400: 'Bad Request', 401: 'Unauthorized',
608
+ 403: 'Forbidden', 404: 'Not Found',
609
+ 405: 'Method Not Allowed', 408: 'Request Timeout',
610
+ 409: 'Conflict', 410: 'Gone',
611
+ 413: 'Payload Too Large', 422: 'Unprocessable Entity',
612
+ 429: 'Too Many Requests', 500: 'Internal Server Error',
613
+ 501: 'Not Implemented', 502: 'Bad Gateway',
614
+ 503: 'Service Unavailable',504: 'Gateway Timeout',
615
+ };
616
+ return titles[status] || 'Error';
617
+ }
618
+
619
+ static _wantsHtml(req) {
620
+ const accept = req.headers?.accept || '';
621
+ return accept.includes('text/html') && !accept.startsWith('application/json');
622
+ }
623
+ }
624
+
625
+ // ── Private helpers ────────────────────────────────────────────────────────────
626
+
627
+ function _isVendor(file) {
628
+ return !file || file.startsWith('node:') || file.includes('node_modules') || file.includes('<');
629
+ }
630
+
631
+ function _esc(str) {
632
+ return String(str ?? '')
633
+ .replace(/&/g, '&amp;')
634
+ .replace(/</g, '&lt;')
635
+ .replace(/>/g, '&gt;')
636
+ .replace(/"/g, '&quot;')
637
+ .replace(/'/g, '&#39;');
638
+ }
639
+
640
+ module.exports = ErrorRenderer;
@@ -0,0 +1,49 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * millas/facades/Admin
5
+ *
6
+ * const { Admin, AdminResource, AdminField, AdminFilter, AdminInline }
7
+ * = require('millas/facades/Admin');
8
+ *
9
+ * class PostResource extends AdminResource {
10
+ * static model = Post;
11
+ * static label = 'Posts';
12
+ * static searchable = ['title'];
13
+ * static dateHierarchy = 'created_at';
14
+ *
15
+ * static fields() {
16
+ * return [
17
+ * AdminField.id(),
18
+ * AdminField.text('title').sortable().required(),
19
+ * AdminField.textarea('body').nullable(),
20
+ * AdminField.boolean('published'),
21
+ * AdminField.datetime('created_at').readonly(),
22
+ * ];
23
+ * }
24
+ *
25
+ * static inlines = [
26
+ * new AdminInline({
27
+ * model: Comment, label: 'Comments',
28
+ * foreignKey: 'post_id', canCreate: true, canDelete: true,
29
+ * }),
30
+ * ];
31
+ * }
32
+ *
33
+ * // In AppServiceProvider.boot():
34
+ * Admin.register(PostResource);
35
+ */
36
+
37
+ const { Admin, AdminResource, AdminField, AdminFilter, AdminServiceProvider } = require('../index');
38
+
39
+ let AdminInline;
40
+ try { AdminInline = require('../admin/resources/AdminResource').AdminInline; } catch {}
41
+
42
+ module.exports = {
43
+ Admin,
44
+ AdminResource,
45
+ AdminField,
46
+ AdminFilter,
47
+ AdminInline,
48
+ AdminServiceProvider,
49
+ };