@nitronjs/framework 0.2.18 → 0.2.19

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/cli/njs.js CHANGED
@@ -1,4 +1,4 @@
1
- #!/usr/bin/env node
1
+ #!/usr/bin/env node
2
2
 
3
3
  const COLORS = {
4
4
  reset: "\x1b[0m",
@@ -0,0 +1,132 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import { init, parse } from "es-module-lexer";
4
+
5
+ await init;
6
+
7
+ /**
8
+ * Transforms a built view module into an async factory function.
9
+ * Keeps imports at module level, wraps body code in a factory
10
+ * that re-executes per request. Skips "use client" modules.
11
+ */
12
+ function transformFile(filePath) {
13
+ const code = fs.readFileSync(filePath, "utf8");
14
+ const trimmed = code.trimStart();
15
+
16
+ if (trimmed.startsWith('"use client"') || trimmed.startsWith("'use client'")) {
17
+ return false;
18
+ }
19
+
20
+ let imports;
21
+
22
+ try {
23
+ [imports] = parse(code);
24
+ }
25
+ catch {
26
+ return false;
27
+ }
28
+
29
+ // Collect static import statement ranges
30
+ const importRanges = [];
31
+
32
+ for (const imp of imports) {
33
+ if (imp.d === -1) {
34
+ importRanges.push({ start: imp.ss, end: imp.se });
35
+ }
36
+ }
37
+
38
+ importRanges.sort((a, b) => a.start - b.start);
39
+
40
+ // Find last `export { ... };` block
41
+ let exportStart = code.length;
42
+
43
+ while (true) {
44
+ exportStart = code.lastIndexOf("export", exportStart - 1);
45
+ if (exportStart === -1) return false;
46
+ if (code.slice(exportStart + 6).trimStart().startsWith("{")) break;
47
+ }
48
+
49
+ const exportClose = code.indexOf("};", exportStart);
50
+
51
+ if (exportClose === -1) return false;
52
+
53
+ const exportEnd = exportClose + 2;
54
+ const exportInner = code.slice(exportStart, exportEnd).match(/\{([\s\S]*)\}/)?.[1];
55
+
56
+ if (!exportInner) return false;
57
+
58
+ // Build return statement from export specifiers
59
+ const returnParts = [];
60
+
61
+ for (const spec of exportInner.split(",").map(s => s.trim()).filter(Boolean)) {
62
+ const m = spec.match(/^(.+?)\s+as\s+(.+)$/);
63
+
64
+ if (m) {
65
+ const local = m[1].trim();
66
+ const exported = m[2].trim();
67
+
68
+ returnParts.push(exported === "default" ? `"default": ${local}` : `${exported}: ${local}`);
69
+ }
70
+ else {
71
+ returnParts.push(spec);
72
+ }
73
+ }
74
+
75
+ // Separate imports from body
76
+ const excludes = [...importRanges, { start: exportStart, end: exportEnd }];
77
+
78
+ excludes.sort((a, b) => a.start - b.start);
79
+
80
+ const importLines = importRanges.map(r => code.slice(r.start, r.end).trim());
81
+ const bodyParts = [];
82
+ let cursor = 0;
83
+
84
+ for (const range of excludes) {
85
+ if (cursor < range.start) {
86
+ bodyParts.push(code.slice(cursor, range.start));
87
+ }
88
+ cursor = range.end;
89
+ }
90
+
91
+ const remaining = code.slice(cursor).trim();
92
+
93
+ if (remaining && !remaining.startsWith("//# sourceMappingURL")) {
94
+ bodyParts.push(remaining);
95
+ }
96
+
97
+ const body = bodyParts.join("").trim();
98
+
99
+ // Write transformed file
100
+ const output =
101
+ importLines.join("\n") + "\n\n" +
102
+ "export const __factory = true;\n\n" +
103
+ "export default async function __viewFactory() {\n" +
104
+ body + "\n" +
105
+ "return { " + returnParts.join(", ") + " };\n" +
106
+ "}\n";
107
+
108
+ fs.writeFileSync(filePath, output);
109
+ return true;
110
+ }
111
+
112
+ /**
113
+ * Transforms all built view files in a directory tree.
114
+ */
115
+ function transformDirectory(dir) {
116
+ let count = 0;
117
+
118
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
119
+ const full = path.join(dir, entry.name);
120
+
121
+ if (entry.isDirectory()) {
122
+ count += transformDirectory(full);
123
+ }
124
+ else if (entry.name.endsWith(".js") && !entry.name.endsWith(".map")) {
125
+ if (transformFile(full)) count++;
126
+ }
127
+ }
128
+
129
+ return count;
130
+ }
131
+
132
+ export { transformFile, transformDirectory };
@@ -20,6 +20,7 @@ import {
20
20
  createMarkerPlugin,
21
21
  createServerModuleBlockerPlugin
22
22
  } from "./plugins.js";
