@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.
Files changed (59) hide show
  1. package/README.md +260 -170
  2. package/lib/Auth/Auth.js +2 -2
  3. package/lib/Build/CssBuilder.js +5 -7
  4. package/lib/Build/EffectivePropUsage.js +174 -0
  5. package/lib/Build/FactoryTransform.js +1 -21
  6. package/lib/Build/FileAnalyzer.js +2 -33
  7. package/lib/Build/Manager.js +390 -58
  8. package/lib/Build/PropUsageAnalyzer.js +1189 -0
  9. package/lib/Build/jsxRuntime.js +25 -155
  10. package/lib/Build/plugins.js +212 -146
  11. package/lib/Build/propUtils.js +70 -0
  12. package/lib/Console/Commands/DevCommand.js +30 -10
  13. package/lib/Console/Commands/MakeCommand.js +8 -1
  14. package/lib/Console/Output.js +0 -2
  15. package/lib/Console/Stubs/rsc-consumer.tsx +74 -0
  16. package/lib/Console/Stubs/vendor-dev.tsx +30 -41
  17. package/lib/Console/Stubs/vendor.tsx +25 -1
  18. package/lib/Core/Config.js +0 -6
  19. package/lib/Core/Paths.js +0 -19
  20. package/lib/Database/Migration/Checksum.js +0 -3
  21. package/lib/Database/Migration/MigrationRepository.js +0 -8
  22. package/lib/Database/Migration/MigrationRunner.js +1 -2
  23. package/lib/Database/Model.js +19 -11
  24. package/lib/Database/QueryBuilder.js +25 -4
  25. package/lib/Database/Schema/Blueprint.js +10 -0
  26. package/lib/Database/Schema/Manager.js +2 -0
  27. package/lib/Date/DateTime.js +1 -1
  28. package/lib/Dev/DevContext.js +44 -0
  29. package/lib/Dev/DevErrorPage.js +990 -0
  30. package/lib/Dev/DevIndicator.js +836 -0
  31. package/lib/HMR/Server.js +16 -37
  32. package/lib/Http/Server.js +177 -24
  33. package/lib/Logging/Log.js +34 -2
  34. package/lib/Mail/Mail.js +41 -10
  35. package/lib/Route/Router.js +43 -19
  36. package/lib/Runtime/Entry.js +10 -6
  37. package/lib/Session/Manager.js +144 -1
  38. package/lib/Session/Redis.js +117 -0
  39. package/lib/Session/Session.js +0 -4
  40. package/lib/Support/Str.js +6 -4
  41. package/lib/Translation/Lang.js +376 -32
  42. package/lib/Translation/pluralize.js +81 -0
  43. package/lib/Validation/MagicBytes.js +120 -0
  44. package/lib/Validation/Validator.js +46 -29
  45. package/lib/View/Client/hmr-client.js +100 -90
  46. package/lib/View/Client/spa.js +121 -50
  47. package/lib/View/ClientManifest.js +60 -0
  48. package/lib/View/FlightRenderer.js +100 -0
  49. package/lib/View/Layout.js +0 -3
  50. package/lib/View/PropFilter.js +81 -0
  51. package/lib/View/View.js +230 -495
  52. package/lib/index.d.ts +22 -1
  53. package/package.json +3 -2
  54. package/skeleton/config/app.js +1 -0
  55. package/skeleton/config/server.js +13 -0
  56. package/skeleton/config/session.js +4 -0
  57. package/lib/Build/HydrationBuilder.js +0 -190
  58. package/lib/Console/Stubs/page-hydration-dev.tsx +0 -72
  59. package/lib/Console/Stubs/page-hydration.tsx +0 -53
package/lib/View/View.js CHANGED
@@ -1,4 +1,4 @@
1
- import { existsSync, readFileSync, readdirSync, statSync } from "fs";
1
+ import { existsSync, readFileSync, statSync } from "fs";
2
2
  import path from "path";
3
3
  import { pathToFileURL, fileURLToPath } from "url";
4
4
  import { randomBytes } from "crypto";
@@ -11,14 +11,14 @@ import Router from "../Route/Router.js";
11
11
  import Paths from "../Core/Paths.js";
12
12
  import Config from "../Core/Config.js";
13
13
  import Environment from "../Core/Environment.js";
14
+ import FlightRenderer from "./FlightRenderer.js";
15
+ import ClientManifest from "./ClientManifest.js";
16
+ import PropFilter from "./PropFilter.js";
17
+ import Lang from "../Translation/Lang.js";
14
18
 
15
- // Get framework version from package.json
16
19
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
17
- const packageJsonPath = path.join(__dirname, "../../package.json");
18
- const FRAMEWORK_VERSION = JSON.parse(readFileSync(packageJsonPath, "utf-8")).version;
19
20
 
20
21
  const CTX = Symbol.for("__nitron_view_context__");
21
- const MARK = Symbol.for("__nitron_client_component__");
22
22
  const Context = new AsyncLocalStorage();
23
23
  globalThis[CTX] ??= Context;
24
24
 
@@ -31,92 +31,51 @@ const ESC_MAP = {
31
31
  "`": "`"
32
32
  };
33
33
 
