@nitronjs/framework 0.2.27 → 0.3.1

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 (58) 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 +354 -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 +171 -23
  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 +103 -1
  38. package/lib/Session/Session.js +0 -4
  39. package/lib/Support/Str.js +6 -4
  40. package/lib/Translation/Lang.js +376 -32
  41. package/lib/Translation/pluralize.js +81 -0
  42. package/lib/Validation/MagicBytes.js +120 -0
  43. package/lib/Validation/Validator.js +46 -29
  44. package/lib/View/Client/hmr-client.js +100 -90
  45. package/lib/View/Client/spa.js +121 -50
  46. package/lib/View/ClientManifest.js +60 -0
  47. package/lib/View/FlightRenderer.js +100 -0
  48. package/lib/View/Layout.js +0 -3
  49. package/lib/View/PropFilter.js +81 -0
  50. package/lib/View/View.js +230 -495
  51. package/lib/index.d.ts +22 -1
  52. package/package.json +2 -2
  53. package/skeleton/config/app.js +1 -0
  54. package/skeleton/config/server.js +13 -0
  55. package/skeleton/config/session.js +3 -0
  56. package/lib/Build/HydrationBuilder.js +0 -190
  57. package/lib/Console/Stubs/page-hydration-dev.tsx +0 -72
  58. package/lib/Console/Stubs/page-hydration.tsx +0 -53
@@ -10,17 +10,19 @@ import Layout from "../View/Layout.js";
10
10
  import JSX_RUNTIME from "./jsxRuntime.js";
11
11
  import FileAnalyzer from "./FileAnalyzer.js";
12
12
  import CssBuilder from "./CssBuilder.js";
13
- import HydrationBuilder from "./HydrationBuilder.js";
14
13
  import {
15
14
  createPathAliasPlugin,
16
15
  createOriginalJsxPlugin,
17
16
  createVendorGlobalsPlugin,
18
17
  createServerFunctionsPlugin,
19
18
  createCssStubPlugin,
20
- createMarkerPlugin,
21
- createServerModuleBlockerPlugin
19
+ createServerModuleBlockerPlugin,
20
+ createClientReferencePlugin,
21
+ createPropFilterPlugin
22
22
  } from "./plugins.js";
23
23
  import { transformFile as factoryTransform } from "./FactoryTransform.js";
24
+ import PropUsageAnalyzer from "./PropUsageAnalyzer.js";
25
+ import EffectivePropUsage from "./EffectivePropUsage.js";
24
26
  import COLORS from "./colors.js";
25
27
 
26
28
  dotenv.config({ quiet: true });
@@ -43,21 +45,21 @@ class Builder {
43
45
  css: new Map(),
44
46
  cssHashes: new Map(),
45
47
  viewHashes: new Map(),
46
- hydrationTemplate: null,
47
- hydrationTemplateDev: null,
48
48
  vendorBuilt: false,
49
49
  spaBuilt: false,
50
50
  hmrBuilt: false,
51
+ rscConsumerBuilt: false,
51
52
  tailwindProcessor: null,
52
53
  viewsChanged: false,
53
54
  fileMeta: new Map(),
54
- fileHashes: new Map()
55
+ fileHashes: new Map(),
56
+ propAnalysis: new Map()
55
57
  };
56
58
  #diskCachePath = path.join(Paths.nitronTemp, "build-cache.json");
57
59
  #changedFiles = new Set();
60
+ #devBuiltOnce = false;
58
61
  #analyzer;
59
62
  #cssBuilder;
60
- #hydrationBuilder;
61
63
 
62
64
  constructor() {
63
65
  this.#paths = {
@@ -74,8 +76,7 @@ class Builder {
74
76
  };
75
77
 
76
78
  this.#analyzer = new FileAnalyzer(this.#cache);
77
- this.#cssBuilder = new CssBuilder(this.#cache, this.#isDev, this.#paths.cssInput, this.#paths.cssOutput);
78
- this.#hydrationBuilder = new HydrationBuilder(this.#cache, this.#isDev, this.#paths.templates, this.#analyzer);
79
+ this.#cssBuilder = new CssBuilder(this.#cache, this.#paths.cssInput, this.#paths.cssOutput);
79
80
  }
80
81
 
