@nitronjs/framework 0.2.12 → 0.2.14

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/lib/Auth/Auth.js CHANGED
@@ -82,6 +82,7 @@ class Auth {
82
82
  const guard = guardName || authConfig.defaults.guard;
83
83
 
84
84
  req.session.set(`auth_${guard}`, null);
85
+ req.session.regenerate();
85
86
  }
86
87
 
87
88
  /**
@@ -239,7 +239,7 @@ class FileAnalyzer {
239
239
  if (declaration?.type === "VariableDeclaration") {
240
240
  for (const decl of declaration.declarations) {
241
241
  if (decl.id.type === "Identifier") {
242
- if (decl.id.name === "layout" &&
242
+ if (decl.id.name === "Layout" &&
243
243
  decl.init?.type === "BooleanLiteral" &&
244
244
  decl.init.value === false) {
245
245
  meta.layoutDisabled = true;
@@ -116,8 +116,11 @@ class Builder {
116
116
  };
117
117
  } catch (error) {
118
118
  this.#cleanupTemp();
119
- if (!silent) console.log(`\n${COLORS.red}✖ Build failed: ${error.message}${COLORS.reset}\n`);
120
- if (this.#isDev && !silent) console.error(error);
119
+
120
+ if (!silent) {
121
+ console.log(this.#formatBuildError(error));
122
+ }
123
+
121
124
  return { success: false, error: error.message };
122
125
  }
123
126
  }
@@ -580,6 +583,63 @@ class Builder {
580
583
  }
581
584
  }
582
585
 
586
+ #formatBuildError(error) {
587
+ const R = COLORS.red, Y = COLORS.yellow, D = COLORS.dim, C = COLORS.reset;
588
+
589
+ // FileAnalyzer errors already have formatted messages
590
+ if (error.message?.includes("✖")) {
591
+ return `\n${error.message}\n`;
592
+ }
593
+
594
+ // esbuild errors — extract detailed info from .errors array
595
+ if (error.errors?.length > 0) {
596
+ let output = `\n${R}✖ Build failed${C}\n`;
597
+
598
+ for (const err of error.errors.slice(0, 5)) {
599
+ const loc = err.location;
600
+ output += `\n ${R}${err.text}${C}\n`;
601
+
602
+ if (loc) {
603
+ const file = loc.file ? path.relative(Paths.project, loc.file) : "unknown";
604
+ output += ` ${D}at${C} ${Y}${file}:${loc.line}:${loc.column}${C}\n`;
605
+
606
+ if (loc.lineText) {
607
+ output += ` ${D}${loc.lineText.trim()}${C}\n`;
608
+ }
609
+ }
610
+
611
+ if (err.notes?.length > 0) {
612
+ for (const note of err.notes) {
613
+ output += ` ${D}→ ${note.text}${C}\n`;
614
+ }
615
+ }
616
+ }
617
+
618
+ if (error.errors.length > 5) {
619
+ output += `\n ${D}... and ${error.errors.length - 5} more errors${C}\n`;
620
+ }
621
+
622
+ return output;
623
+ }
624
+
625
+ // Generic errors — provide hint based on common patterns
626
+ const msg = error.message || "Unknown error";
627
+ let output = `\n${R}✖ Build failed${C}\n\n ${msg}\n`;
628
+
629
+ if (msg.includes("ENOENT")) {
630
+ output += `\n ${Y}Hint:${C} A file or directory was not found. Check your import paths.\n`;
631
+ }
632
+ else if (msg.includes("Duplicate")) {
633
+ output += `\n ${Y}Hint:${C} Two views resolve to the same route. Rename one of them.\n`;
634
+ }
635
+ else if (msg.includes("Cannot find module")) {
636
+ const match = msg.match(/Cannot find module ['"]([^'"]+)['"]/);
637
+ if (match) output += `\n ${Y}Hint:${C} Module "${match[1]}" is not installed. Run npm install.\n`;
638
+ }
639
+
640
+ return output;
641
+ }
642
+
583
643
  #cleanupTemp() {
584
644
  const projectDir = path.normalize(Paths.project) + path.sep;
585
645
  const normalizedTemp = path.normalize(this.#paths.nitronTemp);
@@ -35,7 +35,7 @@ globalThis.csrf = () => getContext()?.csrf || '';
35
35
 
36
36
  globalThis.request = () => {
37
37
  const ctx = getContext();
38
- return ctx?.request || { params: {}, query: {}, url: '', method: 'GET', headers: {} };
38
+ return ctx?.request || { path: '', method: 'GET', query: {}, params: {}, headers: {}, cookies: {}, ip: '', isAjax: false, session: null, auth: null };
39
39
  };
40
40
 
41
41
  const DepthContext = React.createContext(false);
@@ -162,7 +162,8 @@ export function createServerModuleBlockerPlugin() {
162
162
 
163
163
  /**
164
164
  * Creates an esbuild plugin that transforms server functions for client use.
165
- * Replaces csrf() and route() calls with runtime lookups.
165
+ * Replaces csrf() calls with runtime lookups.
166
+ * Note: route() is now a global function defined in spa.js
166
167
  * @returns {import("esbuild").Plugin}
167
168
  */