34
- const UNSAFE_KEYS = new Set(["__proto__", "constructor", "prototype"]);
35
-
36
34
  function escapeHtml(str) {
37
35
  if (!str) return "";
38
36
  return String(str).replace(/[&<>"'`]/g, char => ESC_MAP[char]);
39
37
  }
40
38
 
41
- function trackProps(obj) {
42
- const accessed = new Set();
43
- const cache = new WeakMap();
44
-
45
- function wrap(source, prefix) {
46
- if (source === null || typeof source !== "object") return source;
47
- if (cache.has(source)) return cache.get(source);
48
-
49
- const isArr = Array.isArray(source);
50
- const copy = isArr ? [] : {};
51
-
52
- for (const key of Object.keys(source)) {
53
- const val = source[key];
54
-
55
- if (val !== null && typeof val === "object" && typeof val !== "function") {
56
- copy[key] = wrap(val, prefix ? prefix + "." + key : key);
57
- }
58
- else {
59
- copy[key] = val;
60
- }
61
- }
62
-
63
- const proxy = new Proxy(copy, {
64
- get(t, prop, receiver) {
65
- if (typeof prop === "symbol") return Reflect.get(t, prop, receiver);
66
-
67
- const val = Reflect.get(t, prop, receiver);
68
- if (typeof val === "function") return val;
69
-
70
- if (val !== undefined) {
71
- const currentPath = prefix ? prefix + "." + String(prop) : String(prop);
72
- accessed.add(currentPath);
73
- }
39
+ function isLocalPath(url) {
40
+ if (!url || typeof url !== "string") return false;
74
41
 
75
- return val;
76
- }
77
- });
78
-
79
- cache.set(source, proxy);
80
- return proxy;
42
+ try {
43
+ const decoded = decodeURIComponent(url);
44
+ return decoded.startsWith("/") && !decoded.startsWith("//");
81
45
  }
82
-
83
- return { proxy: wrap(obj, ""), accessed };
84
- }
85
-
86
- function resolveExists(obj, path) {
87
- const parts = path.split(".");
88
- let current = obj;
89
-
90
- for (let i = 0; i < parts.length; i++) {
91
- if (current == null || typeof current !== "object") return false;
92
- current = current[parts[i]];
46
+ catch {
47
+ return false;
93
48
  }
94
-
95
- return current !== undefined;
96
49
  }
97
50
 
51
+
98
52
  /**
99
- * React SSR view renderer with streaming support.
100
- * Handles component rendering, asset injection, and client hydration.
101
- *
53
+ * Server-side view renderer with RSC (React Server Components) support.
54
+ * Dual renders each page: HTML for initial paint, Flight payload for hydration.
55
+ *
102
56
  * @example
103
- * return View.render(res, "Dashboard", { user: currentUser });
57
+ * // In a controller:
58
+ * return res.view("Dashboard", { user: currentUser });
104
59
  */
105
60
  class View {
106
- static #root = Paths.project;
107
61
  static #userViews = Paths.buildViews;
108
62
  static #frameworkViews = Paths.buildFrameworkViews;
109
63
  static #manifestPath = path.join(Paths.build, "manifest.json");
110
64
  static #manifest = null;
111
- static #routesCache = null;
112
65
  static #manifestMtime = null;
113
66
  static #moduleCache = new Map();
67
+ static #devIndicatorModule = null;
114
68
 
115
69
  static get #isDev() {
116
70
  return Environment.isDev;
117
71
  }
118
72
 
119
- static setup(server) {
73
+ static async setup(server) {
74
+ if (this.#isDev) {
75
+ const { default: DI } = await import("../Dev/DevIndicator.js");
76
+ this.#devIndicatorModule = DI;
77
+ }
78
+
120
79
  server.decorateReply("view", async function(name, params = {}) {
121
80
  const entry = View.#getEntry("user", name);
122
81
  if (!entry) {
@@ -127,9 +86,10 @@ class View {
127
86
 
128
87
  const csrf = this.request.session?.getCsrfToken?.();
129
88
  const fastifyRequest = this.request;
130
-
89
+ const renderStart = performance.now();
90
+
131
91
  try {
132
- const { html, nonce, meta, props } = await View.#render(
92
+ const { html, nonce, meta, flightPayload, translations } = await View.#render(
133
93
  View.#userViews,
134
94
  name,
135
95
  params,
@@ -138,13 +98,25 @@ class View {
138
98
  entry
139
99
  );
140
100
 
101
+ if (View.#isDev && this.request.__devCtx) {
102
+ const ctx = this.request.__devCtx;
103
+ ctx.renderStart = renderStart;
104
+ ctx.renderDuration = performance.now() - renderStart;
105
+ ctx.viewName = name;
106
+ ctx.rawProps = params;
107
+ ctx.propUsage = entry?.propUsage;
108
+ }
109
+
110
+ const devData = View.#isDev ? this.request.__devCtx : null;
111
+
141
112
  View.#setSecurityHeaders(this, nonce);
142
113
 
143
114
  return this
144
115
  .code(200)
145
116
  .type("text/html")
146
- .send(View.#buildPage(entry, html, nonce, csrf, meta, props));
147
- } catch (error) {
117
+ .send(View.#buildPage(entry, html, nonce, csrf, meta, flightPayload, devData, translations));
118
+ }
119
+ catch (error) {
148
120
  error.statusCode = error.statusCode || 500;
149
121
  throw error;
150
122
  }
@@ -156,12 +128,12 @@ class View {
156
128
  url: req.url,
157
129
  ip: req.ip
158
130
  });
159
- return View.#renderError(res, 404, "errors/404", {
131
+ return View.#renderError(req, res, 404, "errors/404", {
160
132
  title: "404 - Not Found"
161
133
  });
162
134
  });
163
135
 
164
- server.setErrorHandler((err, req, res) => {
136
+ server.setErrorHandler(async (err, req, res) => {
165
137
  const statusCode = err.statusCode || 500;
166
138
  Log.error("HTTP Error", {
167
139
  error: err.message,
@@ -169,27 +141,50 @@ class View {
169
141
  statusCode,
170
142
  url: req.url
171
143
  });
172
- return View.#renderError(res, statusCode, "errors/500", {
144
+
145
+ if (View.#isDev && statusCode >= 500) {
146
+ return View.#renderDevError(err, req, res, statusCode);
147
+ }
148
+
149
+ return View.#renderError(req, res, statusCode, "errors/500", {
173
150
  title: "500 - Server Error",
174
151
  message: View.#isDev ? err.message : "An unexpected error occurred"
175
152
  });
176
153
  });
177
154
 
178
- server.get("/__nitron/navigate", async (req, res) => {
155
+ server.get("/__nitron/rsc", async (req, res) => {
179
156
  const url = req.query.url;
180
157
 
181
- if (!url) {
158
+ if (!url || typeof url !== "string") {
182
159
  return res.code(400).send({ error: "Missing url" });
183
160
  }
184
161
 
162
+ if (!isLocalPath(url)) {
163
+ return res.code(400).send({ error: "Invalid url" });
164
+ }
165
+
185
166
  const result = await View.#renderPartial(url, req, res);
186
167
 
187
168
  if (result.handled) return;
188
169
 
170
+ if (result.status && result.status !== 200) {
171
+ const safeError = View.#isDev ? result.error : "Request failed";
172
+ return res.code(result.status).send({ error: safeError });
173
+ }
174
+
175
+ const flightPayload = result.flightPayload;
176
+ const metadata = JSON.stringify({
177
+ meta: result.meta || {},
178
+ css: result.css || [],
179
+ translations: result.translations || {}
180
+ });
181
+ const body = flightPayload.length + "\n" + flightPayload + metadata;
182
+
189
183
  return res
190
- .code(result.status || 200)
184
+ .code(200)
185
+ .type("text/x-component")
191
186
  .header("X-Content-Type-Options", "nosniff")
192
- .send(result);
187
+ .send(body);
193
188
  });
194
189
 
195
190
  if (View.#isDev) {
@@ -224,6 +219,7 @@ class View {
224
219
  view: (name, p = {}) => { viewName = name; viewParams = p; return mockRes; },
225
220
  redirect: (loc) => { redirectTo = loc; return mockRes; },
226
221
  code: (c) => { originalRes.code(c); return mockRes; },
222
+ status: (c) => { originalRes.code(c); return mockRes; },
227
223
  type: (t) => { originalRes.type(t); return mockRes; },
228
224
  send: (d) => { handled = true; originalRes.send(d); return mockRes; },
229
225
  header: (k, v) => { originalRes.header(k, v); return mockRes; }
@@ -234,6 +230,7 @@ class View {
234
230
  url: parsedUrl.pathname,
235
231
  query: Object.fromEntries(parsedUrl.searchParams),
236
232
  params,
233
+ headers: originalReq.headers,
237
234
  session: originalReq.session
238
235
  };
239
236
 
@@ -250,60 +247,41 @@ class View {
250
247
  }
251
248
 
252
249
  if (handled) return { handled: true };
253
- if (redirectTo) return this.#validateRedirect(redirectTo, originalReq.headers.host);
250
+ if (redirectTo) return this.#validateRedirect(redirectTo);
254
251
 
255
252
  await (route.resolvedHandler || route.handler)(mockReq, mockRes);
256
253
 
257
254
  if (handled) return { handled: true };
258
- if (redirectTo) return this.#validateRedirect(redirectTo, originalReq.headers.host);
255
+ if (redirectTo) return this.#validateRedirect(redirectTo);
259
256
  if (!viewName) return { status: 500, error: "No view rendered" };
260
257
 
261
258
  const entry = this.#getEntry("user", viewName);
262
259
  if (!entry) return { status: 404, error: "View not found" };
263
260
 
264
261
  const csrf = originalReq.session?.getCsrfToken?.();
265
- const { html, meta, props } = await this.#render(
266
- this.#userViews, viewName, viewParams, csrf, originalReq, entry
262
+ const { meta, flightPayload, translations } = await this.#render(
263
+ this.#userViews, viewName, viewParams, csrf, mockReq, entry
267
264
  );
268
265
 
269
- const response = { html, layouts: entry.layouts || [], meta };
270
-
271
- if (entry.hydrationScript) {
272
- response.hydrationScript = entry.hydrationScript;
273
- response.props = props;
274
- response.runtime = {
275
- csrf: csrf || "",
276
- routes: this.#getRoutesForScript(entry.hydrationScript)
277
- };
278
- }
279
-
280
- return response;
266
+ return {
267
+ flightPayload,
268
+ meta,
269
+ layouts: entry.layouts || [],
270
+ css: (entry.css || []).map(href => `/storage${href}`),
271
+ translations: translations || {}
272
+ };
281
273
  } catch (err) {
282
274
  Log.error("SPA Navigate Error", { error: err.message, url });
283
275
  return { status: 500, error: this.#isDev ? err.message : "Server error" };
284
276
  }
285
277
  }
286
278
 
287
- static #validateRedirect(location, host) {
288
- if (location.startsWith("/")) {
279
+ static #validateRedirect(location) {
280
+ if (location.startsWith("/") && !location.startsWith("//")) {
289
281
  return { redirect: location };
290
282
  }
291
- try {
292
- if (new URL(location).host === host) {
293
- return { redirect: location };
294
- }
295
- } catch {}
296
- return { status: 400, error: "Invalid redirect" };
297
- }
298
283
 
299
- static #getRoutesForScript(script) {
300
- const allRoutes = Router.getClientManifest();
301
- const usedNames = this.#getUsedRoutes(script);
302
- const routes = {};
303
- for (const name of usedNames) {
304
- if (allRoutes[name]) routes[name] = allRoutes[name];
305
- }
306
- return routes;
284
+ return { status: 400, error: "Invalid redirect" };
307
285
  }
308
286
 
309
287
  static #getEntry(namespace, name) {
@@ -312,22 +290,23 @@ class View {
312
290
  }
313
291
 
314
292
  static #loadManifest() {
315
- if (!existsSync(this.#manifestPath)) {
293
+ let stat;
294
+
295
+ try { stat = statSync(this.#manifestPath); }
296
+ catch {
316
297
  throw new Error("Build manifest missing");
317
298
  }
318
299
 
319
300
  if (this.#isDev && this.#manifest) {
320
- try {
321
- const mtime = statSync(this.#manifestPath).mtimeMs;
322
- if (mtime !== this.#manifestMtime) {
323
- this.#manifest = null;
324
- }
325
- } catch {}
301
+ if (stat.mtimeMs !== this.#manifestMtime) {
302
+ this.#manifest = null;
303
+ }
326
304
  }
327
305
 
328
306
  if (!this.#manifest) {
329
- this.#manifest = JSON.parse(readFileSync(this.#manifestPath, "utf8"));
330
- this.#manifestMtime = statSync(this.#manifestPath).mtimeMs;
307
+ const raw = readFileSync(this.#manifestPath, "utf8");
308
+ this.#manifestMtime = stat.mtimeMs;
309
+ this.#manifest = JSON.parse(raw);
331
310
  }
332
311
 
333
312
  return this.#manifest;
@@ -342,7 +321,8 @@ class View {
342
321
  const resolvedPath = path.resolve(viewPath);
343
322
  const resolvedBase = path.resolve(baseDir);
344
323
 
345
- if (!resolvedPath.startsWith(resolvedBase + path.sep)) {
324
+ const rel = path.relative(resolvedBase, resolvedPath);
325
+ if (!rel || rel.startsWith("..") || path.isAbsolute(rel)) {
346
326
  throw new Error("Invalid view path");
347
327
  }
348
328
 
@@ -351,10 +331,16 @@ class View {
351
331
  }
352
332
 
353
333
  const nonce = randomBytes(16).toString("hex");
334
+ const translations = Lang.getFilteredTranslations(
335
+ fastifyRequest?.locale || Config.get("app.locale", "en"),
336
+ entry?.translationKeys
337
+ );
338
+
354
339
  const ctx = {
355
340
  nonce,
356
341
  csrf,
357
- props: {},
342
+ locale: fastifyRequest?.locale || Config.get("app.locale", "en"),
343
+ translations,
358
344
  request: fastifyRequest ? {
359
345
  path: (fastifyRequest.url || '').split('?')[0],
360
346
  method: fastifyRequest.method || 'GET',
@@ -366,6 +352,7 @@ class View {
366
352
  isAjax: fastifyRequest.headers?.['x-requested-with'] === 'XMLHttpRequest',
367
353
  session: fastifyRequest.session || null,
368
354
  auth: fastifyRequest.auth || null,
355
+ locale: fastifyRequest.locale || Config.get("app.locale", "en"),
369
356
  } : null
370
357
  };
371
358
 
@@ -389,66 +376,33 @@ class View {
389
376
  }
390
377
 
391
378
  let html = "";
392
- let collectedProps = {};
379
+ let flightPayload = null;
380
+
381
+ // Strip unused props from Flight payload for client components.
382
+ // Build-time AST analysis determines which props the component
383
+ // actually accesses — anything else is filtered out here.
384
+ const safeParams = toPlainProps(PropFilter.apply(params, entry?.propUsage));
393
385
 
394
386
  try {
395
387
  const Component = mod.default;
396
- let element;
397
388
 
398
- if (Component[MARK]) {
399
- const tracker = trackProps(params);
400
-
401
- if (Component.__propHints) {
402
- for (const hint of Component.__propHints) {
403
- if (resolveExists(params, hint)) {
404
- tracker.accessed.add(hint);
405
- }
406
- }
407
- }
408
-
409
- ctx.props[":R0:"] = params;
410
- ctx.trackers = ctx.trackers || {};
411
- ctx.trackers[":R0:"] = tracker.accessed;
412
-
413
- element = React.createElement(
414
- "div",
415
- {
416
- "data-cid": ":R0:",
417
- "data-island": Component.displayName || Component.name || "Anonymous"
418
- },
419
- React.createElement(Component, tracker.proxy)
420
- );
421
- } else {
422
- element = Component(params);
423
- if (element && typeof element.then === "function") {
424
- element = await element;
425
- }
426
- }
427
-
428
- if (layoutModules.length > 0) {
429
- element = React.createElement("div", { "data-nitron-slot": "page" }, element);
430
- }
389
+ let element = React.createElement(Component, safeParams);
431
390
 
432
391
  for (let i = layoutModules.length - 1; i >= 0; i--) {
433
392
  const LayoutComponent = layoutModules[i].default;
434
- element = LayoutComponent({ children: element });
435
-
436
- if (element && typeof element.then === "function") {
437
- element = await element;
438
- }
393
+ element = React.createElement(LayoutComponent, { children: element });
439
394
  }
440
395
 
441
- html = await this.#renderToHtml(element);
396
+ // Dual render: HTML for initial paint + Flight payload for hydration
397
+ const clientManifest = ClientManifest.get();
442
398
 
443
- if (ctx.trackers) {
444
- for (const [id, accessed] of Object.entries(ctx.trackers)) {
445
- if (ctx.props[id]) {
446
- ctx.props[id] = this.#pickProps(ctx.props[id], accessed);
447
- }
448
- }
449
- }
399
+ const [htmlResult, payloadResult] = await Promise.all([
400
+ this.#renderToHtml(element),
401
+ FlightRenderer.render(element, clientManifest)
402
+ ]);
450
403
 
451
- collectedProps = ctx.props;
404
+ html = htmlResult;
405
+ flightPayload = payloadResult;
452
406
  } catch (error) {
453
407
  const componentName = mod.default?.displayName || mod.default?.name || "Unknown";
454
408
  const errorDetails = this.#parseReactError(error);
@@ -466,13 +420,14 @@ class View {
466
420
  throw error;
467
421
  }
468
422
 
469
- const mergedMeta = this.#mergeMeta(layoutModules, mod.Meta, params);
423
+ const mergedMeta = this.#mergeMeta(layoutModules, mod.Meta, safeParams);
470
424
 
471
425
  return {
472
426
  html,
473
427
  nonce,
474
428
  meta: mergedMeta,
475
- props: collectedProps
429
+ flightPayload,
430
+ translations
476
431
  };
477
432
  });
478
433
  }
@@ -503,7 +458,7 @@ class View {
503
458
  ? resolvedViewMeta.title.default
504
459
  : resolvedViewMeta.title;
505
460
  if (viewTitle) {
506
- result.title = result.title.template.replace("%s", viewTitle);
461
+ result.title = result.title.template.replaceAll("%s", viewTitle);
507
462
  }
508
463
  } else {
509
464
  result[key] = resolvedViewMeta[key];
@@ -581,106 +536,6 @@ class View {
581
536
  return cached.mod.__factory ? await cached.mod.default() : cached.mod;
582
537
  }
583
538
 
584
- static #sanitizeProps(obj, seen = new WeakSet()) {
585
- if (obj == null) {
586
- return obj;
587
- }
588
-
589
- const type = typeof obj;
590
-
591
- if (type === "function" || type === "symbol") {
592
- return undefined;
593
- }
594
-
595
- if (type === "bigint") {
596
- return obj.toString();
597
- }
598
-
599
- if (type !== "object") {
600
- return obj;
601
- }
602
-
603
- if (seen.has(obj)) {
604
- return undefined;
605
- }
606
- seen.add(obj);
607
-
608
- if (Array.isArray(obj)) {
609
- return obj.map(item => this.#sanitizeProps(item, seen) ?? null);
610
- }
611
-
612
- if (obj instanceof Date) {
613
- return obj.toISOString();
614
- }
615
-
616
- if (obj._attributes && typeof obj._attributes === "object") {
617
- return this.#sanitizeProps(obj._attributes, seen);
618
- }
619
-
620
- if (typeof obj.toJSON === "function") {
621
- return this.#sanitizeProps(obj.toJSON(), seen);
622
- }
623
-
624
- const proto = Object.getPrototypeOf(obj);
625
- if (proto !== Object.prototype && proto !== null) {
626
- return undefined;
627
- }
628
-
629
- const result = {};
630
- for (const key of Object.keys(obj)) {
631
- if (UNSAFE_KEYS.has(key)) {
632
- continue;
633
- }
634
- const value = this.#sanitizeProps(obj[key], seen);
635
- if (value !== undefined) {
636
- result[key] = value;
637
- }
638
- }
639
- return result;
640
- }
641
-
642
- static #pickProps(props, accessed) {
643
- const paths = [...accessed].sort();
644
- const leaves = [];
645
-
646
- for (let i = 0; i < paths.length; i++) {
647
- const isLeaf = i === paths.length - 1 || !paths[i + 1].startsWith(paths[i] + ".");
648
-
649
- if (isLeaf) {
650
- leaves.push(paths[i]);
651
- }
652
- }
653
-
654
- const result = {};
655
-
656
- for (const leaf of leaves) {
657
- const parts = leaf.split(".");
658
- let src = props;
659
- let dst = result;
660
-
661
- for (let i = 0; i < parts.length - 1; i++) {
662
- if (src == null) break;
663
-
664
- const nextSrc = src[parts[i]];
665
-
666
- if (dst[parts[i]] == null) {
667
- dst[parts[i]] = Array.isArray(nextSrc) ? [] : {};
668
- }
669
-
670
- src = nextSrc;
671
- dst = dst[parts[i]];
672
- }
673
-
674
- const last = parts[parts.length - 1];
675
-
676
- if (src != null && !(Array.isArray(dst) && last === "length")) {
677
- dst[last] = src[last];
678
- }
679
- }
680
-
681
- return this.#sanitizeProps(result);
682
- }
683
-
684
539
  static #renderToHtml(element) {
685
540
  return new Promise((resolve, reject) => {
686
541
  const chunks = [];
@@ -706,6 +561,7 @@ class View {
706
561
  const error = new Error("Render timeout - component took too long to render");
707
562
  error.statusCode = 500;
708
563
  renderError = error;
564
+ stream.destroy();
709
565
  finish(null);
710
566
  }, timeout);
711
567
 
@@ -733,14 +589,16 @@ class View {
733
589
  });
734
590
  }
735
591
 
736
- static async #renderError(res, statusCode, viewName, params) {
592
+ static async #renderError(req, res, statusCode, viewName, params) {
737
593
  try {
738
594
  const entry = this.#getEntry("framework", viewName);
739
- const { html, nonce, meta, props } = await this.#render(
595
+ const { html, nonce, meta, flightPayload, translations } = await this.#render(
740
596
  this.#frameworkViews,
741
597
  viewName,
742
598
  params,
743
- ""
599
+ "",
600
+ req,
601
+ entry
744
602
  );
745
603
 
746
604
  this.#setSecurityHeaders(res, nonce);
@@ -748,144 +606,110 @@ class View {
748
606
  return res
749
607
  .code(statusCode)
750
608
  .type("text/html")
751
- .send(this.#buildPage(entry, html, nonce, "", meta, props));
752
- } catch (error) {
609
+ .send(this.#buildPage(entry, html, nonce, "", meta, flightPayload, null, translations));
610
+ }
611
+ catch (error) {
753
612
  Log.error(`Failed to render ${viewName}`, { error: error.message });
754
613
  const message = statusCode === 404 ? "Not Found" : "Internal Server Error";
755
614
  return res.code(statusCode).send(message);
756
615
  }
757
616
  }
758
617
 
759
- static #buildPage(entry, html, nonce, csrf = "", meta = null, props = {}) {
760
- let processedMeta = meta || {};
618
+ static async #renderDevError(err, req, res, statusCode) {
619
+ try {
620
+ const { default: DevErrorPage } = await import("../Dev/DevErrorPage.js");
621
+ const nonce = randomBytes(16).toString("hex");
622
+ const devCtx = req.__devCtx || {};
761
623
 
762
- if (meta?.title && typeof meta.title === "object") {
763
- processedMeta = {
764
- ...meta,
765
- title: meta.title.default || "NitronJS"
624
+ devCtx.request = {
625
+ url: req.url,
626
+ method: req.method,
627
+ headers: { ...req.headers },
628
+ query: req.query || {},
629
+ params: req.params || {},
630
+ body: req.body || null,
631
+ cookies: req.cookies || {},
632
+ ip: req.ip
766
633
  };
634
+
635
+ const html = DevErrorPage.render(err, devCtx, nonce);
636
+
637
+ this.#setSecurityHeaders(res, nonce);
638
+
639
+ return res
640
+ .code(statusCode)
641
+ .type("text/html")
642
+ .send(html);
767
643
  }
644
+ catch (fallbackError) {
645
+ Log.error("Dev error page failed", { error: fallbackError.message });
646
+ return res.code(statusCode).send("Internal Server Error");
647
+ }
648
+ }
768
649
 
650
+ static #buildPage(entry, html, nonce, csrf = "", meta = null, flightPayload = null, devData = null, translations = null) {
769
651
  const cssFiles = (entry?.css || []).map(href => `/storage${href}`);
770
652
 
771
653
  return this.#generateHtml({
772
654
  html,
773
- meta: processedMeta,
655
+ meta: meta || {},
774
656
  css: cssFiles,
775
- hydrationScript: entry?.hydrationScript,
776
657
  layouts: entry?.layouts || [],
777
658
  nonce,
778
659
  csrf,
779
- props
660
+ flightPayload,
661
+ devData,
662
+ translations
780
663
  });
781
664
  }
782
665
 
783
- static #getUsedRoutes(script) {
784
- if (!script) {
785
- return [];
786
- }
787
-
788
- if (this.#isDev) {
789
- this.#routesCache = new Map();
790
- this.#scanBundles();
791
- } else if (!this.#routesCache) {
792
- this.#routesCache = new Map();
793
- this.#scanBundles();
794
- }
795
-
796
- return this.#routesCache.get(script) || [];
797
- }
798
-
799
- static #scanBundles() {
800
- const jsDir = path.join(this.#root, "storage/app/public/js");
801
-
802
- if (!existsSync(jsDir)) {
803
- return;
804
- }
805
-
806
- const scan = (dir) => {
807
- for (const entry of readdirSync(dir, { withFileTypes: true })) {
808
- const fullPath = path.join(dir, entry.name);
809
-
810
- if (entry.isDirectory()) {
811
- scan(fullPath);
812
- continue;
813
- }
814
-
815
- if (!entry.name.endsWith(".js") || entry.name === "vendor.js") {
816
- continue;
817
- }
818
-
819
- const content = readFileSync(fullPath, "utf8");
820
-
821
- // Match both old format (routes["name"]) and new format (route("name"))
822
- const oldMatches = [...content.matchAll(/routes\["([^"]+)"\]/g)];
823
- const newMatches = [...content.matchAll(/route\s*\(\s*['"]([^'"]+)['"]/g)];
824
-
825
- const routes = [
826
- ...oldMatches.map(match => match[1]),
827
- ...newMatches.map(match => match[1])
828
- ];
829
-
830
- const relativePath = "/js/" + path.relative(jsDir, fullPath).replace(/\\/g, "/");
831
-
832
- this.#routesCache.set(relativePath, [...new Set(routes)]);
833
- }
834
- };
835
-
836
- scan(jsDir);
837
- }
838
-
839
- static #generateHtml({ html, meta, css, hydrationScript, layouts, nonce, csrf, props }) {
666
+ static #generateHtml({ html, meta, css, layouts, nonce, csrf, flightPayload, devData, translations }) {
840
667
  const nonceAttr = nonce ? ` nonce="${escapeHtml(nonce)}"` : "";
841
- const hasHydration = !!hydrationScript;
842
-
843
- const allRoutes = Router.getClientManifest();
844
- const usedRouteNames = hasHydration ? this.#getUsedRoutes(hydrationScript) : [];
845
- const routes = {};
846
- for (const name of usedRouteNames) {
847
- if (allRoutes[name]) routes[name] = allRoutes[name];
848
- }
668
+ const hasFlightPayload = !!flightPayload;
849
669
 
850
670
  const runtimeData = {
851
671
  csrf: csrf || "",
852
- routes,
672
+ routes: Router.getClientManifest(),
853
673
  layouts: layouts || []
854
674
  };
855
675
 
856
- let runtimeScript;
857
- if (hasHydration) {
858
- const propsJson = JSON.stringify(props || {}).replace(/</g, "\\u003c").replace(/>/g, "\\u003e");
859
- runtimeScript = `<script${nonceAttr}>window.__NITRON_RUNTIME__=${JSON.stringify(runtimeData)};window.__NITRON_PROPS__=${propsJson};</script>`;
860
- } else {
861
- runtimeScript = `<script${nonceAttr}>window.__NITRON_RUNTIME__=${JSON.stringify(runtimeData)};</script>`;
676
+ let runtimeScript = `<script${nonceAttr}>window.__NITRON_RUNTIME__=${JSON.stringify(runtimeData)};`;
677
+
678
+ if (hasFlightPayload) {
679
+ const escapedPayload = flightPayload.replace(/</g, "\\u003c").replace(/>/g, "\\u003e");
680
+ runtimeScript += `window.__NITRON_FLIGHT__=${JSON.stringify(escapedPayload)};`;
862
681
  }
863
682
 
864
- const vendorScript = hasHydration || this.#isDev
865
- ? `<script src="/storage/js/vendor.js"${nonceAttr}></script>`
866
- : "";
683
+ if (translations && Object.keys(translations).length > 0) {
684
+ const escapedTranslations = JSON.stringify(translations).replace(/</g, "\\u003c").replace(/>/g, "\\u003e");
685
+ runtimeScript += `window.__NITRON_TRANSLATIONS__=${escapedTranslations};`;
686
+ }
687
+
688
+ runtimeScript += `</script>`;
689
+
690
+ const vendorScript = `<script src="/storage/js/vendor.js"${nonceAttr}></script>`;
867
691
 
868
692
  const hmrScript = this.#isDev
869
693
  ? `<script src="/__nitron_client/socket.io.js"${nonceAttr}></script><script src="/storage/js/hmr.js"${nonceAttr}></script>`
870
694
  : "";
871
695
 
872
- const hydrateScript = hasHydration
873
- ? `<script type="module" src="/storage${hydrationScript}"${nonceAttr}></script>`
696
+ const consumerScript = hasFlightPayload
697
+ ? `<script type="module" src="/storage/js/rsc-consumer.js"${nonceAttr}></script>`
874
698
  : "";
875
699
 
876
700
  const spaScript = `<script${nonceAttr} src="/storage/js/spa.js"></script>`;
877
701
 
878
- const devIndicator = this.#isDev ? this.#generateDevIndicator(nonceAttr) : "";
702
+ const devIndicator = this.#isDev ? this.#generateDevIndicator(nonceAttr, devData) : "";
879
703
 
880
704
  return `<!DOCTYPE html>
881
- <html lang="${meta?.lang || "en"}">
705
+ <html lang="${escapeHtml(meta?.lang || "en")}">
882
706
  <head>
883
707
  ${this.#generateHead(meta, css)}
884
708
  </head>
885
709
  <body>
886
710
  <div id="app">${html}</div>
887
711
  ${runtimeScript}
888
- ${vendorScript}${hmrScript}${hydrateScript}${spaScript}${devIndicator}
712
+ ${vendorScript}${hmrScript}${consumerScript}${spaScript}${devIndicator}
889
713
  </body>
890
714
  </html>`;
891
715
  }
@@ -906,7 +730,11 @@ ${vendorScript}${hmrScript}${hydrateScript}${spaScript}${devIndicator}
906
730
  }
907
731
 
908
732
  if (meta.favicon) {
909
- parts.push(`<link rel="icon" href="${escapeHtml(meta.favicon)}">`);
733
+ const faviconLower = meta.favicon.toLowerCase().trim();
734
+
735
+ if (faviconLower.startsWith("/") || faviconLower.startsWith("https://") || faviconLower.startsWith("http://")) {
736
+ parts.push(`<link rel="icon" href="${escapeHtml(meta.favicon)}">`);
737
+ }
910
738
  }
911
739
 
912
740
  for (const href of css) {
@@ -916,124 +744,9 @@ ${vendorScript}${hmrScript}${hydrateScript}${spaScript}${devIndicator}
916
744
  return parts.join("\n");
917
745
  }
918
746
 
919
- static #generateDevIndicator(nonceAttr) {
920
- const nodeVersion = process.version;
921
- const port = Config.get("server.port", 3000);
922
-
923
- return `
924
- <div id="__nitron_dev__" style="position:fixed;bottom:16px;left:16px;z-index:999999;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;">
925
- <style${nonceAttr}>
926
- #__nitron_dev__ * { box-sizing: border-box; }
927
- #__nitron_dev_btn__ {
928
- display:flex;align-items:center;gap:8px;padding:8px 14px;
929
- background:#0a0a0a;
930
- color:#fff;border:1px solid #1f1f1f;border-radius:10px;
931
- cursor:pointer;box-shadow:0 4px 16px rgba(0,0,0,0.5);
932
- transition:all 0.2s ease;font-size:13px;font-weight:500;
933
- }
934
- #__nitron_dev_btn__:hover {
935
- background:#111;border-color:#2a2a2a;
936
- box-shadow:0 6px 24px rgba(0,0,0,0.6);
937
- }
938
- #__nitron_dev_btn__ .logo {
939
- width:20px;height:20px;
940
- display:flex;align-items:center;justify-content:center;
941
- }
942
- #__nitron_dev_btn__ .logo svg { width:20px;height:20px; }
943
- #__nitron_dev_btn__ .dot { width:6px;height:6px;border-radius:50%;animation:pulse 2s infinite; }
944
- @keyframes pulse { 0%,100%{opacity:1;} 50%{opacity:0.4;} }
945
- #__nitron_dev_panel__ {
946
- display:none;position:absolute;bottom:48px;left:0;width:260px;
947
- background:#0a0a0a;
948
- border:1px solid #1f1f1f;border-radius:12px;
949
- box-shadow:0 16px 48px rgba(0,0,0,0.6);
950
- overflow:hidden;animation:slideUp 0.15s ease-out;
951
- }
952
- @keyframes slideUp { from{opacity:0;transform:translateY(8px);} to{opacity:1;transform:translateY(0);} }
953
- #__nitron_dev_panel__ .header {
954
- padding:14px 16px;background:#111;
955
- border-bottom:1px solid #1f1f1f;display:flex;align-items:center;gap:10px;
956
- }
957
- #__nitron_dev_panel__ .header .icon {
958
- width:28px;height:28px;
959
- border-radius:6px;display:flex;align-items:center;justify-content:center;
960
- }
961
- #__nitron_dev_panel__ .header .info { display:flex;flex-direction:column; }
962
- #__nitron_dev_panel__ .header .title { font-size:14px;font-weight:600;color:#fff;line-height:1.2; }
963
- #__nitron_dev_panel__ .header .version { font-size:11px;color:#666;line-height:1.2; }
964
- #__nitron_dev_panel__ .header .badge {
965
- margin-left:auto;padding:3px 8px;background:#1a1a1a;
966
- color:#4ade80;font-size:10px;font-weight:600;border-radius:4px;text-transform:uppercase;
967
- border:1px solid #2a2a2a;
968
- }
969
- #__nitron_dev_panel__ .body { padding:12px 16px; }
970
- #__nitron_dev_panel__ .row {
971
- display:flex;justify-content:space-between;align-items:center;
972
- padding:7px 0;border-bottom:1px solid #1a1a1a;
973
- }
974
- #__nitron_dev_panel__ .row:last-child { border-bottom:none; }
975
- #__nitron_dev_panel__ .row .label { color:#666;font-size:12px; }
976
- #__nitron_dev_panel__ .row .value { color:#999;font-size:12px;font-weight:500;display:flex;align-items:center;gap:6px; }
977
- #__nitron_dev_panel__ .row .status { width:6px;height:6px;border-radius:50%; }
978
- #__nitron_dev_panel__ .row .status.ok { background:#4ade80; }
979
- #__nitron_dev_panel__ .row .status.warn { background:#fbbf24; }
980
- #__nitron_dev_panel__ .row .status.err { background:#ef4444; }
981
- #__nitron_dev_panel__ .footer {
982
- padding:10px 16px;background:#080808;border-top:1px solid #1a1a1a;
983
- display:flex;gap:6px;
984
- }
985
- #__nitron_dev_panel__ .footer a {
986
- flex:1;padding:6px;background:#111;border:1px solid #1f1f1f;
987
- border-radius:6px;color:#666;font-size:10px;text-decoration:none;text-align:center;
988
- transition:all 0.15s;
989
- }
990
- #__nitron_dev_panel__ .footer a:hover { background:#1a1a1a;color:#999;border-color:#2a2a2a; }
991
- </style>
992
- <button id="__nitron_dev_btn__">
993
- <span class="logo"><img src="/__nitron/icon.png" alt="N" style="width:20px;height:20px;"></span>
994
- <span style="color:#888;">NitronJS</span>
995
- <span class="dot" id="__nitron_status_dot__" style="background:#4ade80;"></span>
996
- </button>
997
- <div id="__nitron_dev_panel__" style="display:none;">
998
- <div class="header">
999
- <div class="icon"><img src="/__nitron/icon.png" alt="N" style="width:28px;height:28px;"></div>
1000
- <div class="info">
1001
- <span class="title">NitronJS</span>
1002
- <span class="version">v${FRAMEWORK_VERSION}</span>
1003
- </div>
1004
- <span class="badge">dev</span>
1005
- </div>
1006
- <div class="body">
1007
- <div class="row"><span class="label">HMR</span><span class="value"><span class="status" id="__nitron_hmr_ind__"></span><span id="__nitron_hmr_txt__">Checking...</span></span></div>
1008
- <div class="row"><span class="label">Server</span><span class="value">localhost:${port}</span></div>
1009
- <div class="row"><span class="label">Node.js</span><span class="value">${nodeVersion}</span></div>
1010
- </div>
1011
- <div class="footer">
1012
- <a href="/__nitron/routes" target="_blank">Routes</a>
1013
- <a href="/__nitron/info" target="_blank">Info</a>
1014
- <a href="https://nitronjs.dev/docs" target="_blank">Docs</a>
1015
- </div>
1016
- </div>
1017
- </div>
1018
- <script${nonceAttr}>
1019
- (function(){
1020
- var btn=document.getElementById('__nitron_dev_btn__');
1021
- var panel=document.getElementById('__nitron_dev_panel__');
1022
- var dot=document.getElementById('__nitron_status_dot__');
1023
- var hmrInd=document.getElementById('__nitron_hmr_ind__');
1024
- var hmrTxt=document.getElementById('__nitron_hmr_txt__');
1025
- btn.onclick=function(e){e.stopPropagation();panel.style.display=panel.style.display==='none'?'block':'none';};
1026
- document.addEventListener('click',function(e){if(!e.target.closest('#__nitron_dev__'))panel.style.display='none';});
1027
- function updateHmr(){
1028
- var connected=window.__nitron_hmr_connected__;
1029
- hmrInd.className='status '+(connected?'ok':'err');
1030
- hmrTxt.textContent=connected?'Connected':'Disconnected';
1031
- dot.style.background=connected?'#4ade80':'#ef4444';
1032
- }
1033
- if(typeof io!=='undefined'){setInterval(updateHmr,1000);updateHmr();}
1034
- else{hmrInd.className='status warn';hmrTxt.textContent='Not Available';}
1035
- })();
1036
- </script>`;
747
+ static #generateDevIndicator(nonceAttr, devData = null) {
748
+ if (!this.#devIndicatorModule) return "";
749
+ return this.#devIndicatorModule.render(nonceAttr, devData);
1037
750
  }
1038
751
 
1039
752
  static #setSecurityHeaders(res, nonce) {
@@ -1044,11 +757,12 @@ ${vendorScript}${hmrScript}${hydrateScript}${spaScript}${devIndicator}
1044
757
  // Example: csp: ["https://fonts.googleapis.com", "https://fonts.gstatic.com"]
1045
758
  // Or detailed format: csp: { styles: [...], fonts: [...] }
1046
759
  const isSimpleFormat = Array.isArray(cspConfig);
1047
-
760
+ const sanitizeCspValue = (v) => String(v).replace(/[;\r\n,]/g, "").trim();
761
+
1048
762
  let whitelist;
1049
763
  if (isSimpleFormat) {
1050
764
  // Simple format: apply URLs to all relevant directives
1051
- const urls = cspConfig.filter(u => u !== "*");
765
+ const urls = cspConfig.filter(u => u !== "*").map(sanitizeCspValue);
1052
766
  const hasWildcard = cspConfig.includes("*");
1053
767
  whitelist = {
1054
768
  styles: hasWildcard ? ["*"] : urls,
@@ -1058,14 +772,16 @@ ${vendorScript}${hmrScript}${hydrateScript}${spaScript}${devIndicator}
1058
772
  connect: hasWildcard ? ["*"] : urls,
1059
773
  frames: hasWildcard ? ["*"] : [],
1060
774
  };
1061
- } else {
775
+ }
776
+ else {
777
+ const sanitizeList = (arr) => (arr || []).map(sanitizeCspValue);
1062
778
  whitelist = {
1063
- styles: cspConfig.styles || [],
1064
- fonts: cspConfig.fonts || [],
1065
- images: cspConfig.images || [],
1066
- scripts: cspConfig.scripts || [],
1067
- connect: cspConfig.connect || [],
1068
- frames: cspConfig.frames || [],
779
+ styles: sanitizeList(cspConfig.styles),
780
+ fonts: sanitizeList(cspConfig.fonts),
781
+ images: sanitizeList(cspConfig.images),
782
+ scripts: sanitizeList(cspConfig.scripts),
783
+ connect: sanitizeList(cspConfig.connect),
784
+ frames: sanitizeList(cspConfig.frames),
1069
785
  };
1070
786
  }
1071
787
 
@@ -1106,4 +822,23 @@ ${vendorScript}${hmrScript}${hydrateScript}${spaScript}${devIndicator}
1106
822
  }
1107
823
  }
1108
824
 
825
+ /**
826
+ * Recursively converts Model instances (Proxy-wrapped objects with toJSON)
827
+ * to plain objects so RSC Flight serializer can process them.
828
+ */
829
+ function toPlainProps(value) {
830
+ if (value == null || typeof value !== "object") return value;
831
+ if (value instanceof Date) return value;
832
+ if (Array.isArray(value)) return value.map(toPlainProps);
833
+ if (typeof value.toJSON === "function") return toPlainProps(value.toJSON());
834
+
835
+ const result = {};
836
+
837
+ for (const [key, val] of Object.entries(value)) {
838
+ result[key] = toPlainProps(val);
839
+ }
840
+
841
+ return result;
842
+ }
843
+
1109
844
  export default View;