23
+ import { transformFile as factoryTransform } from "./FactoryTransform.js";
23
24
  import COLORS from "./colors.js";
24
25
 
25
26
  dotenv.config({ quiet: true });
@@ -334,6 +335,11 @@ class Builder {
334
335
  this.#cache.viewsChanged = true;
335
336
  await this.#runEsbuild(changedFiles, outDir, { meta, outbase: srcDir });
336
337
  await this.#postProcessMeta(changedFiles, srcDir, outDir);
338
+
339
+ for (const entry of changedFiles) {
340
+ const rel = path.relative(srcDir, entry).replace(/\.tsx$/, ".js");
341
+ factoryTransform(path.join(outDir, rel));
342
+ }
337
343
  }
338
344
 
339
345
  this.#stats[namespace === "user" ? "user" : "framework"] = entries.length;
@@ -84,6 +84,35 @@ function wrapWithDepth(children) {
84
84
  return OriginalJsx.jsx(DepthContext.Provider, { value: true, children });
85
85
  }
86
86
 
87
+ // Deep Proxy that tracks which prop paths are accessed during SSR
88
+ function trackProps(obj) {
89
+ const accessed = new Set();
90
+
91
+ function wrap(target, path) {
92
+ if (target === null || typeof target !== 'object') return target;
93
+
94
+ return new Proxy(target, {
95
+ get(t, prop, receiver) {
96
+ if (typeof prop === 'symbol') return Reflect.get(t, prop, receiver);
97
+
98
+ const val = Reflect.get(t, prop, receiver);
99
+ if (typeof val === 'function') return val;
100
+
101
+ const currentPath = path ? path + '.' + String(prop) : String(prop);
102
+ accessed.add(currentPath);
103
+
104
+ if (val !== null && typeof val === 'object') {
105
+ return wrap(val, currentPath);
106
+ }
107
+
108
+ return val;
109
+ }
110
+ });
111
+ }
112
+
113
+ return { proxy: wrap(obj, ''), accessed };
114
+ }
115
+
87
116
  // Creates an island wrapper for client components (hydrated independently)