168
169
  export function createServerFunctionsPlugin() {
@@ -176,7 +177,7 @@ export function createServerFunctionsPlugin() {
176
177
 
177
178
  let source = await fs.promises.readFile(args.path, "utf8");
178
179
 
179
- if (!source.includes("csrf(") && !source.includes("route(")) {
180
+ if (!source.includes("csrf(")) {
180
181
  return null;
181
182
  }
182
183
 
@@ -185,11 +186,6 @@ export function createServerFunctionsPlugin() {
185
186
  "window.__NITRON_RUNTIME__.csrf"
186
187
  );
187
188
 
188
- source = source.replace(
189
- /\broute\s*\(\s*['"]([^'"]+)['"]\s*\)/g,
190
- (_, routeName) => `window.__NITRON_RUNTIME__.routes["${routeName}"]`
191
- );
192
-
193
189
  const ext = args.path.split(".").pop();
194
190
  const loader = ext === "tsx" ? "tsx" : ext === "ts" ? "ts" : ext === "jsx" ? "jsx" : "js";
195
191
 
@@ -1,6 +1,7 @@
1
1
  import dotenv from 'dotenv';
2
2
  import DB from '../../Database/DB.js';
3
3
  import Config from '../../Core/Config.js';
4
+ import Environment from '../../Core/Environment.js';
4
5
  import MigrationRunner from '../../Database/Migration/MigrationRunner.js';
5
6
  import seed from './SeedCommand.js';
6
7
 
@@ -8,6 +9,7 @@ export default async function migrate(options = {}) {
8
9
  const { seed: shouldSeed = false } = options;
9
10
 
10
11
  dotenv.config({ quiet: true });
12
+ Environment.setDev(true);
11
13
  await Config.initialize();
12
14
 
13
15
  try {
@@ -1,6 +1,7 @@
1
1
  import dotenv from 'dotenv';
2
2
  import DB from '../../Database/DB.js';
3
3
  import Config from '../../Core/Config.js';
4
+ import Environment from '../../Core/Environment.js';
4
5
  import MigrationRunner from '../../Database/Migration/MigrationRunner.js';
5
6
  import Output from '../../Console/Output.js';
6
7
  import seed from './SeedCommand.js';
@@ -9,6 +10,7 @@ export default async function migrateFresh(options = {}) {
9
10
  const { seed: shouldSeed = false } = options;
10
11
 
11
12
  dotenv.config({ quiet: true });
13
+ Environment.setDev(true);
12
14
  await Config.initialize();
13
15
 
14
16
  try {
@@ -1,6 +1,7 @@
1
1
  import dotenv from 'dotenv';
2
2
  import DB from '../../Database/DB.js';
3
3
  import Config from '../../Core/Config.js';
4
+ import Environment from '../../Core/Environment.js';
4
5
  import MigrationRunner from '../../Database/Migration/MigrationRunner.js';
5
6
  import Output from '../../Console/Output.js';
6
7
 
@@ -8,6 +9,7 @@ export default async function rollback(options = {}) {
8
9
  const { step = 1, all = false } = options;
9
10
 
10
11
  dotenv.config({ quiet: true });
12
+ Environment.setDev(true);
11
13
  await Config.initialize();
12
14
 
13
15
  try {
@@ -1,11 +1,13 @@
1
1
  import dotenv from 'dotenv';
2
2
  import DB from '../../Database/DB.js';
3
3
  import Config from '../../Core/Config.js';
4
+ import Environment from '../../Core/Environment.js';
4
5
  import MigrationRunner from '../../Database/Migration/MigrationRunner.js';
5
6
  import Output from '../../Console/Output.js';
6
7
 
7
8
  export default async function status() {
8
9
  dotenv.config({ quiet: true });
10
+ Environment.setDev(true);
9
11
  await Config.initialize();
10
12
 
11
13
  try {
@@ -1,10 +1,12 @@
1
1
  import dotenv from 'dotenv';
2
2
  import DB from '../../Database/DB.js';
3
3
  import Config from '../../Core/Config.js';
4
+ import Environment from '../../Core/Environment.js';
4
5
  import SeederRunner from '../../Database/Seeder/SeederRunner.js';
5
6
 
6
7
  export default async function seed(seederName = null) {
7
8
  dotenv.config({ quiet: true });
9
+ Environment.setDev(true);
8
10
  await Config.initialize();
9
11
 
10
12
  try {
@@ -154,13 +154,17 @@ class MySQLDriver {
154
154
  #handleError(error, sql, bindings = null) {
155
155
  if (Environment.isProd) {
156
156
  console.error("[MySQLDriver] Query failed", {
157
+ message: error.message,
157
158
  code: error.code,
158
159
  errno: error.errno,
159
- sqlState: error.sqlState
160
+ sqlState: error.sqlState,
161
+ sql: sql?.substring(0, 200)
160
162
  });
161
163
 
162
164
  const sanitized = new Error("Database query failed");
163
165
  sanitized.code = error.code;
166
+ sanitized.errno = error.errno;
167
+ sanitized.sqlState = error.sqlState;
164
168
  sanitized.driver = "mysql";
165
169
 
166
170
  return sanitized;
@@ -50,6 +50,8 @@ class MigrationRunner {
50
50
  Output.frameworkMigration('done', file);
51
51
  }
52
52
 
53
+ Output.newline();
54
+
53
55
  return { success: true, ran };
54
56
  }
55
57
 
@@ -247,4 +247,24 @@ function hydrate(modelClass, row) {
247
247
  return instance;
248
248
  }
249
249
 
250
+ // ─────────────────────────────────────────────────────────────────────────────
251
+ // QueryBuilder Method Forwarding
252
+ // Automatically forward all QueryBuilder methods to Model for fluent chaining
253
+ // ─────────────────────────────────────────────────────────────────────────────
254
+
255
+ const QUERY_METHODS = [
256
+ // Chain methods (return QueryBuilder)
257
+ 'distinct', 'orWhere', 'whereIn', 'whereNotIn',
258
+ 'whereBetween', 'whereNot', 'join', 'groupBy', 'offset',
259
+ // Terminal methods (return results)
260
+ 'count', 'max', 'min', 'sum', 'avg', 'delete'
261
+ ];
262
+
263
+ for (const method of QUERY_METHODS) {
264
+ Model[method] = function(...args) {
265
+ ensureTable(this);
266
+ return DB.table(this.table, null, this)[method](...args);
267
+ };
268
+ }
269
+
250
270
  export default Model;
@@ -135,8 +135,10 @@ class QueryBuilder {
135
135
  message: error.message,
136
136
  code: error.code,
137
137
  errno: error.errno,
138
- sql: error.sql?.substring(0, 100),
139
- sqlState: error.sqlState
138
+ sqlState: error.sqlState,
139
+ sql: error.sql?.substring(0, 200),
140
+ bindings: error.bindings,
141
+ driver: error.driver
140
142
  });
141
143
 
142
144
  const sanitized = new Error('Database query failed. Please contact support if the problem persists.');
@@ -297,24 +299,14 @@ class QueryBuilder {
297
299
  return this;
298
300
  }
299
301
 
300
- whereNull(column) {
301
- this.#wheres.push({
302
- type: 'null',
303
- column: this.#validateIdentifier(column),
304
- boolean: 'AND'
305
- });
306
-
307
- return this;
308
- }
309
-
310
- whereNotNull(column) {
311
- this.#wheres.push({
312
- type: 'notNull',
313
- column: this.#validateIdentifier(column),
314
- boolean: 'AND'
315
- });
316
-
317
- return this;
302
+ /**
303
+ * Add a WHERE NOT (!=) clause.
304
+ * @param {string} column - Column name
305
+ * @param {*} value - Value to compare
306
+ * @returns {QueryBuilder}
307
+ */
308
+ whereNot(column, value) {
309
+ return this.where(column, '!=', value);
318
310
  }
319
311
 
320
312
  whereBetween(column, values) {
@@ -334,22 +326,6 @@ class QueryBuilder {
334
326
  return this;
335
327
  }
336
328
 
337
- whereRaw(sql, bindings = []) {
338
- if (typeof sql !== 'string' || sql.length === 0) {
339
- throw new Error('whereRaw requires non-empty SQL string');
340
- }
341
-
342
- this.#wheres.push({
343
- type: 'raw',
344
- sql,
345
- boolean: 'AND'
346
- });
347
-
348
- this.#bindings.push(...bindings);
349
-
350
- return this;
351
- }
352
-
353
329
  join(table, first, operator, second, type = 'inner') {
354
330
  if (arguments.length === 3) {
355
331
  second = operator;
@@ -370,14 +346,6 @@ class QueryBuilder {
370
346
  return this;
371
347
  }
372
348
 
373
- leftJoin(table, first, operator, second) {
374
- return this.join(table, first, operator, second, 'left');
375
- }
376
-
377
- rightJoin(table, first, operator, second) {
378
- return this.join(table, first, operator, second, 'right');
379
- }
380
-
381
349
  orderBy(column, direction = 'ASC') {
382
350
  this.#orders.push({
383
351
  column: this.#validateIdentifier(column),
@@ -394,25 +362,6 @@ class QueryBuilder {
394
362
  return this;
395
363
  }
396
364
 
397
- having(column, operator, value) {
398
- if (arguments.length === 2) {
399
- value = operator;
400
- operator = '=';
401
- }
402
-
403
- operator = this.#validateWhereOperator(operator);
404
-
405
- this.#havings.push({
406
- column: this.#validateIdentifier(column),
407
- operator,
408
- value
409
- });
410
- this.#bindings.push(value);
411
-
412
-
413
- return this;
414
- }
415
-
416
365
  limit(value) {
417
366
  this.#limitValue = this.#validateInteger(value);
418
367
 
@@ -425,10 +374,6 @@ class QueryBuilder {
425
374
  return this;
426
375
  }
427
376
 
428
- forPage(page, perPage = 15) {
429
- return this.offset((page - 1) * perPage).limit(perPage);
430
- }
431
-
432
377
  async get() {
433
378
  const connection = await this.#getConnection();
434
379
 
@@ -740,13 +685,6 @@ class QueryBuilder {
740
685
  return sql;
741
686
  }
742
687
 
743
- toSql() {
744
- return this.#toSql();
745
- }
746
-
747
- getBindings() {
748
- return [...this.#bindings];
749
- }
750
688
  }
751
689
 
752
690
  export class RawExpression {
@@ -21,7 +21,14 @@ class DateTime {
21
21
  static #format(date, formatString) {
22
22
  const lang = locale[Config.get('app.locale', 'en')] || locale.en;
23
23
  const pad = (n) => String(n).padStart(2, '0');
24
-
24
+
25
+ // Replace multi-character tokens first to avoid per-character replacement
26
+ let result = formatString;
27
+ result = result.replace(/MMMM/g, '\x01MONTH\x01');
28
+ result = result.replace(/MMM/g, '\x01MONTHSHORT\x01');
29
+ result = result.replace(/DDDD/g, '\x01DAY\x01');
30
+ result = result.replace(/DDD/g, '\x01DAYSHORT\x01');
31
+
25
32
  const map = {
26
33
  'Y': date.getFullYear(),
27
34
  'y': String(date.getFullYear()).slice(-2),
@@ -37,8 +44,16 @@ class DateTime {
37
44
  'M': lang.months[date.getMonth()],
38
45
  'F': lang.monthsShort[date.getMonth()]
39
46
  };
40
-
41
- return formatString.replace(/[YynmdjHislDMF]/g, match => map[match]);
47
+
48
+ result = result.replace(/[YynmdjHislDMF]/g, match => map[match]);
49
+
50
+ // Restore multi-character tokens
51
+ result = result.replace(/\x01MONTH\x01/g, lang.months[date.getMonth()]);
52
+ result = result.replace(/\x01MONTHSHORT\x01/g, lang.monthsShort[date.getMonth()]);
53
+ result = result.replace(/\x01DAY\x01/g, lang.days[date.getDay()]);
54
+ result = result.replace(/\x01DAYSHORT\x01/g, lang.daysShort[date.getDay()]);
55
+
56
+ return result;
42
57
  }
43
58
 
44
59
  /**
@@ -65,6 +65,25 @@ class Storage {
65
65
  await fs.promises.unlink(fullPath);
66
66
  }
67
67
 
68
+ /**
69
+ * Moves or renames a file in storage.
70
+ * @param {string} fromPath - Current relative path to the file
71
+ * @param {string} toPath - New relative path for the file
72
+ * @param {boolean} isPrivate - Whether to operate on private storage
73
+ * @returns {Promise<void>}
74
+ */
75
+ static async move(fromPath, toPath, isPrivate = false) {
76
+ const base = isPrivate ? this.#privateRoot : this.#publicRoot;
77
+ const fullFromPath = this.#validatePath(base, fromPath);
78
+ const fullToPath = this.#validatePath(base, toPath);
79
+
80
+ // Ensure target directory exists
81
+ const targetDir = path.dirname(fullToPath);
82
+ await fs.promises.mkdir(targetDir, { recursive: true });
83
+
84
+ await fs.promises.rename(fullFromPath, fullToPath);
85
+ }
86
+
68
87
  /**
69
88
  * Checks if a file exists in storage.
70
89
  * @param {string} filePath - Relative path to the file
@@ -107,10 +107,47 @@ class Server {
107
107
 
108
108
  this.#server.register(fastifyStatic, {
109
109
  root: Paths.public,
110
- decorateReply: false,
111
- index: false
110
+ decorateReply: true,
111
+ index: false,
112
+ wildcard: false
112
113
  });
113
114
 
115
+ // Register explicit routes for each public subdirectory.
116
+ // Routes like /storage/* have a static prefix and take priority over
117
+ // parametric routes (/:slug), preventing catch-all routes from
118
+ // intercepting static file requests.
119
+ if (fs.existsSync(Paths.public)) {
120
+ for (const entry of fs.readdirSync(Paths.public)) {
121
+ const fullPath = path.resolve(Paths.public, entry);
122
+
123
+ try {
124
+ if (!fs.statSync(fullPath).isDirectory()) continue;
125
+ }
126
+ catch {
127
+ continue;
128
+ }
129
+
130
+ this.#server.route({
131
+ method: ["GET", "HEAD"],
132
+ url: `/${entry}/*`,
133
+ handler(req, reply) {
134
+ return reply.sendFile(req.params["*"], fullPath);
135
+ }
136
+ });
137
+ }
138
+
139
+ // Fallback wildcard for root-level files (favicon.ico, robots.txt, etc.)
140
+ // Less specific than /:slug so it loses when parametric routes exist,
141
+ // but keeps backward compatibility for projects without catch-all routes.
142
+ this.#server.route({
143
+ method: ["GET", "HEAD"],
144
+ url: "/*",
145
+ handler(req, reply) {
146
+ return reply.sendFile(req.params["*"]);
147
+ }
148
+ });
149
+ }
150
+
114
151
  this.#server.register(fastifyCookie, {
115
152
  secret: process.env.APP_KEY,
116
153
  hook: "onRequest"
@@ -171,6 +208,8 @@ class Server {
171
208
  for (const [key, field] of Object.entries(req.body)) {
172
209
  if (field?.toBuffer) {
173
210
  if (field.filename) {
211
+ const lastDot = field.filename.lastIndexOf(".");
212
+ field.extension = lastDot > 0 ? field.filename.slice(lastDot + 1).toLowerCase() : "";
174
213
  files[key] = field;
175
214
  }
176
215
  }
@@ -27,7 +27,7 @@ class FileStore {
27
27
  }
28
28
 
29
29
  try {
30
- const content = await fs.readFile(this.#filePath(sessionId), "utf8");
30
+ const content = await this.#retryOnLock(() => fs.readFile(this.#filePath(sessionId), "utf8"));
31
31
 
32
32
  if (!content?.trim()) {
33
33
  await this.delete(sessionId);
@@ -48,6 +48,8 @@ class FileStore {
48
48
  return null;
49
49
  }
50
50
 
51
+ console.error("[Session File] Read error:", err.message);
52
+
51
53
  return null;
52
54
  }
53
55
  }
@@ -67,10 +69,19 @@ class FileStore {
67
69
  const json = Environment.isDev ? JSON.stringify(data, null, 2) : JSON.stringify(data);
68
70
 
69
71
  try {
70
- await fs.writeFile(tempPath, json, "utf8");
72
+ await this.#retryOnLock(() => fs.writeFile(tempPath, json, "utf8"));
71
73
  await this.#atomicRename(tempPath, filePath);
72
74
  }
73
- catch {
75
+ catch (err) {
76
+ // Atomic rename failed — write directly as fallback to prevent session loss
77
+ try {
78
+ await this.#retryOnLock(() => fs.writeFile(filePath, json, "utf8"));
79
+ }
80
+ catch (writeErr) {
81
+ console.error("[Session File] Write fallback failed:", writeErr.message);
82
+ throw writeErr;
83
+ }
84
+
74
85
  await fs.unlink(tempPath).catch(() => {});
75
86
  }
76
87
  }
@@ -85,12 +96,12 @@ class FileStore {
85
96
  }
86
97
 
87
98
  try {
88
- await fs.unlink(this.#filePath(sessionId));
99
+ await this.#retryOnLock(() => fs.unlink(this.#filePath(sessionId)));
89
100
  }
90
101
  catch (err) {
91
- if (err.code !== "ENOENT") {
92
- console.error("[Session File] Delete error:", err.message);
93
- }
102
+ if (err.code === "ENOENT") return;
103
+
104
+ console.error("[Session File] Delete error:", err.message);
94
105
  }
95
106
  }
96
107
 
@@ -118,16 +129,24 @@ class FileStore {
118
129
  const lastActivity = data.lastActivity || data.createdAt;
119
130
 
120
131
  if (now - lastActivity > lifetime) {
121
- await fs.unlink(filePath);
132
+ await fs.unlink(filePath).catch(() => {});
122
133
  deleted++;
123
134
  }
124
135
  }
125
136
  catch {
137
+ // Corrupted or unreadable — try to clean up, skip if locked
126
138
  await fs.unlink(filePath).catch(() => {});
127
139
  deleted++;
128
140
  }
129
141
  }
130
142
 
143
+ // Clean leftover temp files
144
+ for (const file of files) {
145
+ if (file.endsWith(".tmp")) {
146
+ await fs.unlink(path.join(this.#storagePath, file)).catch(() => {});
147
+ }
148
+ }
149
+
131
150
  return deleted;
132
151
  }
133
152
  catch (err) {
@@ -198,15 +217,41 @@ class FileStore {
198
217
  await fs.rename(from, to);
199
218
  }
200
219
  catch (err) {
201
- if (err.code === "EPERM" || err.code === "EEXIST") {
202
- await fs.unlink(to).catch(() => {});
203
- await fs.rename(from, to);
220
+ if (err.code === "EPERM" || err.code === "EBUSY" || err.code === "EEXIST") {
221
+ await this.#retryOnLock(() => fs.copyFile(from, to));
222
+ await fs.unlink(from).catch(() => {});
204
223
  }
205
224
  else {
206
225
  throw err;
207
226
  }
208
227
  }
209
228
  }
229
+
230
+ /**
231
+ * Retries a file operation with exponential backoff on Windows lock errors.
232
+ * @private
233
+ * @param {Function} fn - Async function to retry
234
+ * @param {number} maxRetries - Maximum retry attempts (default: 4)
235
+ * @returns {Promise<*>}
236
+ */
237
+ async #retryOnLock(fn, maxRetries = 4) {
238
+ let delay = 50;
239
+
240
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
241
+ try {
242
+ return await fn();
243
+ }
244
+ catch (err) {
245
+ if ((err.code === "EBUSY" || err.code === "EPERM") && attempt < maxRetries) {
246
+ await new Promise(r => setTimeout(r, delay));
247
+ delay *= 2;
248
+ continue;
249
+ }
250
+
251
+ throw err;
252
+ }
253
+ }
254
+ }
210
255
  }
211
256
 
212
257
  export default FileStore;
@@ -179,23 +179,21 @@ class SessionManager {
179
179
  });
180
180
 
181
181
  server.addHook("onSend", async (request, response, payload) => {
182
- if (request.session?.shouldRegenerate()) {
183
- response.setCookie(manager.cookieName, request.session.id, manager.cookieConfig);
184
- }
182
+ if (!request.session) return payload;
185
183
 
186
- return payload;
187
- });
184
+ try {
185
+ await manager.persist(request.session);
188
186
 
189
- server.addHook("onResponse", async (request, response) => {
190
- if (!request.session || response.statusCode >= 400) {
191
- return;
187
+ if (request.session.shouldRegenerate()) {
188
+ response.setCookie(manager.cookieName, request.session.id, manager.cookieConfig);
189
+ await manager.deleteOld(request.session.getOldId());
190
+ }
192
191
  }
193
-
194
- if (request.session.shouldRegenerate()) {
195
- await manager.deleteOld(request.session.getOldId());
192
+ catch (err) {
193
+ console.error("[Session] Persist error:", err.message);
196
194
  }
197
195
 
198
- await manager.persist(request.session);
196
+ return payload;
199
197
  });
200
198
 
201
199
  manager.#startGC();
@@ -17,6 +17,10 @@ class Session {
17
17
  this.#data = sessionData.data || {};
18
18
  this.#createdAt = sessionData.createdAt || Date.now();
19
19
  this.#lastActivity = sessionData.lastActivity || null;
20
+
21
+ // Age flash data: previous request's flash becomes readable, then expires.
22
+ this.#data._previousFlash = this.#data._flash || {};
23
+ this.#data._flash = {};
20
24
  }
21
25
 
22
26
  get id() {
@@ -50,11 +54,14 @@ class Session {
50
54
  }
51
55
 
52
56
  /**
53
- * Gets all session data (copy).
57
+ * Gets all session data for persistence.
58
+ * Excludes aged flash data that should not survive another request.
54
59
  * @returns {Object}
55
60
  */
56
61
  all() {
57
- return { ...this.#data };
62
+ const { _previousFlash, ...rest } = this.#data;
63
+
64
+ return rest;
58
65
  }
59
66
 
60
67
  /**
@@ -100,32 +107,21 @@ class Session {
100
107
  }
101
108
 
102
109
  /**
103
- * Stores a flash message (available only for next request).
110
+ * Stores a flash message (available only for the next request).
104
111
  * @param {string} key
105
112
  * @param {*} value
106
113
  */
107
114
  flash(key, value) {
108
- if (!this.#data._flash) {
109
- this.#data._flash = {};
110
- }
111
-
112
115
  this.#data._flash[key] = value;
113
116
  }
114
117
 
115
118
  /**
116
- * Gets and removes a flash message.
119
+ * Gets a flash message from the previous request.
117
120
  * @param {string} key
118
121
  * @returns {*}
119
122
  */
120
123
  getFlash(key) {
121
- if (!this.#data._flash?.[key]) {
122
- return undefined;
123
- }
124
-
125
- const value = this.#data._flash[key];
126
- delete this.#data._flash[key];
127
-
128
- return value;
124
+ return this.#data._previousFlash?.[key];
129
125
  }
130
126
 
131
127
  /**
@@ -6,6 +6,28 @@
6
6
  var layouts = [];
7
7
  var navigating = false;
8
8
 
9
+ // Route helper function for client-side
10
+ globalThis.route = function(name, params) {
11
+ var runtime = window.__NITRON_RUNTIME__;
12
+ if (!runtime || !runtime.routes) {
13
+ console.error("Route runtime not initialized");
14
+ return "";
15
+ }
16
+
17
+ var pattern = runtime.routes[name];
18
+ if (!pattern) {
19
+ console.error('Route "' + name + '" not found');
20
+ return "";
21
+ }
22
+
23
+ if (!params) return pattern;
24
+
25
+ // Replace :param tokens with actual values
26
+ return pattern.replace(/:(\w+)/g, function(match, key) {
27
+ return params[key] !== undefined ? params[key] : match;
28
+ });
29
+ };
30
+
9
31
  function init() {
10
32
  var rt = window.__NITRON_RUNTIME__;
11
33
  if (rt && rt.layouts) layouts = rt.layouts;
package/lib/View/View.js CHANGED
@@ -291,120 +291,134 @@ class View {
291
291
  throw new Error(`View not found: ${name}`);
292
292
  }
293
293
 
294
- const mod = await import(pathToFileURL(viewPath).href + `?t=${Date.now()}`);
295
- if (!mod.default) {
296
- throw new Error("View must have a default export");
297
- }
298
-
299
- const layoutChain = entry?.layouts || [];
300
- const layoutModules = [];
301
-
302
- for (const layoutName of layoutChain) {
303
- const layoutPath = path.join(baseDir, layoutName + ".js");
304
- if (existsSync(layoutPath)) {
305
- const layoutMod = await import(pathToFileURL(layoutPath).href + `?t=${Date.now()}`);
306
- if (layoutMod.default) {
307
- layoutModules.push(layoutMod);
308
- }
309
- }
310
- }
311
-
312
294
  const nonce = randomBytes(16).toString("hex");
313
295
  const ctx = {
314
296
  nonce,
315
297
  csrf,
316
298
  props: {},
317
299
  request: fastifyRequest ? {
318
- params: fastifyRequest.params || {},
319
- query: fastifyRequest.query || {},
320
- url: fastifyRequest.url || '',
300
+ path: (fastifyRequest.url || '').split('?')[0],
321
301
  method: fastifyRequest.method || 'GET',
322
- headers: fastifyRequest.headers || {}
302
+ query: fastifyRequest.query || {},
303
+ params: fastifyRequest.params || {},
304
+ headers: fastifyRequest.headers || {},
305
+ cookies: fastifyRequest.cookies || {},
306
+ ip: fastifyRequest.ip || '',
307
+ isAjax: fastifyRequest.headers?.['x-requested-with'] === 'XMLHttpRequest',
308
+ session: fastifyRequest.session || null,
309
+ auth: fastifyRequest.auth || null,
323
310
  } : null
324
311
  };
325
- let html = "";
326
- let collectedProps = {};
327
312
 
328
- try {
329
- const Component = mod.default;
330
- let element;
331
-
332
- if (Component[MARK]) {
333
- ctx.props[":R0:"] = this.#sanitizeProps(params);
334
- element = React.createElement(
335
- "div",
336
- {
337
- "data-cid": ":R0:",
338
- "data-island": Component.displayName || Component.name || "Anonymous"
339
- },
340
- React.createElement(Component, params)
341
- );
342
- } else {
343
- element = React.createElement(Component, params);
313
+ return Context.run(ctx, async () => {
314
+ const mod = await import(pathToFileURL(viewPath).href + `?t=${Date.now()}`);
315
+ if (!mod.default) {
316
+ throw new Error("View must have a default export");
344
317
  }
345
318
 
346
- if (layoutModules.length > 0) {
347
- element = React.createElement("div", { "data-nitron-slot": "page" }, element);
348
- }
319
+ const layoutChain = entry?.layouts || [];
320
+ const layoutModules = [];
349
321
 
350
- for (let i = layoutModules.length - 1; i >= 0; i--) {
351
- const LayoutComponent = layoutModules[i].default;
352
- element = React.createElement(LayoutComponent, { children: element });
322
+ for (const layoutName of layoutChain) {
323
+ const layoutPath = path.join(baseDir, layoutName + ".js");
324
+ if (existsSync(layoutPath)) {
325
+ const layoutMod = await import(pathToFileURL(layoutPath).href + `?t=${Date.now()}`);
326
+ if (layoutMod.default) {
327
+ layoutModules.push(layoutMod);
328
+ }
329
+ }
353
330
  }
354
331
 
355
- html = await Context.run(ctx, () => this.#renderToHtml(element));
356
- collectedProps = ctx.props;
357
- } catch (error) {
358
- const componentName = mod.default?.displayName || mod.default?.name || "Unknown";
359
- const errorDetails = this.#parseReactError(error);
360
-
361
- Log.error("Render Error", {
362
- view: name,
363
- component: componentName,
364
- error: error.message,
365
- cause: errorDetails.cause,
366
- location: errorDetails.location,
367
- stack: error.stack
368
- });
332
+ let html = "";
333
+ let collectedProps = {};
369
334
 
370
- error.statusCode = error.statusCode || 500;
371
- throw error;
372
- }
335
+ try {
336
+ const Component = mod.default;
337
+ let element;
338
+
339
+ if (Component[MARK]) {
340
+ ctx.props[":R0:"] = this.#sanitizeProps(params);
341
+ element = React.createElement(
342
+ "div",
343
+ {
344
+ "data-cid": ":R0:",
345
+ "data-island": Component.displayName || Component.name || "Anonymous"
346
+ },
347
+ React.createElement(Component, params)
348
+ );
349
+ } else {
350
+ element = React.createElement(Component, params);
351
+ }
373
352
 
374
- const mergedMeta = this.#mergeMeta(layoutModules, mod.Meta);
353
+ if (layoutModules.length > 0) {
354
+ element = React.createElement("div", { "data-nitron-slot": "page" }, element);
355
+ }
375
356
 
376
- return {
377
- html,
378
- nonce,
379
- meta: mergedMeta,
380
- props: collectedProps
381
- };
357
+ for (let i = layoutModules.length - 1; i >= 0; i--) {
358
+ const LayoutComponent = layoutModules[i].default;
359
+ element = React.createElement(LayoutComponent, { children: element });
360
+ }
361
+
362
+ html = await this.#renderToHtml(element);
363
+ collectedProps = ctx.props;
364
+ } catch (error) {
365
+ const componentName = mod.default?.displayName || mod.default?.name || "Unknown";
366
+ const errorDetails = this.#parseReactError(error);
367
+
368
+ Log.error("Render Error", {
369
+ view: name,
370
+ component: componentName,
371
+ error: error.message,
372
+ cause: errorDetails.cause,
373
+ location: errorDetails.location,
374
+ stack: error.stack
375
+ });
376
+
377
+ error.statusCode = error.statusCode || 500;
378
+ throw error;
379
+ }
380
+
381
+ const mergedMeta = this.#mergeMeta(layoutModules, mod.Meta, params);
382
+
383
+ return {
384
+ html,
385
+ nonce,
386
+ meta: mergedMeta,
387
+ props: collectedProps
388
+ };
389
+ });
382
390
  }
383
391
 
384
- static #mergeMeta(layoutModules, viewMeta) {
392
+ static #mergeMeta(layoutModules, viewMeta, params = {}) {
385
393
  const result = {};
386
394
 
387
395
  for (const layoutMod of layoutModules) {
388
396
  if (layoutMod.Meta) {
389
- for (const key of Object.keys(layoutMod.Meta)) {
397
+ const resolvedLayoutMeta = typeof layoutMod.Meta === "function"
398
+ ? layoutMod.Meta(params)
399
+ : layoutMod.Meta;
400
+ for (const key of Object.keys(resolvedLayoutMeta)) {
390
401
  if (!(key in result)) {
391
- result[key] = layoutMod.Meta[key];
402
+ result[key] = resolvedLayoutMeta[key];
392
403
  }
393
404
  }
394
405
  }
395
406
  }
396
407
 
397
408
  if (viewMeta) {
398
- for (const key of Object.keys(viewMeta)) {
409
+ const resolvedViewMeta = typeof viewMeta === "function"
410
+ ? viewMeta(params)
411
+ : viewMeta;
412
+ for (const key of Object.keys(resolvedViewMeta)) {
399
413
  if (key === "title" && result.title?.template) {
400
- const viewTitle = typeof viewMeta.title === "object"
401
- ? viewMeta.title.default
402
- : viewMeta.title;
414
+ const viewTitle = typeof resolvedViewMeta.title === "object"
415
+ ? resolvedViewMeta.title.default
416
+ : resolvedViewMeta.title;
403
417
  if (viewTitle) {
404
418
  result.title = result.title.template.replace("%s", viewTitle);
405
419
  }
406
420
  } else {
407
- result[key] = viewMeta[key];
421
+ result[key] = resolvedViewMeta[key];
408
422
  }
409
423
  }
410
424
  }
@@ -648,8 +662,16 @@ class View {
648
662
  }
649
663
 
650
664
  const content = readFileSync(fullPath, "utf8");
651
- const matches = [...content.matchAll(/routes\["([^"]+)"\]/g)];
652
- const routes = matches.map(match => match[1]);
665
+
666
+ // Match both old format (routes["name"]) and new format (route("name"))
667
+ const oldMatches = [...content.matchAll(/routes\["([^"]+)"\]/g)];
668
+ const newMatches = [...content.matchAll(/route\s*\(\s*['"]([^'"]+)['"]/g)];
669
+
670
+ const routes = [
671
+ ...oldMatches.map(match => match[1]),
672
+ ...newMatches.map(match => match[1])
673
+ ];
674
+
653
675
  const relativePath = "/js/" + path.relative(jsDir, fullPath).replace(/\\/g, "/");
654
676
 
655
677
  this.#routesCache.set(relativePath, [...new Set(routes)]);
@@ -701,7 +723,7 @@ class View {
701
723
  const devIndicator = this.#isDev ? this.#generateDevIndicator(nonceAttr) : "";
702
724
 
703
725
  return `<!DOCTYPE html>
704
- <html lang="en">
726
+ <html lang="${meta?.lang || "en"}">
705
727
  <head>
706
728
  ${this.#generateHead(meta, css)}
707
729
  </head>
@@ -724,6 +746,14 @@ ${vendorScript}${hmrScript}${hydrateScript}${spaScript}${devIndicator}
724
746
  parts.push(`<meta name="description" content="${escapeHtml(meta.description)}">`);
725
747
  }
726
748
 
749
+ if (meta.keywords) {
750
+ parts.push(`<meta name="keywords" content="${escapeHtml(meta.keywords)}">`);
751
+ }
752
+
753
+ if (meta.favicon) {
754
+ parts.push(`<link rel="icon" href="${escapeHtml(meta.favicon)}">`);
755
+ }
756
+
727
757
  for (const href of css) {
728
758
  parts.push(`<link rel="stylesheet" href="${escapeHtml(href)}">`);
729
759
  }
package/lib/index.d.ts CHANGED
@@ -1,15 +1,51 @@
1
1
  export class Storage {
2
- static url(path: string): string;
3
- static path(path: string): string;
4
- static disk(name: string): Storage;
5
- static get(path: string): Promise<Buffer>;
6
- static put(path: string, contents: string | Buffer): Promise<void>;
7
- static delete(path: string): Promise<boolean>;
8
- static exists(path: string): Promise<boolean>;
9
- static copy(from: string, to: string): Promise<void>;
10
- static move(from: string, to: string): Promise<void>;
11
- static files(directory: string): Promise<string[]>;
12
- static directories(directory: string): Promise<string[]>;
2
+ /**
3
+ * Reads a file from storage.
4
+ * @param filePath - Relative path to the file
5
+ * @param isPrivate - Whether to read from private storage (default: false)
6
+ * @returns File contents or null if not found
7
+ */
8
+ static get(filePath: string, isPrivate?: boolean): Promise<Buffer | null>;
9
+
10
+ /**
11
+ * Saves a file to storage.
12
+ * @param file - File object from multipart upload
13
+ * @param dir - Directory path within storage
14
+ * @param fileName - Name for the saved file
15
+ * @param isPrivate - Whether to save to private storage (default: false)
16
+ * @returns True if save successful
17
+ */
18
+ static put(file: { _buf: Buffer }, dir: string, fileName: string, isPrivate?: boolean): Promise<boolean>;
19
+
20
+ /**
21
+ * Deletes a file from storage.
22
+ * @param filePath - Relative path to the file
23
+ * @param isPrivate - Whether to delete from private storage (default: false)
24
+ */
25
+ static delete(filePath: string, isPrivate?: boolean): Promise<void>;
26
+
27
+ /**
28
+ * Moves or renames a file in storage.
29
+ * @param fromPath - Current relative path to the file
30
+ * @param toPath - New relative path for the file
31
+ * @param isPrivate - Whether to operate on private storage (default: false)
32
+ */
33
+ static move(fromPath: string, toPath: string, isPrivate?: boolean): Promise<void>;
34
+
35
+ /**
36
+ * Checks if a file exists in storage.
37
+ * @param filePath - Relative path to the file
38
+ * @param isPrivate - Whether to check private storage (default: false)
39
+ * @returns True if file exists
40
+ */
41
+ static exists(filePath: string, isPrivate?: boolean): boolean;
42
+
43
+ /**
44
+ * Gets the public URL for a file in public storage.
45
+ * @param filePath - Relative path to the file
46
+ * @returns Public URL path
47
+ */
48
+ static url(filePath: string): string;
13
49
  }
14
50
 
15
51
  export class Model {
@@ -20,9 +56,8 @@ export class Model {
20
56
  static hidden: string[];
21
57
  static casts: Record<string, string>;
22
58
 
23
- static all<T extends Model>(this: new () => T): Promise<T[]>;
59
+ static get<T extends Model>(this: new () => T): Promise<T[]>;
24
60
  static find<T extends Model>(this: new () => T, id: number | string): Promise<T | null>;
25
- static findOrFail<T extends Model>(this: new () => T, id: number | string): Promise<T>;
26
61
  static first<T extends Model>(this: new () => T): Promise<T | null>;
27
62
  static where<T extends Model>(this: new () => T, column: string, operator?: any, value?: any): QueryBuilder<T>;
28
63
  static select<T extends Model>(this: new () => T, ...columns: string[]): QueryBuilder<T>;
@@ -189,7 +224,7 @@ export class Lang {
189
224
  export class DateTime {
190
225
  static toSQL(timestamp?: number | null): string;
191
226
  static getTime(sqlDateTime?: string | null): number;
192
- static getDate(timestamp?: number | null, format?: string): string;
227
+ static getDate(timestamp?: string | number | null, format?: string): string;
193
228
  static addDays(days: number): string;
194
229
  static addHours(hours: number): string;
195
230
  static subDays(days: number): string;
@@ -468,7 +503,6 @@ declare global {
468
503
  method: string;
469
504
  query: Record<string, any>;
470
505
  params: Record<string, any>;
471
- body: Record<string, any>;
472
506
  headers: Record<string, string>;
473
507
  cookies: Record<string, string>;
474
508
  ip: string;
@@ -480,5 +514,13 @@ declare global {
480
514
  forget(key: string): void;
481
515
  flash(key: string, value: any): void;
482
516
  };
517
+ auth: {
518
+ guard(name?: string): {
519
+ user<T = any>(): Promise<T | null>;
520
+ check(): boolean;
521
+ };
522
+ user<T = any>(): Promise<T | null>;
523
+ check(): boolean;
524
+ };
483
525
  };
484
526
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nitronjs/framework",
3
- "version": "0.2.12",
3
+ "version": "0.2.14",
4
4
  "description": "NitronJS is a modern and extensible Node.js MVC framework built on Fastify. It focuses on clean architecture, modular structure, and developer productivity, offering built-in routing, middleware, configuration management, CLI tooling, and native React integration for scalable full-stack applications.",
5
5
  "bin": {
6
6
  "njs": "./cli/njs.js"
@@ -12,6 +12,7 @@
12
12
  "forceConsistentCasingInFileNames": true,
13
13
  "noEmit": true,
14
14
  "baseUrl": ".",
15
+ "allowJs": true,
15
16
  "paths": {
16
17
  "@/*": ["./*"],
17
18
  "@css/*": ["./resources/css/*"],