81
82
  /**
@@ -96,7 +97,7 @@ class Builder {
96
97
  try {
97
98
  if (this.#isDev) this.#loadDiskCache();
98
99
  if (!silent) console.log(`\n${COLORS.cyan}⚡ NitronJS Build${COLORS.reset}\n`);
99
- if (!only) this.#cleanOutputDirs();
100
+ if (!only && !(this.#isDev && this.#devBuiltOnce)) this.#cleanOutputDirs();
100
101
 
101
102
  this.#cache.viewsChanged = false;
102
103
  this.#writeJsxRuntime();
@@ -104,7 +105,10 @@ class Builder {
104
105
  if (!only || only === "views") await this.#buildViews();
105
106
  if (!only || only === "css") await this.#buildCss();
106
107
 
107
- if (this.#isDev) this.#saveDiskCache();
108
+ if (this.#isDev) {
109
+ this.#devBuiltOnce = true;
110
+ this.#saveDiskCache();
111
+ }
108
112
  this.#cleanupTemp();
109
113
  if (!silent) this.#printSummary(Date.now() - startTime);
110
114
 
@@ -116,7 +120,8 @@ class Builder {
116
120
  time: Date.now() - startTime
117
121
  };
118
122
  } catch (error) {
119
- this.#cleanupTemp();
123
+ try { this.#cleanupTemp(); }
124
+ catch {}
120
125
 
121
126
  if (!silent) {
122
127
  console.log(this.#formatBuildError(error));
@@ -133,8 +138,8 @@ class Builder {
133
138
 
134
139
  if (data.fileMeta) {
135
140
  for (const [key, value] of Object.entries(data.fileMeta)) {
136
- value.imports = new Set(value.imports || []);
137
- value.css = new Set(value.css || []);
141
+ value.imports = value.imports || [];
142
+ value.css = value.css || [];
138
143
  this.#cache.fileMeta.set(key, value);
139
144
  }
140
145
  }
@@ -153,6 +158,11 @@ class Builder {
153
158
  this.#cache.cssHashes.set(key, value);
154
159
  }
155
160
  }
161
+ if (data.propAnalysis) {
162
+ for (const [key, value] of Object.entries(data.propAnalysis)) {
163
+ this.#cache.propAnalysis.set(key, value);
164
+ }
165
+ }
156
166
  }
157
167
  } catch {}
158
168
  }
@@ -168,10 +178,12 @@ class Builder {
168
178
  fileMeta: {},
169
179
  fileHashes: {},
170
180
  viewHashes: {},
171
- cssHashes: {}
181
+ cssHashes: {},
182
+ propAnalysis: {}
172
183
  };
173
184
 
174
185
  for (const [key, value] of this.#cache.fileMeta) {
186
+ if (!fs.existsSync(key)) continue;
175
187
  data.fileMeta[key] = {
176
188
  ...value,
177
189
  imports: Array.from(value.imports || []),
@@ -179,16 +191,30 @@ class Builder {
179
191
  };
180
192
  }
181
193
  for (const [key, value] of this.#cache.fileHashes) {
194
+ if (!fs.existsSync(key)) continue;
182
195
  data.fileHashes[key] = value;
183
196
  }
184
197
  for (const [key, value] of this.#cache.viewHashes) {
198
+ const filePath = key.endsWith(":mtime") ? key.slice(0, -6) : key;
199
+ if (!fs.existsSync(filePath)) continue;
185
200
  data.viewHashes[key] = value;
186
201
  }
187
202
  for (const [key, value] of this.#cache.cssHashes) {
203
+ if (!fs.existsSync(key)) continue;
188
204
  data.cssHashes[key] = value;
189
205
  }
206
+ for (const [key, value] of this.#cache.propAnalysis) {
207
+ if (!fs.existsSync(key)) continue;
208
+ data.propAnalysis[key] = value;
209
+ }
190
210
 
191
- fs.writeFileSync(this.#diskCachePath, JSON.stringify(data));
211
+ const tmpPath = this.#diskCachePath + ".tmp";
212
+ fs.writeFileSync(tmpPath, JSON.stringify(data));
213
+
214
+ try { fs.renameSync(tmpPath, this.#diskCachePath); }
215
+ catch {
216
+ fs.writeFileSync(this.#diskCachePath, JSON.stringify(data));
217
+ }
192
218
  } catch {}
193
219
  }
194
220
 
@@ -218,10 +244,11 @@ class Builder {
218
244
  }
219
245
 
220
246
  async #buildViews() {
221
- const [, , , userBundle, frameworkBundle] = await Promise.all([
247
+ const [, , , , userBundle, frameworkBundle] = await Promise.all([
222
248
  this.#buildVendor(),
223
249
  this.#buildSpaRuntime(),
224
250
  this.#buildHmrClient(),
251
+ this.#buildRscConsumer(),
225
252
  this.#buildViewBundle("user", this.#paths.userViews, this.#paths.userOutput),
226
253
  this.#buildViewBundle("framework", this.#paths.frameworkViews, this.#paths.frameworkOutput)
227
254
  ]);
@@ -231,19 +258,27 @@ class Builder {
231
258
  ...(frameworkBundle.changedFiles || [])
232
259
  ]);
233
260
 
261
+ // Include changed client component sources so their hydration bundles rebuild
262
+ for (const bundle of [userBundle, frameworkBundle]) {
263
+ if (!bundle?.changedSources) continue;
264
+
265
+ for (const file of bundle.changedSources) {
266
+ const fileMeta = bundle.meta?.get(file);
267
+ if (fileMeta?.isClient) changedViews.add(file);
268
+ }
269
+ }
270
+
234
271
  for (const file of changedViews) {
235
272
  this.#changedFiles.add(file);
236
273
  }
237
274
 
238
- const isFirstBuild = changedViews.size === 0 &&
239
- (userBundle.entries.length > 0 || frameworkBundle.entries.length > 0);
240
-
241
- await this.#buildHydrationBundles(
275
+ const clientComponents = await this.#buildClientComponents(
242
276
  userBundle,
243
277
  frameworkBundle,
244
- isFirstBuild ? null : (changedViews.size > 0 ? changedViews : null)
278
+ changedViews
245
279
  );
246
280
 
281
+ this.#writeClientManifest(clientComponents);
247
282
  this.#writeManifest();
248
283
  }
249
284
 
@@ -304,33 +339,72 @@ class Builder {
304
339
  this.#cache.spaBuilt = true;
305
340
  }
306
341
 
342
+ async #buildRscConsumer() {
343
+ const outfile = path.join(this.#paths.jsOutput, "rsc-consumer.js");
344
+ if (this.#cache.rscConsumerBuilt && fs.existsSync(outfile)) return;
345
+
346
+ const consumerFile = path.join(this.#paths.templates, "rsc-consumer.tsx");
347
+
348
+ await esbuild.build({
349
+ entryPoints: [consumerFile],
350
+ outfile,
351
+ bundle: true,
352
+ platform: "browser",
353
+ format: "iife",
354
+ target: "es2020",
355
+ sourcemap: this.#isDev,
356
+ minify: !this.#isDev,
357
+ keepNames: true,
358
+ jsx: "automatic",
359
+ plugins: [
360
+ createVendorGlobalsPlugin(),
361
+ createCssStubPlugin()
362
+ ]
363
+ });
364
+ this.#cache.rscConsumerBuilt = true;
365
+ }
366
+
307
367
  async #buildViewBundle(namespace, srcDir, outDir) {
308
368
  if (!fs.existsSync(srcDir)) {
309
- return { entries: [], layouts: [], meta: new Map(), srcDir, namespace, changedFiles: [] };
369
+ return { entries: [], layouts: [], meta: new Map(), srcDir, namespace, changedFiles: [], changedSources: new Set() };
310
370
  }
311
371
 
312
372
  const { entries, layouts, meta, importedBy } = this.#analyzer.discoverEntries(srcDir);
313
373
 
314
374
  if (!entries.length && !layouts.length) {
315
- return { entries: [], layouts: [], meta: new Map(), srcDir, namespace, changedFiles: [] };
375
+ return { entries: [], layouts: [], meta: new Map(), srcDir, namespace, changedFiles: [], changedSources: new Set() };
316
376
  }
317
377
 
318
- this.#addToManifest(entries, layouts, meta, srcDir, namespace);
378
+ // Pre-scan: compute effective prop usage for server→client boundary filtering.
379
+ // Must run before addToManifest so manifest gets effective (forward-aware) usage.
380
+ const { effectiveMap, clientPathSet } = this.#preScanPropUsage(meta);
381
+
382
+ this.#addToManifest(entries, layouts, meta, srcDir, namespace, effectiveMap);
319
383
 
320
384
  const allFiles = [...entries, ...layouts];
321
385
  const entryLayoutSet = new Set(allFiles);
322
386
  const changedSources = new Set();
323
387
 
324
- for (const file of meta.keys()) {
388
+ await Promise.all([...meta.keys()].map(async (file) => {
389
+ const stat = await fs.promises.stat(file);
390
+ const mtimeKey = file + ":mtime";
391
+ const cachedMtime = this.#cache.viewHashes.get(mtimeKey);
392
+
393
+ if (cachedMtime === stat.mtimeMs && this.#cache.viewHashes.has(file)) {
394
+ return;
395
+ }
396
+
325
397
  const content = await fs.promises.readFile(file, "utf8");
326
398
  const hash = crypto.createHash("md5").update(content).digest("hex");
327
399
  const cachedHash = this.#cache.viewHashes.get(file);
328
400
 
401
+ this.#cache.viewHashes.set(mtimeKey, stat.mtimeMs);
402
+
329
403
  if (cachedHash !== hash) {
330
404
  this.#cache.viewHashes.set(file, hash);
331
405
  changedSources.add(file);
332
406
  }
333
- }
407
+ }));
334
408
 
335
409
  const filesToBuild = new Set();
336
410
 
@@ -350,7 +424,7 @@ class Builder {
350
424
 
351
425
  if (changedFiles.length) {
352
426
  this.#cache.viewsChanged = true;
353
- await this.#runEsbuild(changedFiles, outDir, { meta, outbase: srcDir });
427
+ await this.#runEsbuild(changedFiles, outDir, { meta, outbase: srcDir, effectiveMap, clientPaths: clientPathSet });
354
428
  await this.#postProcessMeta(changedFiles, srcDir, outDir);
355
429
 
356
430
  for (const entry of changedFiles) {
@@ -361,7 +435,7 @@ class Builder {
361
435
 
362
436
  this.#stats[namespace === "user" ? "user" : "framework"] = entries.length;
363
437
 
364
- return { entries, layouts, meta, srcDir, namespace, changedFiles };
438
+ return { entries, layouts, meta, srcDir, namespace, changedFiles, changedSources };
365
439
  }
366
440
 
367
441
  async #postProcessMeta(entries, srcDir, outDir) {
@@ -437,36 +511,83 @@ class Builder {
437
511
  await Promise.all(entries.map(processEntry));
438
512
  }
439
513
 
440
- async #buildHydrationBundles(userBundle, frameworkBundle, changedViews = null) {
441
- const hydrationFiles = await this.#hydrationBuilder.build(
442
- userBundle,
443
- frameworkBundle,
444
- this.#manifest,
445
- changedViews
446
- );
514
+ async #buildClientComponents(userBundle, frameworkBundle, changedViews = null) {
515
+ const clientFiles = [];
447
516
 
448
- if (!hydrationFiles.length) {
449
- return;
517
+ for (const bundle of [userBundle, frameworkBundle]) {
518
+ if (!bundle?.meta) continue;
519
+
520
+ for (const [filePath, fileMeta] of bundle.meta.entries()) {
521
+ if (!fileMeta.isClient) continue;
522
+
523
+ const relative = path.relative(bundle.srcDir, filePath)
524
+ .replace(/\.tsx$/, ".js")
525
+ .replace(/\\/g, "/");
526
+
527
+ const chunkFile = "js/" + relative;
528
+ const outputPath = path.join(this.#paths.jsOutput, relative);
529
+ const needsRebuild = !changedViews || changedViews.has(filePath);
530
+
531
+ clientFiles.push({
532
+ sourcePath: filePath,
533
+ srcDir: bundle.srcDir,
534
+ chunkId: relative.replace(/\.js$/, ""),
535
+ chunkFile,
536
+ outputPath,
537
+ needsRebuild
538
+ });
539
+ }
450
540
  }
451
541
 
452
- await this.#runEsbuild(hydrationFiles, this.#paths.jsOutput, {
453
- platform: "browser",
454
- target: "es2020",
455
- outbase: path.join(Paths.project, ".nitron/hydration"),
456
- external: ["react", "react-dom", "react-dom/client", "react/jsx-runtime"],
457
- vendor: true,
458
- serverFunctions: true
459
- });
542
+ const toBuild = clientFiles.filter(c => c.needsRebuild || !fs.existsSync(c.outputPath));
543
+
544
+ if (toBuild.length) {
545
+ const srcDirs = new Set(toBuild.map(c => c.srcDir));
546
+
547
+ await Promise.all([...srcDirs].map(srcDir => {
548
+ const entries = toBuild.filter(c => c.srcDir === srcDir).map(c => c.sourcePath);
549
+
550
+ return this.#runEsbuild(entries, this.#paths.jsOutput, {
551
+ platform: "browser",
552
+ target: "es2020",
553
+ outbase: srcDir,
554
+ external: ["react", "react-dom", "react-dom/client", "react/jsx-runtime"],
555
+ vendor: true,
556
+ serverFunctions: true
557
+ });
558
+ }));
559
+
560
+ this.#stats.islands = toBuild.length;
561
+ }
562
+
563
+ return clientFiles;
564
+ }
565
+
566
+ #writeClientManifest(clientComponents) {
567
+ const manifest = {};
568
+
569
+ for (const { sourcePath, chunkId, chunkFile } of clientComponents) {
570
+ const fileUrl = "file:///" + sourcePath.replace(/\\/g, "/");
460
571
 
461
- this.#stats.islands = hydrationFiles.length;
572
+ manifest[fileUrl] = {
573
+ id: chunkId,
574
+ chunks: [chunkId, chunkFile],
575
+ name: "*"
576
+ };
577
+ }
578
+
579
+ const manifestPath = path.join(Paths.build, "client-manifest.json");
580
+ fs.mkdirSync(path.dirname(manifestPath), { recursive: true });
581
+ fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
462
582
  }
463
583
 
464
584
  #getTransitiveDependents(file, importedBy) {
465
585
  const result = new Set();
466
586
  const queue = [file];
587
+ let i = 0;
467
588
 
468
- while (queue.length > 0) {
469
- const current = queue.shift();
589
+ while (i < queue.length) {
590
+ const current = queue[i++];
470
591
  const dependents = importedBy.get(current) || [];
471
592
 
472
593
  for (const dep of dependents) {
@@ -481,18 +602,31 @@ class Builder {
481
602
  }
482
603
 
483
604
  async #buildCss() {
484
- this.#stats.css = await this.#cssBuilder.build(this.#cache.viewsChanged);
605
+ this.#stats.css = await this.#cssBuilder.build(this.#cache.viewsChanged, this.#isDev);
485
606
  }
486
607
 
487
608
  async #runEsbuild(entries, outDir, options = {}) {
488
609
  if (!entries.length) return;
489
610
 
611
+ const isNode = (options.platform ?? "node") === "node";
612
+
490
613
  const plugins = [
491
614
  createCssStubPlugin(),
492
- createMarkerPlugin(options, this.#isDev),
493
615
  createPathAliasPlugin()
494
616
  ];
495
617
 
618
+ // RSC plugin only for server builds — replaces "use client" with reference stubs.
619
+ // Browser builds need the actual component code.
620
+ if (isNode) {
621
+ // Prop filter must be registered BEFORE client reference so its onLoad
622
+ // handles "nitron-filtered" namespace before createClientReferencePlugin tries.
623
+ if (options.effectiveMap && options.clientPaths) {
624
+ plugins.push(createPropFilterPlugin(options.effectiveMap, options.clientPaths));
625
+ }
626
+
627
+ plugins.push(createClientReferencePlugin(this.#isDev));
628
+ }
629
+
496
630
  if (options.vendor) {
497
631
  plugins.push(createVendorGlobalsPlugin());
498
632
  plugins.push(createServerModuleBlockerPlugin());
@@ -502,8 +636,6 @@ class Builder {
502
636
  plugins.push(createServerFunctionsPlugin());
503
637
  }
504
638
 
505
- const isNode = (options.platform ?? "node") === "node";
506
-
507
639
  const config = {
508
640
  entryPoints: entries,
509
641
  outdir: outDir,
@@ -535,7 +667,7 @@ class Builder {
535
667
  await esbuild.build(config);
536
668
  }
537
669
 
538
- #addToManifest(entries, layouts, meta, baseDir, namespace) {
670
+ #addToManifest(entries, layouts, meta, baseDir, namespace, effectiveMap) {
539
671
  const layoutSet = new Set(layouts);
540
672
 
541
673
  for (const file of entries) {
@@ -567,11 +699,26 @@ class Builder {
567
699
  cssSet.add(`/css/${path.basename(css)}`);
568
700
  }
569
701
 
570
- this.#manifest[key] = {
702
+ const entry = {
571
703
  css: [...cssSet],
572
704
  layouts: layoutChain.map(l => l.name),
573
705
  hydrationScript: null
574
706
  };
707
+
708
+ if (fileMeta?.isClient) {
709
+ const propUsage = effectiveMap.get(file) ?? null;
710
+ entry.propUsage = propUsage;
711
+
712
+ if (this.#isDev && propUsage === null) {
713
+ const rel = path.relative(Paths.project, file).replace(/\\/g, "/");
714
+ console.warn(`${COLORS.yellow}⚠ PropFilter: ${rel} — spread or dynamic prop access detected, all props will pass through${COLORS.reset}`);
715
+ }
716
+ }
717
+
718
+ // Collect translation keys from view + all transitive imports
719
+ entry.translationKeys = this.#collectTranslationKeys(file, meta);
720
+
721
+ this.#manifest[key] = entry;
575
722
  }
576
723
 
577
724
  for (const layout of layouts) {
@@ -595,15 +742,164 @@ class Builder {
595
742
  }
596
743
  }
597
744
 
745
+ /**
746
+ * Collects translation keys from a view file and all its transitive imports.
747
+ *
748
+ * Walks the import graph starting from `file`, collecting every static translation
749
+ * key found via build-time AST analysis (PropUsageAnalyzer).
750
+ *
751
+ * Returns null (= "send ALL translations") when any file in the graph uses dynamic
752
+ * keys that can't be statically determined — e.g. `t(variable)` instead of `t("key")`.
753
+ *
754
+ * @param {string} file - Entry file path (absolute).
755
+ * @param {Map} meta - File metadata from FileAnalyzer.
756
+ * @returns {string[]|null} Array of keys, or null if any file has dynamic keys.
757
+ */
758
+ #collectTranslationKeys(file, meta) {
759
+ const visited = new Set();
760
+ const allKeys = new Set();
761
+ let hasNull = false;
762
+
763
+ const walk = (filePath) => {
764
+ if (visited.has(filePath)) return;
765
+
766
+ visited.add(filePath);
767
+
768
+ const cached = this.#cache.propAnalysis.get(filePath);
769
+
770
+ if (!cached) {
771
+ // Non-JSX files (.ts, .js) don't have translation analysis — skip silently.
772
+ // JSX files without cache mean analysis failed — poison to null (send all).
773
+ if (!filePath.endsWith(".tsx") && !filePath.endsWith(".jsx")) {
774
+ return;
775
+ }
776
+ hasNull = true;
777
+ return;
778
+ }
779
+
780
+ const keys = cached.translationKeys;
781
+
782
+ // null means "dynamic keys detected" — can't filter, must send all translations
783
+ if (keys === null) {
784
+ hasNull = true;
785
+ return;
786
+ }
787
+
788
+ if (keys) {
789
+ for (const k of keys) allKeys.add(k);
790
+ }
791
+
792
+ // Walk transitive imports — resolve relative paths to absolute before lookup
793
+ const fileMeta = meta.get(filePath);
794
+
795
+ if (fileMeta?.imports) {
796
+ for (const imp of fileMeta.imports) {
797
+ const resolved = this.#analyzer.resolveImport(filePath, imp);
798
+ walk(resolved);
799
+ }
800
+ }
801
+ };
802
+
803
+ walk(file);
804
+
805
+ if (hasNull) return null;
806
+ if (allKeys.size === 0) return [];
807
+
808
+ return [...allKeys];
809
+ }
810
+
811
+ /**
812
+ * Pre-scan phase: analyzes JSX prop forwarding and computes effective prop usage.
813
+ * This enables the prop filter plugin to strip unused data at server→client boundaries.
814
+ * @param {Map} meta - File metadata from FileAnalyzer.
815
+ * @returns {{effectiveMap: Map, clientPathSet: Set}}
816
+ */
817
+ #preScanPropUsage(meta) {
818
+ const propUsageMap = new Map();
819
+ const forwardMap = new Map();
820
+ const clientPathSet = new Set();
821
+ const cache = this.#cache.propAnalysis;
822
+
823
+ for (const [filePath, fileMeta] of meta.entries()) {
824
+ const isJsx = filePath.endsWith(".tsx") || filePath.endsWith(".jsx");
825
+
826
+ if (!fileMeta.isClient && !isJsx) continue;
827
+
828
+ if (fileMeta.isClient) clientPathSet.add(filePath);
829
+
830
+ let stat;
831
+
832
+ try { stat = fs.statSync(filePath); }
833
+ catch { continue; }
834
+
835
+ const cached = cache.get(filePath);
836
+
837
+ // Fast path: mtime unchanged → skip entirely
838
+ if (cached && cached.mtime === stat.mtimeMs) {
839
+ if (fileMeta.isClient) {
840
+ propUsageMap.set(filePath, cached.propUsage);
841
+ forwardMap.set(filePath, cached.forwarding);
842
+ }
843
+ continue;
844
+ }
845
+
846
+ // mtime changed — verify with content hash to avoid stale cache
847
+ const content = fs.readFileSync(filePath, "utf8");
848
+ const hash = crypto.createHash("md5").update(content).digest("hex");
849
+
850
+ if (cached && cached.hash === hash) {
851
+ const updatedCache = { ...cached, mtime: stat.mtimeMs };
852
+ cache.set(filePath, updatedCache);
853
+
854
+ if (fileMeta.isClient) {
855
+ propUsageMap.set(filePath, cached.propUsage);
856
+ forwardMap.set(filePath, cached.forwarding);
857
+ }
858
+ continue;
859
+ }
860
+
861
+ const { propUsage, forwarding, translationKeys } = PropUsageAnalyzer.analyzeAll(filePath);
862
+
863
+ cache.set(filePath, { mtime: stat.mtimeMs, hash, propUsage, forwarding, translationKeys });
864
+
865
+ if (fileMeta.isClient) {
866
+ propUsageMap.set(filePath, propUsage);
867
+ forwardMap.set(filePath, forwarding);
868
+ }
869
+ }
870
+
871
+ if (clientPathSet.size === 0) {
872
+ return { effectiveMap: new Map(), clientPathSet };
873
+ }
874
+
875
+ const effectiveMap = EffectivePropUsage.compute(propUsageMap, forwardMap, clientPathSet);
876
+
877
+ return { effectiveMap, clientPathSet };
878
+ }
879
+
598
880
  #writeManifest() {
