@nitronjs/framework 0.2.26 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +260 -170
- package/lib/Auth/Auth.js +2 -2
- package/lib/Build/CssBuilder.js +5 -7
- package/lib/Build/EffectivePropUsage.js +174 -0
- package/lib/Build/FactoryTransform.js +1 -21
- package/lib/Build/FileAnalyzer.js +2 -33
- package/lib/Build/Manager.js +390 -58
- package/lib/Build/PropUsageAnalyzer.js +1189 -0
- package/lib/Build/jsxRuntime.js +25 -155
- package/lib/Build/plugins.js +212 -146
- package/lib/Build/propUtils.js +70 -0
- package/lib/Console/Commands/DevCommand.js +30 -10
- package/lib/Console/Commands/MakeCommand.js +8 -1
- package/lib/Console/Output.js +0 -2
- package/lib/Console/Stubs/rsc-consumer.tsx +74 -0
- package/lib/Console/Stubs/vendor-dev.tsx +30 -41
- package/lib/Console/Stubs/vendor.tsx +25 -1
- package/lib/Core/Config.js +0 -6
- package/lib/Core/Paths.js +0 -19
- package/lib/Database/Migration/Checksum.js +0 -3
- package/lib/Database/Migration/MigrationRepository.js +0 -8
- package/lib/Database/Migration/MigrationRunner.js +1 -2
- package/lib/Database/Model.js +19 -11
- package/lib/Database/QueryBuilder.js +25 -4
- package/lib/Database/Schema/Blueprint.js +10 -0
- package/lib/Database/Schema/Manager.js +2 -0
- package/lib/Date/DateTime.js +1 -1
- package/lib/Dev/DevContext.js +44 -0
- package/lib/Dev/DevErrorPage.js +990 -0
- package/lib/Dev/DevIndicator.js +836 -0
- package/lib/HMR/Server.js +16 -37
- package/lib/Http/Server.js +177 -24
- package/lib/Logging/Log.js +34 -2
- package/lib/Mail/Mail.js +41 -10
- package/lib/Route/Router.js +43 -19
- package/lib/Runtime/Entry.js +10 -6
- package/lib/Session/Manager.js +144 -1
- package/lib/Session/Redis.js +117 -0
- package/lib/Session/Session.js +0 -4
- package/lib/Support/Str.js +6 -4
- package/lib/Translation/Lang.js +376 -32
- package/lib/Translation/pluralize.js +81 -0
- package/lib/Validation/MagicBytes.js +120 -0
- package/lib/Validation/Validator.js +46 -29
- package/lib/View/Client/hmr-client.js +100 -90
- package/lib/View/Client/spa.js +121 -50
- package/lib/View/ClientManifest.js +60 -0
- package/lib/View/FlightRenderer.js +100 -0
- package/lib/View/Layout.js +0 -3
- package/lib/View/PropFilter.js +81 -0
- package/lib/View/View.js +230 -495
- package/lib/index.d.ts +22 -1
- package/package.json +3 -2
- package/skeleton/config/app.js +1 -0
- package/skeleton/config/server.js +13 -0
- package/skeleton/config/session.js +4 -0
- package/lib/Build/HydrationBuilder.js +0 -190
- package/lib/Console/Stubs/page-hydration-dev.tsx +0 -72
- package/lib/Console/Stubs/page-hydration.tsx +0 -53
package/lib/HMR/Server.js
CHANGED
|
@@ -14,10 +14,10 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
|
14
14
|
* // In HTTP Server
|
|
15
15
|
* HMR.registerRoutes(fastify);
|
|
16
16
|
* HMR.setup(httpServer);
|
|
17
|
-
*
|
|
17
|
+
*
|
|
18
18
|
* // Emit updates
|
|
19
|
-
* HMR.
|
|
20
|
-
* HMR.
|
|
19
|
+
* HMR.emitChange({ changeType: "page", file: "Site/Home.tsx" });
|
|
20
|
+
* HMR.emitChange({ changeType: "css", file: "global.css" });
|
|
21
21
|
*/
|
|
22
22
|
class HMRServer {
|
|
23
23
|
#io = null;
|
|
@@ -33,13 +33,14 @@ class HMRServer {
|
|
|
33
33
|
*/
|
|
34
34
|
registerRoutes(fastify) {
|
|
35
35
|
this.#clientScript = this.#findSocketIoClient();
|
|
36
|
+
const cachedScript = this.#clientScript ? fs.readFileSync(this.#clientScript, "utf-8") : null;
|
|
36
37
|
|
|
37
38
|
fastify.get("/__nitron_client/socket.io.js", (req, reply) => {
|
|
38
|
-
if (!
|
|
39
|
+
if (!cachedScript) {
|
|
39
40
|
return reply.code(503).send("// HMR disabled: socket.io client not found");
|
|
40
41
|
}
|
|
41
42
|
|
|
42
|
-
reply.type("application/javascript").send(
|
|
43
|
+
reply.type("application/javascript").send(cachedScript);
|
|
43
44
|
});
|
|
44
45
|
}
|
|
45
46
|
|
|
@@ -75,41 +76,19 @@ class HMRServer {
|
|
|
75
76
|
}
|
|
76
77
|
|
|
77
78
|
/**
|
|
78
|
-
*
|
|
79
|
-
* @
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
/**
|
|
86
|
-
* Emits a view update event to trigger hot reload of a React component.
|
|
87
|
-
* @param {string} filePath - Absolute path to the changed view file.
|
|
88
|
-
*/
|
|
89
|
-
emitViewUpdate(filePath) {
|
|
90
|
-
if (!this.#io) return;
|
|
91
|
-
|
|
92
|
-
const normalized = filePath.replace(/\\/g, "/");
|
|
93
|
-
const match = normalized.match(/resources\/views\/(.+)\.tsx$/);
|
|
94
|
-
const viewPath = match ? match[1].toLowerCase() : path.basename(filePath, ".tsx").toLowerCase();
|
|
95
|
-
|
|
96
|
-
this.#io.emit("hmr:update", {
|
|
97
|
-
type: "view",
|
|
98
|
-
file: viewPath,
|
|
99
|
-
url: `/js/${viewPath}.js`,
|
|
100
|
-
timestamp: Date.now()
|
|
101
|
-
});
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
/**
|
|
105
|
-
* Emits a CSS update event to refresh stylesheets without page reload.
|
|
106
|
-
* @param {string} [filePath] - Path to the changed CSS file. If null, refreshes all CSS.
|
|
79
|
+
* Emits a unified change event for RSC-based hot updates.
|
|
80
|
+
* @param {object} data
|
|
81
|
+
* @param {string} data.changeType - "page" | "layout" | "css"
|
|
82
|
+
* @param {boolean} [data.cssChanged] - Whether CSS also changed (Tailwind rebuild)
|
|
83
|
+
* @param {string} [data.file] - Relative path of the changed file (for logging)
|
|
107
84
|
*/
|
|
108
|
-
|
|
85
|
+
emitChange(data) {
|
|
109
86
|
if (!this.#io) return;
|
|
110
87
|
|
|
111
|
-
this.#io.emit("hmr:
|
|
112
|
-
|
|
88
|
+
this.#io.emit("hmr:change", {
|
|
89
|
+
changeType: data.changeType,
|
|
90
|
+
cssChanged: data.cssChanged || false,
|
|
91
|
+
file: data.file || null,
|
|
113
92
|
timestamp: Date.now()
|
|
114
93
|
});
|
|
115
94
|
}
|
package/lib/Http/Server.js
CHANGED
|
@@ -17,11 +17,27 @@ import View from "../View/View.js";
|
|
|
17
17
|
import Auth from "../Auth/Auth.js";
|
|
18
18
|
import SessionManager from "../Session/Manager.js";
|
|
19
19
|
import DB from "../Database/DB.js";
|
|
20
|
+
import Lang from "../Translation/Lang.js";
|
|
20
21
|
import Log from "../Logging/Log.js";
|
|
22
|
+
import { detectMime, sanitizeFilename, isSameMimeFamily } from "../Validation/MagicBytes.js";
|
|
21
23
|
|
|
22
24
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
23
25
|
const packageJson = JSON.parse(fs.readFileSync(path.join(__dirname, "../../package.json"), "utf8"));
|
|
24
26
|
|
|
27
|
+
const SECURITY_DEFAULTS = {
|
|
28
|
+
verifyMagicBytes: true,
|
|
29
|
+
dangerousExtensions: [
|
|
30
|
+
".exe", ".bat", ".cmd", ".com", ".scr", ".pif", ".msi", ".dll",
|
|
31
|
+
".sh", ".cgi", ".csh", ".ksh",
|
|
32
|
+
".php", ".phtml", ".php5", ".php7",
|
|
33
|
+
".jsp", ".asp", ".aspx",
|
|
34
|
+
".vbs", ".vbe", ".wsf", ".wsh", ".ps1", ".psm1",
|
|
35
|
+
".hta", ".cpl", ".inf", ".reg", ".sct"
|
|
36
|
+
],
|
|
37
|
+
compoundExtensions: [".tar.gz", ".tar.bz2", ".tar.xz", ".tar.zst"],
|
|
38
|
+
allowDoubleExtension: false
|
|
39
|
+
};
|
|
40
|
+
|
|
25
41
|
/**
|
|
26
42
|
* HTTP server manager built on Fastify.
|
|
27
43
|
* Handles server configuration, plugin registration, and lifecycle management.
|
|
@@ -52,7 +68,7 @@ class Server {
|
|
|
52
68
|
|
|
53
69
|
const serverDefaults = {
|
|
54
70
|
bodyLimit: 50 * 1024 * 1024,
|
|
55
|
-
trustProxy:
|
|
71
|
+
trustProxy: false,
|
|
56
72
|
maxParamLength: 150
|
|
57
73
|
};
|
|
58
74
|
|
|
@@ -201,7 +217,9 @@ class Server {
|
|
|
201
217
|
this.#server.register(fastifyFormbody);
|
|
202
218
|
|
|
203
219
|
// Register hooks
|
|
204
|
-
|
|
220
|
+
const securityConfig = { ...SECURITY_DEFAULTS, ...multipartConfig?.security };
|
|
221
|
+
|
|
222
|
+
this.#server.addHook("preHandler", async (req, reply) => {
|
|
205
223
|
if (!req.isMultipart() || !req.body) {
|
|
206
224
|
return;
|
|
207
225
|
}
|
|
@@ -210,18 +228,127 @@ class Server {
|
|
|
210
228
|
const files = {};
|
|
211
229
|
|
|
212
230
|
for (const [key, field] of Object.entries(req.body)) {
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
231
|
+
const items = Array.isArray(field) ? field : [field];
|
|
232
|
+
let hasFile = false;
|
|
233
|
+
const fileItems = [];
|
|
234
|
+
|
|
235
|
+
for (const item of items) {
|
|
236
|
+
if (!item?.toBuffer) {
|
|
237
|
+
if (!hasFile && item && typeof item === "object" && "value" in item) {
|
|
238
|
+
normalized[key] = item.value;
|
|
239
|
+
}
|
|
240
|
+
else if (!hasFile) {
|
|
241
|
+
normalized[key] = item;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Filename guard — no filename means drop the file (existing behavior)
|
|
248
|
+
if (!item.filename) continue;
|
|
249
|
+
|
|
250
|
+
// 1. Filename sanitization
|
|
251
|
+
item.filename = sanitizeFilename(item.filename);
|
|
252
|
+
|
|
253
|
+
// 2. Extension extraction
|
|
254
|
+
const lastDot = item.filename.lastIndexOf(".");
|
|
255
|
+
|
|
256
|
+
if (lastDot === 0) {
|
|
257
|
+
item.extension = item.filename.slice(1).toLowerCase();
|
|
258
|
+
}
|
|
259
|
+
else if (lastDot > 0) {
|
|
260
|
+
item.extension = item.filename.slice(lastDot + 1).toLowerCase();
|
|
261
|
+
}
|
|
262
|
+
else {
|
|
263
|
+
item.extension = "";
|
|
218
264
|
}
|
|
265
|
+
|
|
266
|
+
// 3. Dangerous extension check
|
|
267
|
+
if (item.extension && securityConfig.dangerousExtensions.includes("." + item.extension)) {
|
|
268
|
+
Log.warn("File rejected", {
|
|
269
|
+
filename: item.filename,
|
|
270
|
+
reason: "Dangerous extension"
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
return reply.code(422).send({
|
|
274
|
+
statusCode: 422,
|
|
275
|
+
error: "Unprocessable Entity",
|
|
276
|
+
message: "File upload rejected."
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// 4. Double extension check
|
|
281
|
+
if (!securityConfig.allowDoubleExtension) {
|
|
282
|
+
const parts = item.filename.split(".");
|
|
283
|
+
const middleExtensions = parts.slice(1, -1);
|
|
284
|
+
|
|
285
|
+
if (middleExtensions.length > 0) {
|
|
286
|
+
const filenameLower = item.filename.toLowerCase();
|
|
287
|
+
let exemptExts = [];
|
|
288
|
+
|
|
289
|
+
for (const compound of securityConfig.compoundExtensions) {
|
|
290
|
+
if (filenameLower.endsWith(compound)) {
|
|
291
|
+
exemptExts = compound.slice(1).split("."); // ".tar.gz" → ["tar", "gz"]
|
|
292
|
+
break;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const remaining = middleExtensions.map(e => e.toLowerCase()).filter(e => !exemptExts.includes(e));
|
|
297
|
+
|
|
298
|
+
for (const ext of remaining) {
|
|
299
|
+
if (securityConfig.dangerousExtensions.includes("." + ext)) {
|
|
300
|
+
Log.warn("File rejected", {
|
|
301
|
+
filename: item.filename,
|
|
302
|
+
reason: "Dangerous double extension"
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
return reply.code(422).send({
|
|
306
|
+
statusCode: 422,
|
|
307
|
+
error: "Unprocessable Entity",
|
|
308
|
+
message: "File upload rejected."
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// 5. Magic byte detection
|
|
316
|
+
if (securityConfig.verifyMagicBytes) {
|
|
317
|
+
try {
|
|
318
|
+
const buf = await item.toBuffer();
|
|
319
|
+
|
|
320
|
+
item._magicMime = detectMime(buf.subarray(0, 12));
|
|
321
|
+
item._fileSize = buf.length;
|
|
322
|
+
}
|
|
323
|
+
catch (err) {
|
|
324
|
+
Log.warn("toBuffer failed", {
|
|
325
|
+
filename: item.filename,
|
|
326
|
+
error: err.message
|
|
327
|
+
});
|
|
328
|
+
item._magicMime = null;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// 6. MIME mismatch logging
|
|
332
|
+
if (item._magicMime && item.mimetype && item._magicMime !== item.mimetype.toLowerCase()) {
|
|
333
|
+
if (!isSameMimeFamily(item._magicMime, item.mimetype)) {
|
|
334
|
+
Log.warn("MIME mismatch detected", {
|
|
335
|
+
filename: item.filename,
|
|
336
|
+
declared: item.mimetype,
|
|
337
|
+
detected: item._magicMime
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
hasFile = true;
|
|
344
|
+
fileItems.push(item);
|
|
219
345
|
}
|
|
220
|
-
|
|
221
|
-
|
|
346
|
+
|
|
347
|
+
if (fileItems.length === 1) {
|
|
348
|
+
files[key] = fileItems[0];
|
|
222
349
|
}
|
|
223
|
-
else {
|
|
224
|
-
|
|
350
|
+
else if (fileItems.length > 1) {
|
|
351
|
+
files[key] = fileItems;
|
|
225
352
|
}
|
|
226
353
|
}
|
|
227
354
|
|
|
@@ -247,6 +374,9 @@ class Server {
|
|
|
247
374
|
|
|
248
375
|
for (let i = 0; i < parts.length - 1; i++) {
|
|
249
376
|
const part = parts[i];
|
|
377
|
+
|
|
378
|
+
if (part === "__proto__" || part === "constructor" || part === "prototype") break;
|
|
379
|
+
|
|
250
380
|
const isNextIndex = /^\d+$/.test(parts[i + 1]);
|
|
251
381
|
|
|
252
382
|
if (current[part] === undefined) {
|
|
@@ -283,7 +413,12 @@ class Server {
|
|
|
283
413
|
console.log("\x1b[32m✓ Server stopped gracefully\x1b[0m");
|
|
284
414
|
|
|
285
415
|
try {
|
|
286
|
-
|
|
416
|
+
const sessionManager = await SessionManager.getInstance();
|
|
417
|
+
|
|
418
|
+
await Promise.race([
|
|
419
|
+
sessionManager.close(),
|
|
420
|
+
new Promise(resolve => setTimeout(resolve, 1000))
|
|
421
|
+
]);
|
|
287
422
|
await Promise.race([
|
|
288
423
|
DB.close(),
|
|
289
424
|
new Promise(resolve => setTimeout(resolve, 1000))
|
|
@@ -305,15 +440,21 @@ class Server {
|
|
|
305
440
|
await DB.setup();
|
|
306
441
|
await SessionManager.setup(this.#server);
|
|
307
442
|
Auth.setup(this.#server);
|
|
443
|
+
Lang.setup(this.#server);
|
|
308
444
|
|
|
309
|
-
// Register
|
|
445
|
+
// Register dev-only hooks and routes
|
|
310
446
|
if (Environment.isDev) {
|
|
311
447
|
const { default: HMRServer } = await import("../HMR/Server.js");
|
|
312
448
|
HMRServer.registerRoutes(this.#server);
|
|
449
|
+
|
|
450
|
+
const { default: DevContext } = await import("../Dev/DevContext.js");
|
|
451
|
+
this.#server.addHook("onRequest", async (req) => {
|
|
452
|
+
DevContext.attach(req);
|
|
453
|
+
});
|
|
313
454
|
}
|
|
314
455
|
|
|
315
456
|
await Router.setup(this.#server);
|
|
316
|
-
View.setup(this.#server);
|
|
457
|
+
await View.setup(this.#server);
|
|
317
458
|
|
|
318
459
|
// Listen
|
|
319
460
|
try {
|
|
@@ -328,20 +469,29 @@ class Server {
|
|
|
328
469
|
HMRServer.setup(this.#server.server);
|
|
329
470
|
}
|
|
330
471
|
|
|
331
|
-
this.#
|
|
472
|
+
const banner = this.#renderBanner({ success: true, address, host, port });
|
|
473
|
+
|
|
474
|
+
if (Environment.isDev && process.send) {
|
|
475
|
+
process.send({ type: "banner", text: banner });
|
|
476
|
+
}
|
|
477
|
+
else if (banner) {
|
|
478
|
+
console.log(banner);
|
|
479
|
+
}
|
|
480
|
+
|
|
332
481
|
Log.info("Server started successfully!", { address, host, port, environment: Environment.isDev ? "development" : "production" });
|
|
333
482
|
}
|
|
334
483
|
catch (err) {
|
|
335
|
-
this.#
|
|
484
|
+
const banner = this.#renderBanner({ success: false, error: err });
|
|
485
|
+
if (banner) console.log(banner);
|
|
336
486
|
Log.fatal("Server startup failed", { error: err.message, code: err.code, stack: err.stack });
|
|
337
487
|
process.exit(1);
|
|
338
488
|
}
|
|
339
489
|
}
|
|
340
490
|
|
|
341
491
|
// Private Methods
|
|
342
|
-
static #
|
|
492
|
+
static #renderBanner({ success, address, host, port, error }) {
|
|
343
493
|
if (this.#config.log.channel === "console") {
|
|
344
|
-
return;
|
|
494
|
+
return null;
|
|
345
495
|
}
|
|
346
496
|
|
|
347
497
|
const color = success ? "\x1b[32m" : "\x1b[31m";
|
|
@@ -352,7 +502,7 @@ class Server {
|
|
|
352
502
|
const pad = (content, length) => content + " ".repeat(Math.max(0, length - stripAnsi(content).length));
|
|
353
503
|
|
|
354
504
|
const version = `v${packageJson.version}`;
|
|
355
|
-
|
|
505
|
+
|
|
356
506
|
const logo = `
|
|
357
507
|
${color}███╗ ██╗██╗████████╗██████╗ ██████╗ ███╗ ██╗ ██╗███████╗
|
|
358
508
|
████╗ ██║██║╚══██╔══╝██╔══██╗██╔═══██╗████╗ ██║ ██║██╔════╝
|
|
@@ -379,15 +529,18 @@ ${color}███╗ ██╗██╗████████╗████
|
|
|
379
529
|
...(error?.code ? [`${bold}Code:${reset} ${error.code}`] : [])
|
|
380
530
|
];
|
|
381
531
|
|
|
382
|
-
|
|
383
|
-
|
|
532
|
+
const parts = [logo];
|
|
533
|
+
|
|
534
|
+
parts.push(`${color}┌${"─".repeat(boxWidth - 2)}┐${reset}`);
|
|
384
535
|
|
|
385
536
|
for (const line of lines) {
|
|
386
|
-
|
|
537
|
+
parts.push(`${color}│${reset} ${pad(line, boxWidth - 4)} ${color}│${reset}`);
|
|
387
538
|
}
|
|
388
539
|
|
|
389
|
-
|
|
390
|
-
|
|
540
|
+
parts.push(`${color}│${reset} ${" ".repeat(boxWidth - 4)} ${color}│${reset}`);
|
|
541
|
+
parts.push(`${color}└${"─".repeat(boxWidth - 2)}┘${reset}`);
|
|
542
|
+
|
|
543
|
+
return parts.join("\n");
|
|
391
544
|
}
|
|
392
545
|
}
|
|
393
546
|
|
package/lib/Logging/Log.js
CHANGED
|
@@ -70,13 +70,15 @@ class Log {
|
|
|
70
70
|
return;
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
+
const sanitizedMessage = sanitizeLogValue(message);
|
|
74
|
+
const sanitizedContext = sanitizeLogContext(context);
|
|
73
75
|
const timestamp = new Date();
|
|
74
76
|
|
|
75
77
|
if (config.channel === "console") {
|
|
76
|
-
this.#logToConsole(level,
|
|
78
|
+
this.#logToConsole(level, sanitizedMessage, sanitizedContext, timestamp);
|
|
77
79
|
}
|
|
78
80
|
else if (config.channel === "file") {
|
|
79
|
-
this.#logToFile(level,
|
|
81
|
+
this.#logToFile(level, sanitizedMessage, sanitizedContext, timestamp, config);
|
|
80
82
|
}
|
|
81
83
|
}
|
|
82
84
|
catch (e) {
|
|
@@ -194,4 +196,34 @@ class Log {
|
|
|
194
196
|
}
|
|
195
197
|
}
|
|
196
198
|
|
|
199
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
200
|
+
// Private Helper Functions
|
|
201
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
202
|
+
|
|
203
|
+
function sanitizeLogValue(value) {
|
|
204
|
+
if (typeof value !== "string") return value;
|
|
205
|
+
|
|
206
|
+
return value.replace(/[\r\n]/g, " ");
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function sanitizeLogContext(context) {
|
|
210
|
+
if (!context || typeof context !== "object") return context;
|
|
211
|
+
|
|
212
|
+
const result = {};
|
|
213
|
+
|
|
214
|
+
for (const [key, value] of Object.entries(context)) {
|
|
215
|
+
if (typeof value === "string") {
|
|
216
|
+
result[key] = sanitizeLogValue(value);
|
|
217
|
+
}
|
|
218
|
+
else if (typeof value === "object" && value !== null && !Array.isArray(value)) {
|
|
219
|
+
result[key] = sanitizeLogContext(value);
|
|
220
|
+
}
|
|
221
|
+
else {
|
|
222
|
+
result[key] = value;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
return result;
|
|
227
|
+
}
|
|
228
|
+
|
|
197
229
|
export default Log;
|
package/lib/Mail/Mail.js
CHANGED
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
import nodemailer from "nodemailer";
|
|
2
|
-
import
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import Paths from "../Core/Paths.js";
|
|
3
5
|
|
|
4
6
|
/**
|
|
5
7
|
* Email sending utility with fluent API.
|
|
6
|
-
* Supports text, HTML
|
|
7
|
-
*
|
|
8
|
+
* Supports text, HTML, attachments, and calendar invites.
|
|
9
|
+
*
|
|
8
10
|
* @example
|
|
9
11
|
* await Mail.from("noreply@example.com")
|
|
10
12
|
* .to("user@example.com")
|
|
11
13
|
* .subject("Welcome!")
|
|
12
|
-
* .
|
|
14
|
+
* .html("<h1>Hello!</h1>")
|
|
13
15
|
* .send();
|
|
14
16
|
*/
|
|
15
17
|
class Mail {
|
|
@@ -112,14 +114,34 @@ class Mail {
|
|
|
112
114
|
}
|
|
113
115
|
|
|
114
116
|
/**
|
|
115
|
-
* Sets HTML content
|
|
116
|
-
* @param {
|
|
117
|
-
* @param {string} viewName - View template name
|
|
118
|
-
* @param {Object|null} data - Data to pass to the view
|
|
117
|
+
* Sets raw HTML content.
|
|
118
|
+
* @param {string} htmlString - HTML string
|
|
119
119
|
* @returns {Mail} This instance for chaining
|
|
120
120
|
*/
|
|
121
|
-
|
|
122
|
-
this.#htmlContent =
|
|
121
|
+
html(htmlString) {
|
|
122
|
+
this.#htmlContent = htmlString;
|
|
123
|
+
|
|
124
|
+
return this;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Sets HTML content from a template file with placeholder replacement.
|
|
129
|
+
* Template files are loaded from resources/views/ directory.
|
|
130
|
+
* Placeholders use {{ key }} syntax.
|
|
131
|
+
*
|
|
132
|
+
* @param {string} templateName - Template path relative to views dir (e.g. "emails/welcome")
|
|
133
|
+
* @param {Object} data - Data to replace placeholders with
|
|
134
|
+
* @returns {Mail} This instance for chaining
|
|
135
|
+
*/
|
|
136
|
+
view(templateName, data = {}) {
|
|
137
|
+
const filePath = path.join(Paths.views, `${templateName}.html`);
|
|
138
|
+
let content = fs.readFileSync(filePath, "utf-8");
|
|
139
|
+
|
|
140
|
+
for (const [key, value] of Object.entries(data)) {
|
|
141
|
+
content = content.replaceAll(`{{ ${key} }}`, escapeHtml(String(value ?? "")));
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
this.#htmlContent = content;
|
|
123
145
|
|
|
124
146
|
return this;
|
|
125
147
|
}
|
|
@@ -184,4 +206,13 @@ class Mail {
|
|
|
184
206
|
}
|
|
185
207
|
}
|
|
186
208
|
|
|
209
|
+
function escapeHtml(str) {
|
|
210
|
+
return str
|
|
211
|
+
.replace(/&/g, "&")
|
|
212
|
+
.replace(/</g, "<")
|
|
213
|
+
.replace(/>/g, ">")
|
|
214
|
+
.replace(/"/g, """)
|
|
215
|
+
.replace(/'/g, "'");
|
|
216
|
+
}
|
|
217
|
+
|
|
187
218
|
export default Mail;
|
package/lib/Route/Router.js
CHANGED
|
@@ -154,7 +154,10 @@ class Router {
|
|
|
154
154
|
const isExcluded = csrf.except.some(pattern => {
|
|
155
155
|
if (route.url === pattern) return true;
|
|
156
156
|
if (pattern.includes("*")) {
|
|
157
|
-
|
|
157
|
+
const escaped = pattern
|
|
158
|
+
.replace(/[.+?^${}()|[\]\\]/g, "\\$&")
|
|
159
|
+
.replace(/\*/g, ".*");
|
|
160
|
+
return new RegExp("^" + escaped + "$").test(route.url);
|
|
158
161
|
}
|
|
159
162
|
return false;
|
|
160
163
|
});
|
|
@@ -165,6 +168,8 @@ class Router {
|
|
|
165
168
|
}
|
|
166
169
|
|
|
167
170
|
// Resolve middleware names to handlers
|
|
171
|
+
route.middlewareNames = route.middlewares.map(m => m.split(":")[0]);
|
|
172
|
+
|
|
168
173
|
route.resolvedMiddlewares = route.middlewares.map(middleware => {
|
|
169
174
|
const [name, param] = middleware.split(":");
|
|
170
175
|
|
|
@@ -183,8 +188,39 @@ class Router {
|
|
|
183
188
|
server.route({
|
|
184
189
|
method: route.method,
|
|
185
190
|
url: route.url,
|
|
186
|
-
preHandler:
|
|
187
|
-
|
|
191
|
+
preHandler: Environment.isDev
|
|
192
|
+
? route.resolvedMiddlewares.map((mw, i) => {
|
|
193
|
+
const mwName = route.middlewareNames[i];
|
|
194
|
+
return async (request, response) => {
|
|
195
|
+
const start = performance.now();
|
|
196
|
+
await mw(request, response);
|
|
197
|
+
if (request.__devCtx) {
|
|
198
|
+
request.__devCtx.middlewareTiming.push({
|
|
199
|
+
name: mwName,
|
|
200
|
+
duration: performance.now() - start
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
};
|
|
204
|
+
})
|
|
205
|
+
: route.resolvedMiddlewares,
|
|
206
|
+
handler: Environment.isDev
|
|
207
|
+
? async (request, response) => {
|
|
208
|
+
if (request.__devCtx) {
|
|
209
|
+
request.__devCtx.route = {
|
|
210
|
+
name: route.name,
|
|
211
|
+
pattern: route.url,
|
|
212
|
+
method: route.method,
|
|
213
|
+
middlewareNames: route.middlewareNames
|
|
214
|
+
};
|
|
215
|
+
request.__devCtx.controllerStart = performance.now();
|
|
216
|
+
}
|
|
217
|
+
const result = await route.resolvedHandler(request, response);
|
|
218
|
+
if (request.__devCtx) {
|
|
219
|
+
request.__devCtx.controllerDuration = performance.now() - request.__devCtx.controllerStart;
|
|
220
|
+
}
|
|
221
|
+
return result;
|
|
222
|
+
}
|
|
223
|
+
: route.resolvedHandler
|
|
188
224
|
});
|
|
189
225
|
}
|
|
190
226
|
}
|
|
@@ -253,7 +289,7 @@ class Router {
|
|
|
253
289
|
let url = route.url;
|
|
254
290
|
|
|
255
291
|
for (const key in params) {
|
|
256
|
-
url = url.replace(`:${key}`, params[key]);
|
|
292
|
+
url = url.replace(`:${key}`, encodeURIComponent(params[key]));
|
|
257
293
|
}
|
|
258
294
|
|
|
259
295
|
if (query && Object.keys(query).length > 0) {
|
|
@@ -284,8 +320,9 @@ class Router {
|
|
|
284
320
|
if (route.method !== method) continue;
|
|
285
321
|
|
|
286
322
|
const pattern = route.url
|
|
287
|
-
.
|
|
288
|
-
.replace(/\//g, "\\/")
|
|
323
|
+
.split(/:[^/]+/)
|
|
324
|
+
.map(s => s.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\//g, "\\/"))
|
|
325
|
+
.join("([^/]+)");
|
|
289
326
|
const regex = new RegExp(`^${pattern}$`);
|
|
290
327
|
const match = pathname.match(regex);
|
|
291
328
|
|
|
@@ -326,19 +363,6 @@ class Router {
|
|
|
326
363
|
return this.#kernel;
|
|
327
364
|
}
|
|
328
365
|
|
|
329
|
-
// Legacy compatibility (used by View/Manager.js)
|
|
330
|
-
static getSessionConfig() {
|
|
331
|
-
return Config.all("session");
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
static async getKernel() {
|
|
335
|
-
return this.#getKernel();
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
// Alias for backwards compatibility
|
|
339
|
-
static route(name, params, query) {
|
|
340
|
-
return this.url(name, params, query);
|
|
341
|
-
}
|
|
342
366
|
}
|
|
343
367
|
|
|
344
368
|
// ─────────────────────────────────────────────────────────────────────────────
|
package/lib/Runtime/Entry.js
CHANGED
|
@@ -7,6 +7,10 @@ Environment.setDev(process.env.__NITRON_DEV__ === "true");
|
|
|
7
7
|
export async function start() {
|
|
8
8
|
await Server.start();
|
|
9
9
|
|
|
10
|
+
if (process.send) {
|
|
11
|
+
process.send({ type: "ready" });
|
|
12
|
+
}
|
|
13
|
+
|
|
10
14
|
if (Environment.isDev && process.send) {
|
|
11
15
|
const { default: HMRServer } = await import("../HMR/Server.js");
|
|
12
16
|
|
|
@@ -14,11 +18,8 @@ export async function start() {
|
|
|
14
18
|
if (!msg?.type || !HMRServer.isReady) return;
|
|
15
19
|
|
|
16
20
|
switch (msg.type) {
|
|
17
|
-
case "
|
|
18
|
-
HMRServer.
|
|
19
|
-
break;
|
|
20
|
-
case "css":
|
|
21
|
-
HMRServer.emitCss(msg.filePath);
|
|
21
|
+
case "change":
|
|
22
|
+
HMRServer.emitChange(msg);
|
|
22
23
|
break;
|
|
23
24
|
case "reload":
|
|
24
25
|
HMRServer.emitReload(msg.reason);
|
|
@@ -33,5 +34,8 @@ export async function start() {
|
|
|
33
34
|
|
|
34
35
|
const isMain = process.argv[1]?.endsWith("Entry.js");
|
|
35
36
|
if (isMain) {
|
|
36
|
-
start()
|
|
37
|
+
start().catch(e => {
|
|
38
|
+
console.error(e);
|
|
39
|
+
process.exit(1);
|
|
40
|
+
});
|
|
37
41
|
}
|