@nitronjs/framework 0.1.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 (87) hide show
  1. package/README.md +429 -0
  2. package/cli/create.js +260 -0
  3. package/cli/njs.js +164 -0
  4. package/lib/Auth/Manager.js +111 -0
  5. package/lib/Build/Manager.js +1232 -0
  6. package/lib/Console/Commands/BuildCommand.js +25 -0
  7. package/lib/Console/Commands/DevCommand.js +385 -0
  8. package/lib/Console/Commands/MakeCommand.js +110 -0
  9. package/lib/Console/Commands/MigrateCommand.js +98 -0
  10. package/lib/Console/Commands/MigrateFreshCommand.js +97 -0
  11. package/lib/Console/Commands/SeedCommand.js +92 -0
  12. package/lib/Console/Commands/StorageLinkCommand.js +31 -0
  13. package/lib/Console/Stubs/controller.js +19 -0
  14. package/lib/Console/Stubs/middleware.js +9 -0
  15. package/lib/Console/Stubs/migration.js +23 -0
  16. package/lib/Console/Stubs/model.js +7 -0
  17. package/lib/Console/Stubs/page-hydration.tsx +54 -0
  18. package/lib/Console/Stubs/seeder.js +9 -0
  19. package/lib/Console/Stubs/vendor.tsx +11 -0
  20. package/lib/Core/Config.js +86 -0
  21. package/lib/Core/Environment.js +21 -0
  22. package/lib/Core/Paths.js +188 -0
  23. package/lib/Database/Connection.js +61 -0
  24. package/lib/Database/DB.js +84 -0
  25. package/lib/Database/Drivers/MySQLDriver.js +234 -0
  26. package/lib/Database/Manager.js +162 -0
  27. package/lib/Database/Model.js +161 -0
  28. package/lib/Database/QueryBuilder.js +714 -0
  29. package/lib/Database/QueryValidation.js +62 -0
  30. package/lib/Database/Schema/Blueprint.js +126 -0
  31. package/lib/Database/Schema/Manager.js +116 -0
  32. package/lib/Date/DateTime.js +108 -0
  33. package/lib/Date/Locale.js +68 -0
  34. package/lib/Encryption/Manager.js +47 -0
  35. package/lib/Filesystem/Manager.js +49 -0
  36. package/lib/Hashing/Manager.js +25 -0
  37. package/lib/Http/Server.js +317 -0
  38. package/lib/Logging/Manager.js +153 -0
  39. package/lib/Mail/Manager.js +120 -0
  40. package/lib/Route/Loader.js +81 -0
  41. package/lib/Route/Manager.js +265 -0
  42. package/lib/Runtime/Entry.js +11 -0
  43. package/lib/Session/File.js +299 -0
  44. package/lib/Session/Manager.js +259 -0
  45. package/lib/Session/Memory.js +67 -0
  46. package/lib/Session/Session.js +196 -0
  47. package/lib/Support/Str.js +100 -0
  48. package/lib/Translation/Manager.js +49 -0
  49. package/lib/Validation/MimeTypes.js +39 -0
  50. package/lib/Validation/Validator.js +691 -0
  51. package/lib/View/Manager.js +544 -0
  52. package/lib/View/Templates/default/Home.tsx +262 -0
  53. package/lib/View/Templates/default/MainLayout.tsx +44 -0
  54. package/lib/View/Templates/errors/404.tsx +13 -0
  55. package/lib/View/Templates/errors/500.tsx +13 -0
  56. package/lib/View/Templates/errors/ErrorLayout.tsx +112 -0
  57. package/lib/View/Templates/messages/Maintenance.tsx +17 -0
  58. package/lib/View/Templates/messages/MessageLayout.tsx +136 -0
  59. package/lib/index.js +57 -0
  60. package/package.json +47 -0
  61. package/skeleton/.env.example +26 -0
  62. package/skeleton/app/Controllers/HomeController.js +9 -0
  63. package/skeleton/app/Kernel.js +11 -0
  64. package/skeleton/app/Middlewares/Authentication.js +9 -0
  65. package/skeleton/app/Middlewares/Guest.js +9 -0
  66. package/skeleton/app/Middlewares/VerifyCsrf.js +24 -0
  67. package/skeleton/app/Models/User.js +7 -0
  68. package/skeleton/config/app.js +4 -0
  69. package/skeleton/config/auth.js +16 -0
  70. package/skeleton/config/database.js +27 -0
  71. package/skeleton/config/hash.js +3 -0
  72. package/skeleton/config/server.js +28 -0
  73. package/skeleton/config/session.js +21 -0
  74. package/skeleton/database/migrations/2025_01_01_00_00_users.js +20 -0
  75. package/skeleton/database/seeders/UserSeeder.js +15 -0
  76. package/skeleton/globals.d.ts +1 -0
  77. package/skeleton/package.json +24 -0
  78. package/skeleton/public/.gitkeep +0 -0
  79. package/skeleton/resources/css/.gitkeep +0 -0
  80. package/skeleton/resources/langs/.gitkeep +0 -0
  81. package/skeleton/resources/views/Site/Home.tsx +66 -0
  82. package/skeleton/routes/web.js +4 -0
  83. package/skeleton/storage/app/private/.gitkeep +0 -0
  84. package/skeleton/storage/app/public/.gitkeep +0 -0
  85. package/skeleton/storage/framework/sessions/.gitkeep +0 -0
  86. package/skeleton/storage/logs/.gitkeep +0 -0
  87. package/skeleton/tsconfig.json +33 -0