88
117
  function createIsland(Component, name) {
89
118
  function IslandBoundary(props) {
@@ -92,18 +121,20 @@ function createIsland(Component, name) {
92
121
  }
93
122
 
94
123
  const id = React.useId();
95
- const safeProps = sanitizeProps(props) || {};
124
+ const tracker = trackProps(props);
96
125
 
97
126
  const ctx = getContext();
98
127
  if (ctx) {
99
128
  ctx.props = ctx.props || {};
100
- ctx.props[id] = safeProps;
129
+ ctx.trackers = ctx.trackers || {};
130
+ ctx.props[id] = props;
131
+ ctx.trackers[id] = tracker.accessed;
101
132
  }
102
133
 
103
134
  return OriginalJsx.jsx('div', {
104
135
  'data-cid': id,
105
136
  'data-island': name,
106
- children: wrapWithDepth(OriginalJsx.jsx(Component, props))
137
+ children: wrapWithDepth(OriginalJsx.jsx(Component, tracker.proxy))
107
138
  });
108
139
  }
109
140
 
package/lib/View/View.js CHANGED
@@ -38,6 +38,34 @@ function escapeHtml(str) {
38
38
  return String(str).replace(/[&<>"'`]/g, char => ESC_MAP[char]);
39
39
  }
40
40
 
41
+ function trackProps(obj) {
42
+ const accessed = new Set();
43
+
44
+ function wrap(target, prefix) {
45
+ if (target === null || typeof target !== "object") return target;
46
+
47
+ return new Proxy(target, {
48
+ get(t, prop, receiver) {
49
+ if (typeof prop === "symbol") return Reflect.get(t, prop, receiver);
50
+
51
+ const val = Reflect.get(t, prop, receiver);
52
+ if (typeof val === "function") return val;
53
+
54
+ const currentPath = prefix ? prefix + "." + String(prop) : String(prop);
55
+ accessed.add(currentPath);
56
+
57
+ if (val !== null && typeof val === "object") {
58
+ return wrap(val, currentPath);
59
+ }
60
+
61
+ return val;
62
+ }
63
+ });
64
+ }
65
+
66
+ return { proxy: wrap(obj, ""), accessed };
67
+ }
68
+
41
69
  /**
42
70
  * React SSR view renderer with streaming support.
43
71
  * Handles component rendering, asset injection, and client hydration.
@@ -338,14 +366,19 @@ class View {
338
366
  let element;
339
367
 
340
368
  if (Component[MARK]) {
341
- ctx.props[":R0:"] = this.#sanitizeProps(params);
369
+ const tracker = trackProps(params);
370
+
371
+ ctx.props[":R0:"] = params;
372
+ ctx.trackers = ctx.trackers || {};
373
+ ctx.trackers[":R0:"] = tracker.accessed;
374
+
342
375
  element = React.createElement(
343
376
  "div",
344
377
  {
345
378
  "data-cid": ":R0:",
346
379
  "data-island": Component.displayName || Component.name || "Anonymous"
347
380
  },
348
- React.createElement(Component, params)
381
+ React.createElement(Component, tracker.proxy)
349
382
  );
350
383
  } else {
351
384
  element = Component(params);
@@ -368,6 +401,15 @@ class View {
368
401
  }
369
402
 
370
403
  html = await this.#renderToHtml(element);
404
+
405
+ if (ctx.trackers) {
406
+ for (const [id, accessed] of Object.entries(ctx.trackers)) {
407
+ if (ctx.props[id]) {
408
+ ctx.props[id] = this.#pickProps(ctx.props[id], accessed);
409
+ }
410
+ }
411
+ }
412
+
371
413
  collectedProps = ctx.props;
372
414
  } catch (error) {
373
415
  const componentName = mod.default?.displayName || mod.default?.name || "Unknown";
@@ -478,17 +520,27 @@ class View {
478
520
 
479
521
  static async #importModule(filePath) {
480
522
  const url = pathToFileURL(filePath).href;
523
+ let cached = this.#moduleCache.get(filePath);
481
524
 
482
525
  if (this.#isDev) {
483
- return import(url + `?t=${Date.now()}`);
526
+ const mtime = statSync(filePath).mtimeMs;
527
+
528
+ if (!cached || cached.mtime !== mtime) {
529
+ const mod = await import(url + `?v=${mtime}`);
530
+ cached = { mod, mtime };
531
+ this.#moduleCache.set(filePath, cached);
532
+ }
533
+
534
+ return cached.mod.__factory ? await cached.mod.default() : cached.mod;
484
535
  }
485
536
 
486
- let mod = this.#moduleCache.get(filePath);
487
- if (!mod) {
488
- mod = await import(url);
489
- this.#moduleCache.set(filePath, mod);
537
+ if (!cached) {
538
+ const mod = await import(url);
539
+ cached = { mod };
540
+ this.#moduleCache.set(filePath, cached);
490
541
  }
491
- return mod;
542
+
543
+ return cached.mod.__factory ? await cached.mod.default() : cached.mod;
492
544
  }
493
545
 
494
546
  static #sanitizeProps(obj, seen = new WeakSet()) {
@@ -549,6 +601,48 @@ class View {
549
601
  return result;
550
602
  }
551
603
 
604
+ static #pickProps(props, accessed) {
605
+ const paths = [...accessed].sort();
606
+ const leaves = [];
607
+
608
+ for (let i = 0; i < paths.length; i++) {
609
+ const isLeaf = i === paths.length - 1 || !paths[i + 1].startsWith(paths[i] + ".");
610
+
611
+ if (isLeaf) {
612
+ leaves.push(paths[i]);
613
+ }
614
+ }
615
+
616
+ const result = {};
617
+
618
+ for (const leaf of leaves) {
619
+ const parts = leaf.split(".");
620
+ let src = props;
621
+ let dst = result;
622
+
623
+ for (let i = 0; i < parts.length - 1; i++) {
624
+ if (src == null) break;
625
+
626
+ const nextSrc = src[parts[i]];
627
+
628
+ if (dst[parts[i]] == null) {
629
+ dst[parts[i]] = Array.isArray(nextSrc) ? [] : {};
630
+ }
631
+
632
+ src = nextSrc;
633
+ dst = dst[parts[i]];
634
+ }
635
+
636
+ const last = parts[parts.length - 1];
637
+
638
+ if (src != null && !(Array.isArray(dst) && last === "length")) {
639
+ dst[last] = src[last];
640
+ }
641
+ }
642
+
643
+ return this.#sanitizeProps(result);
644
+ }
645
+
552
646
  static #renderToHtml(element) {
553
647
  return new Promise((resolve, reject) => {
554
648
  const chunks = [];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nitronjs/framework",
3
- "version": "0.2.18",
3
+ "version": "0.2.19",
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"
@@ -27,6 +27,7 @@
27
27
  "bcrypt": "^5.1.1",
28
28
  "chokidar": "^5.0.0",
29
29
  "dotenv": "^17.2.3",
30
+ "es-module-lexer": "^2.0.0",
30
31
  "esbuild": "^0.27.2",
31
32
  "fastify": "^5.6.2",
32
33
  "mysql2": "^3.16.0",