599
881
  const manifestPath = path.join(Paths.build, "manifest.json");
882
+ const tmpPath = manifestPath + ".tmp";
883
+ const data = JSON.stringify(this.#manifest, null, 2);
884
+
600
885
  fs.mkdirSync(path.dirname(manifestPath), { recursive: true });
601
- fs.writeFileSync(manifestPath, JSON.stringify(this.#manifest, null, 2));
886
+ fs.writeFileSync(tmpPath, data);
887
+
888
+ try { fs.renameSync(tmpPath, manifestPath); }
889
+ catch {
890
+ fs.writeFileSync(manifestPath, data);
891
+ }
602
892
  }
603
893
 
604
894
  #writeJsxRuntime() {
605
- fs.mkdirSync(path.dirname(this.#paths.jsxRuntime), { recursive: true });
606
- fs.writeFileSync(this.#paths.jsxRuntime, JSX_RUNTIME);
895
+ const runtimePath = this.#paths.jsxRuntime;
896
+
897
+ if (fs.existsSync(runtimePath) && fs.readFileSync(runtimePath, "utf8") === JSX_RUNTIME) {
898
+ return;
899
+ }
900
+
901
+ fs.mkdirSync(path.dirname(runtimePath), { recursive: true });
902
+ fs.writeFileSync(runtimePath, JSX_RUNTIME);
607
903
  }
608
904
 
609
905
  #cleanOutputDirs() {