@@ -0,0 +1,544 @@
1
+ import { existsSync, readFileSync, readdirSync, statSync } from "fs";
2
+ import path from "path";
3
+ import { pathToFileURL } from "url";
4
+ import { randomBytes } from "crypto";
5
+ import { AsyncLocalStorage } from "node:async_hooks";
6
+ import { PassThrough } from "stream";
7
+ import React from "react";
8
+ import { renderToPipeableStream } from "react-dom/server";
9
+ import Log from "../Logging/Manager.js";
10
+ import Route from "../Route/Manager.js";
11
+ import Paths from "../Core/Paths.js";
12
+
13
+ const CTX = Symbol.for("__nitron_view_context__");
14
+ const MARK = Symbol.for("__nitron_client_component__");
15
+ const Context = new AsyncLocalStorage();
16
+ globalThis[CTX] ??= Context;
17
+
18
+ const ESC_MAP = {
19
+ "&": "&",
20
+ "<": "&lt;",
21
+ ">": "&gt;",
22
+ '"': "&quot;",
23
+ "'": "&#39;",
24
+ "`": "&#96;"
25
+ };
26
+
27
+ const UNSAFE_KEYS = new Set(["__proto__", "constructor", "prototype"]);
28
+
29
+ function escapeHtml(str) {
30
+ if (!str) return "";
31
+ return String(str).replace(/[&<>"'`]/g, char => ESC_MAP[char]);
32
+ }
33
+
34
+ class View {
35
+ static #root = Paths.project;
36
+ static #userViews = Paths.buildViews;
37
+ static #frameworkViews = Paths.buildFrameworkViews;
38
+ static #manifestPath = path.join(Paths.project, "build/manifest.json");
39
+ static #manifest = null;
40
+ static #routesCache = null;
41
+ static #manifestMtime = null;
42
+ static #isDev = process.env.APP_DEV === "true";
43
+
44
+ static setup(server) {
45
+ server.decorateReply("view", async function(name, params = {}) {
46
+ const entry = View.#getEntry("user", name);
47
+ if (!entry) {
48
+ const error = new Error(`View "${name}" not found`);
49
+ error.statusCode = 404;
50
+ throw error;
51
+ }
52
+
53
+ const csrf = this.request.session?.getCsrfToken?.();
54
+ const fastifyRequest = this.request;
55
+
56
+ try {
57
+ const { html, nonce, meta, props } = await View.#render(
58
+ View.#userViews,
59
+ name,
60
+ params,
61
+ csrf,
62
+ fastifyRequest
63
+ );
64
+
65
+ View.#setSecurityHeaders(this, nonce);
66
+
67
+ return this
68
+ .code(200)
69
+ .type("text/html")
70
+ .send(View.#buildPage(entry, html, nonce, csrf, meta, props));
71
+ } catch (error) {
72
+ error.statusCode = error.statusCode || 500;
73
+ throw error;
74
+ }
75
+ });
76
+
77
+ server.setNotFoundHandler((req, res) => {
78
+ Log.warn("HTTP 404", {
79
+ method: req.method,
80
+ url: req.url,
81
+ ip: req.ip
82
+ });
83
+ return View.#renderError(res, 404, "errors/404", {
84
+ title: "404 - Not Found"
85
+ });
86
+ });
87
+
88
+ server.setErrorHandler((err, req, res) => {
89
+ const statusCode = err.statusCode || 500;
90
+ Log.error("HTTP Error", {
91
+ error: err.message,
92
+ stack: err.stack,
93
+ statusCode,
94
+ url: req.url
95
+ });
96
+ return View.#renderError(res, statusCode, "errors/500", {
97
+ title: "500 - Server Error",
98
+ message: View.#isDev ? err.message : "An unexpected error occurred"
99
+ });
100
+ });
101
+ }
102
+
103
+ static #getEntry(namespace, name) {
104
+ const key = `${namespace}:${name.toLowerCase().replace(/\\/g, "/")}`;
105
+ return this.#loadManifest()[key] || null;
106
+ }
107
+
108
+ static #loadManifest() {
109
+ if (!existsSync(this.#manifestPath)) {
110
+ throw new Error("Build manifest missing");
111
+ }
112
+
113
+ if (this.#isDev && this.#manifest) {
114
+ try {
115
+ const mtime = statSync(this.#manifestPath).mtimeMs;
116
+ if (mtime !== this.#manifestMtime) {
117
+ this.#manifest = null;
118
+ }
119
+ } catch {}
120
+ }
121
+
122
+ if (!this.#manifest) {
123
+ this.#manifest = JSON.parse(readFileSync(this.#manifestPath, "utf8"));
124
+ this.#manifestMtime = statSync(this.#manifestPath).mtimeMs;
125
+ }
126
+
127
+ return this.#manifest;
128
+ }
129
+
130
+ static async #render(baseDir, name, params, csrf = "", fastifyRequest = null) {
131
+ if (!name || typeof name !== "string") {
132
+ throw new Error("Invalid view name");
133
+ }
134
+
135
+ const viewPath = path.join(baseDir, name + ".js");
136
+ const resolvedPath = path.resolve(viewPath);
137
+ const resolvedBase = path.resolve(baseDir);
138
+
139
+ if (!resolvedPath.startsWith(resolvedBase + path.sep)) {
140
+ throw new Error("Invalid view path");
141
+ }
142
+
143
+ if (!existsSync(viewPath)) {
144
+ throw new Error(`View not found: ${name}`);
145
+ }
146
+
147
+ const mod = await import(pathToFileURL(viewPath).href + `?t=${Date.now()}`);
148
+ if (!mod.default) {
149
+ throw new Error("View must have a default export");
150
+ }
151
+
152
+ const nonce = randomBytes(16).toString("hex");
153
+ const ctx = {
154
+ nonce,
155
+ csrf,
156
+ props: {},
157
+ request: fastifyRequest ? {
158
+ params: fastifyRequest.params || {},
159
+ query: fastifyRequest.query || {},
160
+ url: fastifyRequest.url || '',
161
+ method: fastifyRequest.method || 'GET',
162
+ headers: fastifyRequest.headers || {}
163
+ } : null
164
+ };
165
+ let html = "";
166
+ let collectedProps = {};
167
+
168
+ try {
169
+ const Component = mod.default;
170
+ let element;
171
+
172
+ if (Component[MARK]) {
173
+ ctx.props[":R0:"] = this.#sanitizeProps(params);
174
+ element = React.createElement(
175
+ "div",
176
+ {
177
+ "data-cid": ":R0:",
178
+ "data-island": Component.displayName || Component.name || "Anonymous"
179
+ },
180
+ React.createElement(Component, params)
181
+ );
182
+ } else {
183
+ element = React.createElement(Component, params);
184
+ }
185
+
186
+ html = await Context.run(ctx, () => this.#renderToHtml(element));
187
+ collectedProps = ctx.props;
188
+ } catch (error) {
189
+ const componentName = mod.default?.displayName || mod.default?.name || "Unknown";
190
+ const errorDetails = this.#parseReactError(error);
191
+
192
+ Log.error("Render Error", {
193
+ view: name,
194
+ component: componentName,
195
+ error: error.message,
196
+ cause: errorDetails.cause,
197
+ location: errorDetails.location,
198
+ stack: error.stack
199
+ });
200
+
201
+ error.statusCode = error.statusCode || 500;
202
+ throw error;
203
+ }
204
+
205
+ return {
206
+ html,
207
+ nonce,
208
+ meta: mod.Meta ?? null,
209
+ props: collectedProps
210
+ };
211
+ }
212
+
213
+ static #parseReactError(error) {
214
+ const result = {
215
+ cause: null,
216
+ location: null
217
+ };
218
+
219
+ const stack = error.stack || "";
220
+
221
+ const undefinedMatch = error.message.match(/Cannot read propert(?:y|ies) of (undefined|null)(?: \(reading ['"](.+)['"]\))?/);
222
+ if (undefinedMatch) {
223
+ result.cause = undefinedMatch[2]
224
+ ? `Tried to access "${undefinedMatch[2]}" on ${undefinedMatch[1]}`
225
+ : `Accessed property on ${undefinedMatch[1]}`;
226
+ }
227
+
228
+ const notFunctionMatch = error.message.match(/(.+) is not a function/);
229
+ if (notFunctionMatch) {
230
+ result.cause = `"${notFunctionMatch[1]}" is not callable`;
231
+ }
232
+
233
+ const notDefinedMatch = error.message.match(/(.+) is not defined/);
234
+ if (notDefinedMatch) {
235
+ result.cause = `Variable "${notDefinedMatch[1]}" does not exist`;
236
+ }
237
+
238
+ const componentMatch = stack.match(/at (\w+) \(.*?resources[/\\]views[/\\](.+?\.tsx)(?::(\d+):(\d+))?\)/);
239
+ if (componentMatch) {
240
+ result.location = {
241
+ component: componentMatch[1],
242
+ file: componentMatch[2].replace(/\\/g, "/"),
243
+ line: componentMatch[3] || "?",
244
+ column: componentMatch[4] || "?"
245
+ };
246
+ }
247
+
248
+ return result;
249
+ }
250
+
251
+ static #sanitizeProps(obj, seen = new WeakSet()) {
252
+ if (obj == null) {
253
+ return obj;
254
+ }
255
+
256
+ const type = typeof obj;
257
+
258
+ if (type === "function" || type === "symbol") {
259
+ return undefined;
260
+ }
261
+
262
+ if (type === "bigint") {
263
+ return obj.toString();
264
+ }
265
+
266
+ if (type !== "object") {
267
+ return obj;
268
+ }
269
+
270
+ if (seen.has(obj)) {
271
+ return undefined;
272
+ }
273
+ seen.add(obj);
274
+
275
+ if (Array.isArray(obj)) {
276
+ return obj.map(item => this.#sanitizeProps(item, seen) ?? null);
277
+ }
278
+
279
+ if (obj instanceof Date) {
280
+ return obj.toISOString();
281
+ }
282
+
283
+ if (obj._attributes && typeof obj._attributes === "object") {
284
+ return this.#sanitizeProps(obj._attributes, seen);
285
+ }
286
+
287
+ if (typeof obj.toJSON === "function") {
288
+ return this.#sanitizeProps(obj.toJSON(), seen);
289
+ }
290
+
291
+ const proto = Object.getPrototypeOf(obj);
292
+ if (proto !== Object.prototype && proto !== null) {
293
+ return undefined;
294
+ }
295
+
296
+ const result = {};
297
+ for (const key of Object.keys(obj)) {
298
+ if (UNSAFE_KEYS.has(key)) {
299
+ continue;
300
+ }
301
+ const value = this.#sanitizeProps(obj[key], seen);
302
+ if (value !== undefined) {
303
+ result[key] = value;
304
+ }
305
+ }
306
+ return result;
307
+ }
308
+
309
+ static #renderToHtml(element) {
310
+ return new Promise((resolve, reject) => {
311
+ const chunks = [];
312
+ const stream = new PassThrough();
313
+ let done = false;
314
+ let renderError = null;
315
+
316
+ const finish = (result) => {
317
+ if (!done) {
318
+ done = true;
319
+ clearTimeout(timer);
320
+
321
+ if (renderError) {
322
+ reject(renderError);
323
+ } else {
324
+ resolve(result);
325
+ }
326
+ }
327
+ };
328
+
329
+ const timeout = this.#isDev ? 10000 : 3000;
330
+ const timer = setTimeout(() => {
331
+ const error = new Error("Render timeout - component took too long to render");
332
+ error.statusCode = 500;
333
+ renderError = error;
334
+ finish(null);
335
+ }, timeout);
336
+
337
+ stream.on("data", chunk => chunks.push(chunk));
338
+ stream.on("end", () => finish(Buffer.concat(chunks).toString("utf-8")));
339
+ stream.on("error", (error) => {
340
+ renderError = error;
341
+ finish(null);
342
+ });
343
+
344
+ try {
345
+ const { pipe } = renderToPipeableStream(element, {
346
+ onShellReady() {
347
+ pipe(stream);
348
+ },
349
+ onError: (error) => {
350
+ renderError = error;
351
+ finish(null);
352
+ }
353
+ });
354
+ } catch (error) {
355
+ renderError = error;
356
+ finish(null);
357
+ }
358
+ });
359
+ }
360
+
361
+ static async #renderError(res, statusCode, viewName, params) {
362
+ try {
363
+ const entry = this.#getEntry("framework", viewName);
364
+ const { html, nonce, meta, props } = await this.#render(
365
+ this.#frameworkViews,
366
+ viewName,
367
+ params,
368
+ ""
369
+ );
370
+
371
+ this.#setSecurityHeaders(res, nonce);
372
+
373
+ return res
374
+ .code(statusCode)
375
+ .type("text/html")
376
+ .send(this.#buildPage(entry, html, nonce, "", meta, props));
377
+ } catch (error) {
378
+ Log.error(`Failed to render ${viewName}`, { error: error.message });
379
+ const message = statusCode === 404 ? "Not Found" : "Internal Server Error";
380
+ return res.code(statusCode).send(message);
381
+ }
382
+ }
383
+
384
+ static #buildPage(entry, html, nonce, csrf = "", meta = null, props = {}) {
385
+ let processedMeta = meta || {};
386
+
387
+ if (meta?.title && typeof meta.title === "object") {
388
+ processedMeta = {
389
+ ...meta,
390
+ title: meta.title.default || "NitronJS"
391
+ };
392
+ }
393
+
394
+ const cssFiles = (entry?.css || []).map(href => `/storage${href}`);
395
+
396
+ return this.#generateHtml({
397
+ html,
398
+ meta: processedMeta,
399
+ css: cssFiles,
400
+ hydrationScript: entry?.hydrationScript,
401
+ nonce,
402
+ csrf,
403
+ props
404
+ });
405
+ }
406
+
407
+ static #getUsedRoutes(script) {
408
+ if (!script) {
409
+ return [];
410
+ }
411
+
412
+ if (this.#isDev) {
413
+ this.#routesCache = new Map();
414
+ this.#scanBundles();
415
+ } else if (!this.#routesCache) {
416
+ this.#routesCache = new Map();
417
+ this.#scanBundles();
418
+ }
419
+
420
+ return this.#routesCache.get(script) || [];
421
+ }
422
+
423
+ static #scanBundles() {
424
+ const jsDir = path.join(this.#root, "storage/app/public/js");
425
+
426
+ if (!existsSync(jsDir)) {
427
+ return;
428
+ }
429
+
430
+ const scan = (dir) => {
431
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
432
+ const fullPath = path.join(dir, entry.name);
433
+
434
+ if (entry.isDirectory()) {
435
+ scan(fullPath);
436
+ continue;
437
+ }
438
+
439
+ if (!entry.name.endsWith(".js") || entry.name === "vendor.js") {
440
+ continue;
441
+ }
442
+
443
+ const content = readFileSync(fullPath, "utf8");
444
+ const matches = [...content.matchAll(/routes\["([^"]+)"\]/g)];
445
+ const routes = matches.map(match => match[1]);
446
+ const relativePath = "/js/" + path.relative(jsDir, fullPath).replace(/\\/g, "/");
447
+
448
+ this.#routesCache.set(relativePath, [...new Set(routes)]);
449
+ }
450
+ };
451
+
452
+ scan(jsDir);
453
+ }
454
+
455
+ static #generateHtml({ html, meta, css, hydrationScript, nonce, csrf, props }) {
456
+ const nonceAttr = nonce ? ` nonce="${escapeHtml(nonce)}"` : "";
457
+
458
+ let runtimeScript = "";
459
+ if (hydrationScript) {
460
+ const allRoutes = Route.getClientManifest();
461
+ const usedRouteNames = this.#getUsedRoutes(hydrationScript);
462
+ const routes = {};
463
+
464
+ for (const routeName of usedRouteNames) {
465
+ if (allRoutes[routeName]) {
466
+ routes[routeName] = allRoutes[routeName];
467
+ }
468
+ }
469
+
470
+ const propsJson = JSON.stringify(props || {})
471
+ .replace(/</g, "\\u003c")
472
+ .replace(/>/g, "\\u003e");
473
+
474
+ const runtimeData = JSON.stringify({
475
+ csrf: csrf || "",
476
+ routes
477
+ });
478
+
479
+ runtimeScript = `<script${nonceAttr}>window.__NITRON_RUNTIME__=${runtimeData};window.__NITRON_PROPS__=${propsJson};</script>`;
480
+ }
481
+
482
+ const vendorScript = hydrationScript
483
+ ? `<script src="/storage/js/vendor.js"${nonceAttr}></script>`
484
+ : "";
485
+
486
+ const hydrateScript = hydrationScript
487
+ ? `<script type="module" src="/storage${hydrationScript}"${nonceAttr}></script>`
488
+ : "";
489
+
490
+ return `<!DOCTYPE html>
491
+ <html lang="en">
492
+ <head>
493
+ ${this.#generateHead(meta, css)}
494
+ </head>
495
+ <body>
496
+ <div id="app">${html}</div>
497
+ ${runtimeScript}${vendorScript}${hydrateScript}
498
+ </body>
499
+ </html>`;
500
+ }
501
+
502
+ static #generateHead(meta = {}, css = []) {
503
+ const parts = [
504
+ '<meta charset="UTF-8">',
505
+ '<meta name="viewport" content="width=device-width, initial-scale=1.0">',
506
+ `<title>${escapeHtml(meta.title || "NitronJS")}</title>`
507
+ ];
508
+
509
+ if (meta.description) {
510
+ parts.push(`<meta name="description" content="${escapeHtml(meta.description)}">`);
511
+ }
512
+
513
+ for (const href of css) {
514
+ parts.push(`<link rel="stylesheet" href="${escapeHtml(href)}">`);
515
+ }
516
+
517
+ return parts.join("\n");
518
+ }
519
+
520
+ static #setSecurityHeaders(res, nonce) {
521
+ const connectSrc = this.#isDev ? "'self' ws: wss:" : "'self'";
522
+
523
+ const csp = [
524
+ "default-src 'self'",
525
+ `script-src 'self' 'nonce-${nonce}'`,
526
+ "style-src 'self' 'unsafe-inline'",
527
+ "img-src 'self' data: blob:",
528
+ "font-src 'self'",
529
+ `connect-src ${connectSrc}`,
530
+ "frame-ancestors 'self'",
531
+ "base-uri 'self'",
532
+ "form-action 'self'"
533
+ ].join("; ");
534
+
535
+ res.header("Content-Security-Policy", csp);
536
+ res.header("X-Content-Type-Options", "nosniff");
537
+ res.header("X-Frame-Options", "DENY");
538
+ res.header("X-XSS-Protection", "0");
539
+ res.header("Referrer-Policy", "strict-origin-when-cross-origin");
540
+ res.header("Permissions-Policy", "geolocation=(), microphone=(), camera=()");
541
+ }
542
+ }
543
+
544
+ export default View;