@nitronjs/framework 0.1.23 → 0.2.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 (37) hide show
  1. package/lib/Build/CssBuilder.js +129 -0
  2. package/lib/Build/FileAnalyzer.js +395 -0
  3. package/lib/Build/HydrationBuilder.js +173 -0
  4. package/lib/Build/Manager.js +290 -936
  5. package/lib/Build/colors.js +10 -0
  6. package/lib/Build/jsxRuntime.js +116 -0
  7. package/lib/Build/plugins.js +264 -0
  8. package/lib/Console/Commands/BuildCommand.js +6 -5
  9. package/lib/Console/Commands/DevCommand.js +151 -311
  10. package/lib/Console/Stubs/page-hydration-dev.tsx +72 -0
  11. package/lib/Console/Stubs/page-hydration.tsx +15 -16
  12. package/lib/Console/Stubs/vendor-dev.tsx +50 -0
  13. package/lib/Core/Environment.js +29 -2
  14. package/lib/Core/Paths.js +12 -4
  15. package/lib/Database/Drivers/MySQLDriver.js +5 -4
  16. package/lib/Database/QueryBuilder.js +2 -3
  17. package/lib/Filesystem/Manager.js +32 -7
  18. package/lib/HMR/Server.js +87 -0
  19. package/lib/Http/Server.js +9 -5
  20. package/lib/Logging/Manager.js +68 -18
  21. package/lib/Route/Loader.js +3 -4
  22. package/lib/Route/Manager.js +24 -3
  23. package/lib/Runtime/Entry.js +26 -1
  24. package/lib/Session/File.js +18 -7
  25. package/lib/View/Client/hmr-client.js +166 -0
  26. package/lib/View/Client/spa.js +142 -0
  27. package/lib/View/Layout.js +94 -0
  28. package/lib/View/Manager.js +390 -46
  29. package/lib/index.d.ts +55 -0
  30. package/package.json +2 -1
  31. package/skeleton/.env.example +0 -2
  32. package/skeleton/app/Controllers/HomeController.js +27 -3
  33. package/skeleton/config/app.js +15 -14
  34. package/skeleton/config/session.js +1 -1
  35. package/skeleton/globals.d.ts +3 -63
  36. package/skeleton/resources/views/Site/Home.tsx +274 -50
  37. package/skeleton/tsconfig.json +5 -1
@@ -1,167 +1,57 @@
1
1
  #!/usr/bin/env node
2
+ import crypto from "crypto";
2
3
  import dotenv from "dotenv";
3
4
  import fs from "fs";
4
5
  import path from "path";
5
6
  import esbuild from "esbuild";
6
- import { parse } from "@babel/parser";
7
- import traverse from "@babel/traverse";
8
- import postcss from "postcss";
9
- import tailwindPostcss from "@tailwindcss/postcss";
10
7
  import Paths from "../Core/Paths.js";
8
+ import Environment from "../Core/Environment.js";
9
+ import Layout from "../View/Layout.js";
10
+ import JSX_RUNTIME from "./jsxRuntime.js";
11
+ import FileAnalyzer from "./FileAnalyzer.js";
12
+ import CssBuilder from "./CssBuilder.js";
13
+ import HydrationBuilder from "./HydrationBuilder.js";
14
+ import {
15
+ createPathAliasPlugin,
16
+ createOriginalJsxPlugin,
17
+ createVendorGlobalsPlugin,
18
+ createServerFunctionsPlugin,
19
+ createCssStubPlugin,
20
+ createMarkerPlugin,
21
+ createServerModuleBlockerPlugin
22
+ } from "./plugins.js";
23
+ import COLORS from "./colors.js";
11
24
 
12
25
  dotenv.config({ quiet: true });
13
26
 
14
- const COLORS = {
15
- reset: "\x1b[0m",
16
- dim: "\x1b[2m",
17
- red: "\x1b[31m",
18
- green: "\x1b[32m",
19
- yellow: "\x1b[33m",
20
- cyan: "\x1b[36m"
21
- };
22
-
23
- const CLIENT_HOOKS = new Set([
24
- "useState",
25
- "useEffect",
26
- "useRef",
27
- "useReducer",
28
- "useLayoutEffect",
29
- "useCallback",
30
- "useMemo"
31
- ]);
32
-
33
- const MAX_DEPTH = 50;
34
-
35
- function sanitizeName(name) {
36
- return name.replace(/[^a-zA-Z0-9_$]/g, "_");
37
- }
38
-
39
- const JSX_RUNTIME = `
40
- import * as React from 'react';
41
- import * as OriginalJsx from '__react_jsx_original__';
42
-
43
- const CTX = Symbol.for('__nitron_view_context__');
44
- const MARK = Symbol.for('__nitron_client_component__');
45
- const UNSAFE_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
46
-
47
- function getContext() {
48
- return globalThis[CTX]?.getStore?.();
49
- }
50
-
51
- globalThis.csrf = () => getContext()?.csrf || '';
52
-
53
- globalThis.request = () => {
54
- const ctx = getContext();
55
- return ctx?.request || { params: {}, query: {}, url: '', method: 'GET', headers: {} };
56
- };
57
-
58
- const DepthContext = React.createContext(false);
59
- const componentCache = new WeakMap();
60
-
61
- function sanitizeProps(obj, seen = new WeakSet()) {
62
- if (obj == null) return obj;
63
-
64
- const type = typeof obj;
65
- if (type === 'function' || type === 'symbol') return undefined;
66
- if (type === 'bigint') return obj.toString();
67
- if (type !== 'object') return obj;
68
-
69
- if (seen.has(obj)) return undefined;
70
- seen.add(obj);
71
-
72
- if (Array.isArray(obj)) {
73
- return obj.map(item => {
74
- const sanitized = sanitizeProps(item, seen);
75
- return sanitized === undefined ? null : sanitized;
76
- });
77
- }
78
-
79
- if (obj instanceof Date) return obj.toISOString();
80
- if (obj._attributes && typeof obj._attributes === 'object') {
81
- return sanitizeProps(obj._attributes, seen);
82
- }
83
- if (typeof obj.toJSON === 'function') {
84
- return sanitizeProps(obj.toJSON(), seen);
85
- }
86
-
87
- const proto = Object.getPrototypeOf(obj);
88
- if (proto !== Object.prototype && proto !== null) return undefined;
89
-
90
- const result = {};
91
- for (const key of Object.keys(obj)) {
92
- if (UNSAFE_KEYS.has(key)) continue;
93
- const value = sanitizeProps(obj[key], seen);
94
- if (value !== undefined) result[key] = value;
95
- }
96
- return result;
97
- }
98
-
99
- function wrapWithDepth(children) {
100
- return OriginalJsx.jsx(DepthContext.Provider, { value: true, children });
101
- }
102
-
103
- function createIsland(Component, name) {
104
- function IslandBoundary(props) {
105
- if (React.useContext(DepthContext)) {
106
- return OriginalJsx.jsx(Component, props);
107
- }
108
-
109
- const id = React.useId();
110
- const safeProps = sanitizeProps(props) || {};
111
-
112
- const ctx = getContext();
113
- if (ctx) {
114
- ctx.props = ctx.props || {};
115
- ctx.props[id] = safeProps;
116
- }
117
-
118
- return OriginalJsx.jsx('div', {
119
- 'data-cid': id,
120
- 'data-island': name,
121
- children: wrapWithDepth(OriginalJsx.jsx(Component, props))
122
- });
123
- }
124
-
125
- IslandBoundary.displayName = 'Island(' + name + ')';
126
- return IslandBoundary;
127
- }
128
-
129
- function getWrappedComponent(Component) {
130
- if (!componentCache.has(Component)) {
131
- const name = Component.displayName || Component.name || 'Anonymous';
132
- componentCache.set(Component, createIsland(Component, name));
133
- }
134
- return componentCache.get(Component);
135
- }
136
-
137
- export function jsx(type, props, key) {
138
- if (typeof type === 'function' && type[MARK]) {
139
- return OriginalJsx.jsx(getWrappedComponent(type), props, key);
140
- }
141
- return OriginalJsx.jsx(type, props, key);
142
- }
143
-
144
- export function jsxs(type, props, key) {
145
- if (typeof type === 'function' && type[MARK]) {
146
- return OriginalJsx.jsx(getWrappedComponent(type), props, key);
147
- }
148
- return OriginalJsx.jsxs(type, props, key);
149
- }
150
-
151
- export const Fragment = OriginalJsx.Fragment;
152
- `;
153
-
154
27
  class Builder {
155
- #isDev = process.env.APP_DEV === "true";
156
- #traverse = traverse.default;
28
+ #isDev = false;
157
29
  #manifest = {};
158
30
  #paths;
159
31
  #stats = { user: 0, framework: 0, islands: 0, css: 0 };
160
- #cache = { imports: new Map(), css: new Map(), hydrationTemplate: null };
32
+ #cache = {
33
+ imports: new Map(),
34
+ css: new Map(),
35
+ cssHashes: new Map(),
36
+ viewHashes: new Map(),
37
+ hydrationTemplate: null,
38
+ hydrationTemplateDev: null,
39
+ vendorBuilt: false,
40
+ spaBuilt: false,
41
+ hmrBuilt: false,
42
+ tailwindProcessor: null,
43
+ viewsChanged: false,
44
+ fileMeta: new Map(),
45
+ fileHashes: new Map()
46
+ };
47
+ #diskCachePath = path.join(Paths.nitronTemp, "build-cache.json");
48
+ #changedFiles = new Set();
49
+ #analyzer;
50
+ #cssBuilder;
51
+ #hydrationBuilder;
161
52
 
162
53
  constructor() {
163
54
  this.#paths = {
164
- // User project paths
165
55
  userViews: Paths.views,
166
56
  userOutput: Paths.buildViews,
167
57
  frameworkOutput: Paths.buildFrameworkViews,
@@ -170,40 +60,119 @@ class Builder {
170
60
  jsOutput: Paths.publicJs,
171
61
  jsxRuntime: Paths.jsxRuntime,
172
62
  nitronTemp: Paths.nitronTemp,
173
- // Framework paths
174
63
  frameworkViews: Paths.frameworkViews,
175
64
  templates: Paths.frameworkTemplates
176
65
  };
66
+
67
+ this.#analyzer = new FileAnalyzer(this.#cache);
68
+ this.#cssBuilder = new CssBuilder(this.#cache, this.#isDev, this.#paths.cssInput, this.#paths.cssOutput);
69
+ this.#hydrationBuilder = new HydrationBuilder(this.#cache, this.#isDev, this.#paths.templates, this.#analyzer);
177
70
  }
178
71
 
179
- async run(only = null) {
72
+ async run(only = null, isDev = false, silent = false) {
73
+ this.#isDev = isDev;
74
+ Environment.setDev(isDev);
75
+
180
76
  const startTime = Date.now();
77
+ this.#changedFiles.clear();
78
+ this.#manifest = {};
181
79
 
182
80
  try {
183
- console.log(`\n${COLORS.cyan}⚡ NitronJS Build${COLORS.reset}\n`);
184
-
185
- if (!only) {
186
- this.#cleanOutputDirs();
187
- }
81
+ if (this.#isDev) this.#loadDiskCache();
82
+ if (!silent) console.log(`\n${COLORS.cyan}⚡ NitronJS Build${COLORS.reset}\n`);
83
+ if (!only) this.#cleanOutputDirs();
188
84
 
85
+ this.#cache.viewsChanged = false;
189
86
  this.#writeJsxRuntime();
190
87
 
191
- await Promise.all([
192
- (!only || only === "views") ? this.#buildViews() : null,
193
- (!only || only === "css") ? this.#buildCss() : null
194
- ]);
88
+ if (!only || only === "views") await this.#buildViews();
89
+ if (!only || only === "css") await this.#buildCss();
195
90
 
91
+ if (this.#isDev) this.#saveDiskCache();
196
92
  this.#cleanupTemp();
197
- this.#printSummary(Date.now() - startTime);
198
- return true;
93
+ if (!silent) this.#printSummary(Date.now() - startTime);
94
+
95
+ return {
96
+ success: true,
97
+ changedFiles: [...this.#changedFiles],
98
+ cssChanged: this.#stats.css > 0,
99
+ viewsChanged: this.#cache.viewsChanged,
100
+ time: Date.now() - startTime
101
+ };
199
102
  } catch (error) {
200
103
  this.#cleanupTemp();
201
- console.log(`\n${COLORS.red}✖ Build failed: ${error.message}${COLORS.reset}\n`);
202
- if (this.#isDev) console.error(error);
203
- return false;
104
+ if (!silent) console.log(`\n${COLORS.red}✖ Build failed: ${error.message}${COLORS.reset}\n`);
105
+ if (this.#isDev && !silent) console.error(error);
106
+ return { success: false, error: error.message };
204
107
  }
205
108
  }
206
109
 
110
+ #loadDiskCache() {
111
+ try {
112
+ if (fs.existsSync(this.#diskCachePath)) {
113
+ const data = JSON.parse(fs.readFileSync(this.#diskCachePath, "utf8"));
114
+
115
+ if (data.fileMeta) {
116
+ for (const [key, value] of Object.entries(data.fileMeta)) {
117
+ value.imports = new Set(value.imports || []);
118
+ value.css = new Set(value.css || []);
119
+ this.#cache.fileMeta.set(key, value);
120
+ }
121
+ }
122
+ if (data.fileHashes) {
123
+ for (const [key, value] of Object.entries(data.fileHashes)) {
124
+ this.#cache.fileHashes.set(key, value);
125
+ }
126
+ }
127
+ if (data.viewHashes) {
128
+ for (const [key, value] of Object.entries(data.viewHashes)) {
129
+ this.#cache.viewHashes.set(key, value);
130
+ }
131
+ }
132
+ if (data.cssHashes) {
133
+ for (const [key, value] of Object.entries(data.cssHashes)) {
134
+ this.#cache.cssHashes.set(key, value);
135
+ }
136
+ }
137
+ }
138
+ } catch {}
139
+ }
140
+
141
+ #saveDiskCache() {
142
+ try {
143
+ const cacheDir = path.dirname(this.#diskCachePath);
144
+ if (!fs.existsSync(cacheDir)) {
145
+ fs.mkdirSync(cacheDir, { recursive: true });
146
+ }
147
+
148
+ const data = {
149
+ fileMeta: {},
150
+ fileHashes: {},
151
+ viewHashes: {},
152
+ cssHashes: {}
153
+ };
154
+
155
+ for (const [key, value] of this.#cache.fileMeta) {
156
+ data.fileMeta[key] = {
157
+ ...value,
158
+ imports: Array.from(value.imports || []),
159
+ css: Array.from(value.css || [])
160
+ };
161
+ }
162
+ for (const [key, value] of this.#cache.fileHashes) {
163
+ data.fileHashes[key] = value;
164
+ }
165
+ for (const [key, value] of this.#cache.viewHashes) {
166
+ data.viewHashes[key] = value;
167
+ }
168
+ for (const [key, value] of this.#cache.cssHashes) {
169
+ data.cssHashes[key] = value;
170
+ }
171
+
172
+ fs.writeFileSync(this.#diskCachePath, JSON.stringify(data));
173
+ } catch {}
174
+ }
175
+
207
176
  #printSummary(duration) {
208
177
  const { user, framework, islands, css } = this.#stats;
209
178
  const lines = [];
@@ -230,20 +199,46 @@ class Builder {
230
199
  }
231
200
 
232
201
  async #buildViews() {
233
- const [, userBundle, frameworkBundle] = await Promise.all([
202
+ const [, , , userBundle, frameworkBundle] = await Promise.all([
234
203
  this.#buildVendor(),
204
+ this.#buildSpaRuntime(),
205
+ this.#buildHmrClient(),
235
206
  this.#buildViewBundle("user", this.#paths.userViews, this.#paths.userOutput),
236
207
  this.#buildViewBundle("framework", this.#paths.frameworkViews, this.#paths.frameworkOutput)
237
208
  ]);
238
209
 
239
- await this.#buildHydrationBundles(userBundle, frameworkBundle);
210
+ const changedViews = new Set([
211
+ ...(userBundle.changedFiles || []),
212
+ ...(frameworkBundle.changedFiles || [])
213
+ ]);
214
+
215
+ for (const file of changedViews) {
216
+ this.#changedFiles.add(file);
217
+ }
218
+
219
+ const isFirstBuild = changedViews.size === 0 &&
220
+ (userBundle.entries.length > 0 || frameworkBundle.entries.length > 0);
221
+
222
+ await this.#buildHydrationBundles(
223
+ userBundle,
224
+ frameworkBundle,
225
+ isFirstBuild ? null : (changedViews.size > 0 ? changedViews : null)
226
+ );
227
+
240
228
  this.#writeManifest();
241
229
  }
242
230
 
243
231
  async #buildVendor() {
232
+ const outfile = path.join(this.#paths.jsOutput, "vendor.js");
233
+ if (this.#cache.vendorBuilt && fs.existsSync(outfile)) return;
234
+
235
+ const vendorFile = this.#isDev
236
+ ? path.join(this.#paths.templates, "vendor-dev.tsx")
237
+ : path.join(this.#paths.templates, "vendor.tsx");
238
+
244
239
  await esbuild.build({
245
- entryPoints: [path.join(this.#paths.templates, "vendor.tsx")],
246
- outfile: path.join(this.#paths.jsOutput, "vendor.js"),
240
+ entryPoints: [vendorFile],
241
+ outfile,
247
242
  bundle: true,
248
243
  platform: "browser",
249
244
  format: "iife",
@@ -252,26 +247,79 @@ class Builder {
252
247
  minify: !this.#isDev,
253
248
  jsx: "automatic"
254
249
  });
250
+ this.#cache.vendorBuilt = true;
251
+ }
252
+
253
+ async #buildHmrClient() {
254
+ if (!this.#isDev) return;
255
+
256
+ const outfile = path.join(this.#paths.jsOutput, "hmr.js");
257
+ if (this.#cache.hmrBuilt && fs.existsSync(outfile)) return;
258
+
259
+ await esbuild.build({
260
+ entryPoints: [path.join(Paths.frameworkLib, "View/Client/hmr-client.js")],
261
+ outfile,
262
+ bundle: false,
263
+ platform: "browser",
264
+ format: "iife",
265
+ target: "es2020",
266
+ minify: false
267
+ });
268
+ this.#cache.hmrBuilt = true;
269
+ }
270
+
271
+ async #buildSpaRuntime() {
272
+ const outfile = path.join(this.#paths.jsOutput, "spa.js");
273
+ if (this.#cache.spaBuilt && fs.existsSync(outfile)) return;
274
+
275
+ await esbuild.build({
276
+ entryPoints: [path.join(Paths.frameworkLib, "View/Client/spa.js")],
277
+ outfile,
278
+ bundle: true,
279
+ platform: "browser",
280
+ format: "iife",
281
+ target: "es2020",
282
+ minify: !this.#isDev
283
+ });
284
+ this.#cache.spaBuilt = true;
255
285
  }
256
286
 
257
287
  async #buildViewBundle(namespace, srcDir, outDir) {
258
288
  if (!fs.existsSync(srcDir)) {
259
- return { entries: [], meta: new Map(), srcDir, namespace };
289
+ return { entries: [], layouts: [], meta: new Map(), srcDir, namespace, changedFiles: [] };
260
290
  }
261
291
 
262
- const { entries, meta } = this.#discoverEntries(srcDir);
292
+ const { entries, layouts, meta } = this.#analyzer.discoverEntries(srcDir);
263
293
 
264
- if (!entries.length) {
265
- return { entries: [], meta: new Map(), srcDir, namespace };
294
+ if (!entries.length && !layouts.length) {
295
+ return { entries: [], layouts: [], meta: new Map(), srcDir, namespace, changedFiles: [] };
266
296
  }
267
297
 
268
- this.#addToManifest(entries, meta, srcDir, namespace);
269
- await this.#runEsbuild(entries, outDir, { meta, outbase: srcDir });
270
- await this.#postProcessMeta(entries, srcDir, outDir);
298
+ this.#addToManifest(entries, layouts, meta, srcDir, namespace);
299
+
300
+ const allFiles = [...entries, ...layouts];
301
+ const changedFiles = [];
302
+
303
+ for (const file of allFiles) {
304
+ const content = await fs.promises.readFile(file, "utf8");
305
+ const hash = crypto.createHash("md5").update(content).digest("hex");
306
+ const cachedHash = this.#cache.viewHashes.get(file);
307
+
308
+ if (cachedHash !== hash) {
309
+ this.#cache.viewHashes.set(file, hash);
310
+ changedFiles.push(file);
311
+ }
312
+ }
313
+
314
+ if (changedFiles.length) {
315
+ this.#cache.viewsChanged = true;
316
+ await this.#runEsbuild(changedFiles, outDir, { meta, outbase: srcDir });
317
+ await this.#postProcessMeta(changedFiles, srcDir, outDir);
318
+ }
271
319
 
272
320
  this.#stats[namespace === "user" ? "user" : "framework"] = entries.length;
273
321
 
274
- return { entries, meta, srcDir, namespace };
322
+ return { entries, layouts, meta, srcDir, namespace, changedFiles };
275
323
  }
276
324
 
277
325
  async #postProcessMeta(entries, srcDir, outDir) {
@@ -347,27 +395,13 @@ class Builder {
347
395
  await Promise.all(entries.map(processEntry));
348
396
  }
349
397
 
350
- async #buildHydrationBundles(userBundle, frameworkBundle) {
351
- const hydrationFiles = [];
352
-
353
- for (const bundle of [userBundle, frameworkBundle]) {
354
- if (!bundle?.entries.length) continue;
355
-
356
- for (const viewPath of bundle.entries) {
357
- const clientComponents = this.#findClientComponents(viewPath, bundle.meta, new Set());
358
-
359
- if (clientComponents.size) {
360
- const hydrationFile = this.#generateHydrationFile(
361
- viewPath,
362
- bundle.srcDir,
363
- clientComponents,
364
- bundle.meta,
365
- bundle.namespace
366
- );
367
- hydrationFiles.push(hydrationFile);
368
- }
369
- }
370
- }
398
+ async #buildHydrationBundles(userBundle, frameworkBundle, changedViews = null) {
399
+ const hydrationFiles = await this.#hydrationBuilder.build(
400
+ userBundle,
401
+ frameworkBundle,
402
+ this.#manifest,
403
+ changedViews
404
+ );
371
405
 
372
406
  if (!hydrationFiles.length) {
373
407
  return;
@@ -385,132 +419,26 @@ class Builder {
385
419
  this.#stats.islands = hydrationFiles.length;
386
420
  }
387
421
 
388
- #findClientComponents(file, meta, seen, depth = 0) {
389
- const result = new Set();
390
-
391
- if (depth > MAX_DEPTH || seen.has(file)) {
392
- return result;
393
- }
394
- seen.add(file);
395
-
396
- const fileMeta = meta.get(file);
397
- if (!fileMeta) {
398
- return result;
399
- }
400
-
401
- if (fileMeta.isClient) {
402
- result.add(file);
403
- }
404
-
405
- for (const importPath of fileMeta.imports) {
406
- const resolvedPath = this.#resolveImport(file, importPath);
407
-
408
- if (meta.has(resolvedPath)) {
409
- const childComponents = this.#findClientComponents(resolvedPath, meta, seen, depth + 1);
410
- for (const component of childComponents) {
411
- result.add(component);
412
- }
413
- }
414
- }
415
-
416
- return result;
417
- }
418
-
419
- #generateHydrationFile(viewPath, srcDir, clientComponents, meta, namespace) {
420
- if (!this.#cache.hydrationTemplate) {
421
- const templatePath = path.join(this.#paths.templates, "page-hydration.tsx");
422
- this.#cache.hydrationTemplate = fs.readFileSync(templatePath, "utf8");
423
- }
424
-
425
- const viewRelative = path.relative(srcDir, viewPath).replace(/\.tsx$/, "").toLowerCase();
426
- const outputDir = path.join(Paths.project, ".nitron/hydration", path.dirname(viewRelative));
427
- const outputFile = path.join(outputDir, path.basename(viewRelative) + ".tsx");
428
-
429
- fs.mkdirSync(outputDir, { recursive: true });
430
-
431
- const imports = [];
432
- const manifestEntries = [];
433
- let index = 0;
434
-
435
- for (const componentPath of clientComponents) {
436
- const componentMeta = meta.get(componentPath);
437
- if (!componentMeta) continue;
438
-
439
- const baseName = path.basename(componentPath, ".tsx");
440
- const relativePath = path.relative(outputDir, componentPath)
441
- .replace(/\\/g, "/")
442
- .replace(/\.tsx$/, "");
443
-
444
- if (componentMeta.hasDefault) {
445
- const importName = sanitizeName(baseName) + "_" + index++;
446
- imports.push(`import ${importName} from "${relativePath}";`);
447
- manifestEntries.push(` "${baseName}": ${importName}`);
448
- }
449
-
450
- for (const namedExport of componentMeta.named || []) {
451
- const importName = sanitizeName(namedExport) + "_" + index++;
452
- imports.push(`import { ${namedExport} as ${importName} } from "${relativePath}";`);
453
- manifestEntries.push(` "${namedExport}": ${importName}`);
454
- }
455
- }
456
-
457
- const code = this.#cache.hydrationTemplate
458
- .replace("// __COMPONENT_IMPORTS__", imports.join("\n"))
459
- .replace(
460
- "// __COMPONENT_MANIFEST__",
461
- `Object.assign(componentManifest, {\n${manifestEntries.join(",\n")}\n});`
462
- );
463
-
464
- fs.writeFileSync(outputFile, code);
465
-
466
- const manifestKey = `${namespace}:${viewRelative.replace(/\\/g, "/")}`;
467
- if (this.#manifest[manifestKey]) {
468
- this.#manifest[manifestKey].hydrationScript = `/js/${viewRelative.replace(/\\/g, "/")}.js`;
469
- }
470
-
471
- return outputFile;
472
- }
473
-
474
422
  async #buildCss() {
475
- if (!fs.existsSync(this.#paths.cssInput)) {
476
- return;
477
- }
478
-
479
- const cssFiles = fs.readdirSync(this.#paths.cssInput).filter(f => f.endsWith(".css"));
480
- if (!cssFiles.length) {
481
- return;
482
- }
483
-
484
- await Promise.all(cssFiles.map(async (filename) => {
485
- const filePath = path.join(this.#paths.cssInput, filename);
486
- if (!this.#cache.css.has(filePath)) {
487
- const content = await fs.promises.readFile(filePath, "utf8");
488
- this.#cache.css.set(filePath, content);
489
- }
490
- }));
491
-
492
- const hasTailwind = this.#detectTailwind();
493
- const processor = hasTailwind ? postcss([tailwindPostcss()]) : null;
494
-
495
- await Promise.all(cssFiles.map(filename => this.#processCss(filename, hasTailwind, processor)));
496
-
497
- this.#stats.css = cssFiles.length;
423
+ this.#stats.css = await this.#cssBuilder.build(this.#cache.viewsChanged);
498
424
  }
499
425
 
500
426
  async #runEsbuild(entries, outDir, options = {}) {
427
+ if (!entries.length) return;
428
+
501
429
  const plugins = [
502
- this.#createCssStubPlugin(),
503
- this.#createMarkerPlugin(options),
504
- this.#createPathAliasPlugin()
430
+ createCssStubPlugin(),
431
+ createMarkerPlugin(options, this.#isDev),
432
+ createPathAliasPlugin()
505
433
  ];
506
434
 
507
435
  if (options.vendor) {
508
- plugins.push(this.#createVendorGlobalsPlugin());
509
- plugins.push(this.#createServerStubPlugin());
436
+ plugins.push(createVendorGlobalsPlugin());
437
+ plugins.push(createServerModuleBlockerPlugin());
510
438
  }
511
439
 
512
440
  if (options.serverFunctions) {
513
- plugins.push(this.#createServerFunctionsPlugin());
441
+ plugins.push(createServerFunctionsPlugin());
514
442
  }
515
443
 
516
444
  const isNode = (options.platform ?? "node") === "node";
@@ -527,7 +455,9 @@ class Builder {
527
455
  sourcemap: this.#isDev,
528
456
  minify: !this.#isDev,
529
457
  external: options.external ?? ["react", "react-dom", "react-dom/server"],
530
- plugins
458
+ plugins,
459
+ write: true,
460
+ logLevel: "silent"
531
461
  };
532
462
 
533
463
  if (isNode) {
@@ -537,655 +467,78 @@ class Builder {
537
467
 
538
468
  if (options.platform !== "browser") {
539
469
  config.alias = { "react/jsx-runtime": this.#paths.jsxRuntime };
540
- plugins.push(this.#createOriginalJsxPlugin());
470
+ plugins.push(createOriginalJsxPlugin());
541
471
  }
542
472
 
543
473
  await esbuild.build(config);
544
474
  }
545
475
 
546
- #createPathAliasPlugin() {
547
- const root = Paths.project;
548
- return {
549
- name: "path-alias",
550
- setup: (build) => {
551
- // Handle @/* alias -> ./app/* with high priority
552
- // Using filter with higher specificity to run before packages: "external"
553
- build.onResolve({ filter: /^@\// }, async (args) => {
554
- const relativePath = args.path.replace(/^@\//, "");
555
- const absolutePath = path.join(root, "app", relativePath);
556
-
557
- // Try with .js extension first, then without
558
- const extensions = [".js", ".ts", ".jsx", ".tsx", ""];
559
- for (const ext of extensions) {
560
- const fullPath = absolutePath + ext;
561
- if (fs.existsSync(fullPath)) {
562
- // Return with explicit namespace to ensure it's bundled
563
- return { path: fullPath, external: false };
564
- }
565
- }
566
-
567
- // If it's a directory, try index files
568
- if (fs.existsSync(absolutePath) && fs.statSync(absolutePath).isDirectory()) {
569
- for (const ext of [".js", ".ts", ".jsx", ".tsx"]) {
570
- const indexPath = path.join(absolutePath, "index" + ext);
571
- if (fs.existsSync(indexPath)) {
572
- return { path: indexPath, external: false };
573
- }
574
- }
575
- }
576
-
577
- return { path: absolutePath + ".js", external: false };
578
- });
579
- }
580
- };
581
- }
582
-
583
- #createOriginalJsxPlugin() {
584
- return {
585
- name: "original-jsx",
586
- setup: (build) => {
587
- build.onResolve({ filter: /^__react_jsx_original__$/ }, () => ({
588
- path: "react/jsx-runtime",
589
- external: true
590
- }));
591
- }
592
- };
593
- }
594
-
595
- #createVendorGlobalsPlugin() {
596
- const escapeRegex = (str) => str.replace(/[.*+?^${}()|[\]\\\/]/g, "\\$&");
476
+ #addToManifest(entries, layouts, meta, baseDir, namespace) {
477
+ const layoutSet = new Set(layouts);
597
478
 
598
- const packages = {
599
- "react": "__NITRON_REACT__",
600
- "react-dom": "__NITRON_REACT_DOM__",
601
- "react-dom/client": "__NITRON_REACT_DOM_CLIENT__",
602
- "react/jsx-runtime": "__NITRON_JSX_RUNTIME__"
603
- };
479
+ for (const file of entries) {
480
+ const fileMeta = meta.get(file);
481
+ const viewPath = path.relative(baseDir, file)
482
+ .replace(/\.tsx$/, "")
483
+ .replace(/\\/g, "/");
604
484
 
605
- const patterns = Object.entries(packages).map(([pkg, global]) => ({
606
- filter: new RegExp(`^${escapeRegex(pkg)}$`),
607
- pkg,
608
- global
609
- }));
610
-
611
- return {
612
- name: "vendor-globals",
613
- setup: (build) => {
614
- for (const { filter, pkg, global } of patterns) {
615
- build.onResolve({ filter }, () => ({
616
- path: pkg,
617
- namespace: "vendor-global"
618
- }));
619
-
620
- build.onLoad({ filter, namespace: "vendor-global" }, () => ({
621
- contents: `module.exports = window.${global};`,
622
- loader: "js"
623
- }));
624
- }
625
- }
626
- };
627
- }
485
+ const key = `${namespace}:${viewPath.toLowerCase()}`;
628
486
 
629
- #createServerStubPlugin() {
630
- return {
631
- name: "server-stub",
632
- setup: (build) => {
633
- build.onResolve({ filter: /lib\/Storage\.js$/ }, (args) => ({
634
- path: args.path,
635
- namespace: "storage-stub"
636
- }));
637
-
638
- build.onLoad({ filter: /.*/, namespace: "storage-stub" }, () => ({
639
- contents: `export default { url: p => '/storage/' + (p.startsWith('/') ? p.slice(1) : p) };`,
640
- loader: "js"
641
- }));
642
-
643
- build.onResolve(
644
- { filter: /lib\/(DB|Mail|Log|Hash|Environment|Server|Model|Validator)\.js$/ },
645
- (args) => ({ path: args.path, namespace: "server-only" })
646
- );
647
-
648
- build.onLoad({ filter: /.*/, namespace: "server-only" }, (args) => {
649
- const moduleName = args.path.split("/").pop()?.replace(".js", "") || "Module";
650
- return {
651
- contents: `const err = () => { throw new Error("${moduleName} is server-only") }; export default new Proxy({}, { get: err, apply: err });`,
652
- loader: "js"
653
- };
654
- });
487
+ if (this.#manifest[key]) {
488
+ throw new Error(`Duplicate: ${key}`);
655
489
  }
656
- };
657
- }
658
490
 
659
- #createServerFunctionsPlugin() {
660
- return {
661
- name: "server-functions",
662
- setup: (build) => {
663
- build.onLoad({ filter: /\.(tsx?|jsx?)$/ }, async (args) => {
664
- if (args.path.includes("node_modules")) {
665
- return null;
666
- }
491
+ const layoutDisabled = fileMeta?.layoutDisabled === true;
492
+ const layoutChain = layoutDisabled ? [] : Layout.resolve(viewPath + ".tsx", baseDir);
667
493
 
668
- let source = await fs.promises.readFile(args.path, "utf8");
669
-
670
- if (!source.includes("csrf(") && !source.includes("route(")) {
671
- return null;
494
+ const cssSet = new Set();
495
+ for (const layout of layoutChain) {
496
+ const layoutMeta = meta.get(layout.path);
497
+ if (layoutMeta?.css) {
498
+ for (const css of layoutMeta.css) {
499
+ cssSet.add(`/css/${path.basename(css)}`);
672
500
  }
673
-
674
- source = source.replace(
675
- /\bcsrf\s*\(\s*\)/g,
676
- "window.__NITRON_RUNTIME__.csrf"
677
- );
678
-
679
- source = source.replace(
680
- /\broute\s*\(\s*['"]([^'"]+)['"]\s*\)/g,
681
- (_, routeName) => `window.__NITRON_RUNTIME__.routes["${routeName}"]`
682
- );
683
-
684
- const ext = args.path.split(".").pop();
685
- const loader = ext === "tsx" ? "tsx" : ext === "ts" ? "ts" : ext === "jsx" ? "jsx" : "js";
686
-
687
- return { contents: source, loader };
688
- });
689
- }
690
- };
691
- }
692
-
693
- #createCssStubPlugin() {
694
- return {
695
- name: "css-stub",
696
- setup: (build) => {
697
- build.onResolve({ filter: /\.css$/ }, (args) => ({
698
- path: args.path,
699
- namespace: "css-stub"
700
- }));
701
-
702
- build.onLoad({ filter: /.*/, namespace: "css-stub" }, () => ({
703
- contents: "",
704
- loader: "js"
705
- }));
706
- }
707
- };
708
- }
709
-
710
- #createMarkerPlugin(options) {
711
- const isDev = this.#isDev;
712
-
713
- return {
714
- name: "client-marker",
715
- setup: (build) => {
716
- if (options.platform === "browser") {
717
- return;
718
501
  }
719
-
720
- build.onLoad({ filter: /\.tsx$/ }, async (args) => {
721
- const source = await fs.promises.readFile(args.path, "utf8");
722
-
723
- if (!/^\s*["']use client["']/.test(source.slice(0, 50))) {
724
- return null;
725
- }
726
-
727
- let ast;
728
- try {
729
- ast = parse(source, {
730
- sourceType: "module",
731
- plugins: ["typescript", "jsx"]
732
- });
733
- } catch {
734
- if (isDev) {
735
- console.warn(`${COLORS.yellow}⚠ Parse: ${args.path}${COLORS.reset}`);
736
- }
737
- return null;
738
- }
739
-
740
- const exports = this.#findExports(ast);
741
- if (!exports.length) {
742
- return null;
743
- }
744
-
745
- const symbolCode = `Symbol.for('__nitron_client_component__')`;
746
- let additionalCode = "\n";
747
-
748
- for (const exp of exports) {
749
- additionalCode += `try { Object.defineProperty(${exp.name}, ${symbolCode}, { value: true }); ${exp.name}.displayName = "${exp.name}"; } catch {}\n`;
750
- }
751
-
752
- return { contents: source + additionalCode, loader: "tsx" };
753
- });
754
502
  }
755
- };
756
- }
757
-
758
- async #processCss(filename, hasTailwind, processor) {
759
- const inputPath = path.join(this.#paths.cssInput, filename);
760
- const outputPath = path.join(this.#paths.cssOutput, filename);
761
503
 
762
- await fs.promises.mkdir(path.dirname(outputPath), { recursive: true });
763
-
764
- let content = this.#cache.css.get(inputPath);
765
- if (!content) {
766
- content = await fs.promises.readFile(inputPath, "utf8");
767
- this.#cache.css.set(inputPath, content);
768
- }
769
-
770
- if (!hasTailwind) {
771
- await fs.promises.writeFile(outputPath, content);
772
- return;
773
- }
774
-
775
- const result = await processor.process(content, {
776
- from: inputPath,
777
- to: outputPath,
778
- map: this.#isDev ? { inline: false } : false
779
- });
780
-
781
- await fs.promises.writeFile(outputPath, result.css);
782
-
783
- if (result.map) {
784
- await fs.promises.writeFile(`${outputPath}.map`, result.map.toString());
785
- }
786
- }
787
-
788
- #detectTailwind() {
789
- if (!fs.existsSync(this.#paths.cssInput)) {
790
- return false;
791
- }
792
-
793
- const tailwindPattern = /@(import\s+["']tailwindcss["']|tailwind\s+(base|components|utilities))/;
794
-
795
- for (const filename of fs.readdirSync(this.#paths.cssInput).filter(f => f.endsWith(".css"))) {
796
- const filePath = path.join(this.#paths.cssInput, filename);
797
-
798
- let content = this.#cache.css.get(filePath);
799
- if (!content) {
800
- content = fs.readFileSync(filePath, "utf8");
801
- this.#cache.css.set(filePath, content);
504
+ for (const css of this.#analyzer.collectCss(file, meta, new Set())) {
505
+ cssSet.add(`/css/${path.basename(css)}`);
802
506
  }
803
507
 
804
- if (tailwindPattern.test(content)) {
805
- return true;
806
- }
807
- }
808
-
809
- return false;
810
- }
811
-
812
- #discoverEntries(baseDir) {
813
- if (!fs.existsSync(baseDir)) {
814
- return { entries: [], meta: new Map() };
815
- }
816
-
817
- const files = this.#findTsxFiles(baseDir);
818
- const { graph, imported, importedBy } = this.#buildDependencyGraph(files);
819
-
820
- const entries = [...graph.entries()]
821
- .filter(([file, meta]) => !imported.has(file) && meta.hasDefault && meta.jsx)
822
- .map(([file]) => file);
823
-
824
- this.#validateGraph(graph, entries, importedBy);
825
-
826
- return { entries, meta: graph };
827
- }
828
-
829
- #findTsxFiles(dir, result = []) {
830
- if (!fs.existsSync(dir)) {
831
- return result;
832
- }
833
-
834
- for (const item of fs.readdirSync(dir)) {
835
- const fullPath = path.join(dir, item);
836
- const stat = fs.lstatSync(fullPath);
837
-
838
- if (stat.isSymbolicLink()) {
839
- continue;
840
- }
841
-
842
- if (stat.isDirectory()) {
843
- this.#findTsxFiles(fullPath, result);
844
- } else if (fullPath.endsWith(".tsx")) {
845
- result.push(fullPath);
846
- }
847
- }
848
-
849
- return result;
850
- }
851
-
852
- #buildDependencyGraph(files) {
853
- const graph = new Map();
854
- const imported = new Set();
855
- const importedBy = new Map();
856
-
857
- for (const file of files) {
858
- const meta = this.#analyzeFile(file);
859
- graph.set(file, meta);
860
-
861
- for (const importPath of meta.imports) {
862
- const resolvedPath = this.#resolveImport(file, importPath);
863
-
864
- if (this.#isInRoot(resolvedPath)) {
865
- imported.add(resolvedPath);
866
-
867
- if (!importedBy.has(resolvedPath)) {
868
- importedBy.set(resolvedPath, []);
869
- }
870
- importedBy.get(resolvedPath).push(file);
871
- }
872
- }
873
- }
874
-
875
- return { graph, imported, importedBy };
876
- }
877
-
878
- #analyzeFile(filePath) {
879
- const source = fs.readFileSync(filePath, "utf8");
880
-
881
- let ast;
882
- try {
883
- ast = parse(source, {
884
- sourceType: "module",
885
- plugins: ["typescript", "jsx"]
886
- });
887
- } catch {
888
- return {
889
- imports: [],
890
- css: [],
891
- hasDefault: false,
892
- named: [],
893
- jsx: false,
894
- needsClient: false,
895
- isClient: false
508
+ this.#manifest[key] = {
509
+ css: [...cssSet],
510
+ layouts: layoutChain.map(l => l.name.toLowerCase()),
511
+ hydrationScript: null
896
512
  };
897
513
  }
898
514
 
899
- const isClient = ast.program.directives?.some(d => d.value?.value === "use client");
900
-
901
- const meta = {
902
- imports: new Set(),
903
- css: new Set(),
904
- hasDefault: false,
905
- named: [],
906
- jsx: false,
907
- needsClient: false,
908
- isClient,
909
- reactNamespace: null
910
- };
911
-
912
- this.#traverse(ast, {
913
- ImportDeclaration: (p) => {
914
- const source = p.node.source.value;
915
-
916
- if (source.startsWith(".")) {
917
- meta.imports.add(source);
918
- }
919
-
920
- if (source.endsWith(".css")) {
921
- const resolved = path.resolve(path.dirname(filePath), source);
922
- if (resolved.startsWith(Paths.project)) {
923
- meta.css.add(resolved);
924
- }
925
- }
926
-
927
- if (source === "react") {
928
- for (const specifier of p.node.specifiers) {
929
- if (specifier.type === "ImportSpecifier" && CLIENT_HOOKS.has(specifier.imported.name)) {
930
- meta.needsClient = true;
931
- }
932
- if (specifier.type === "ImportNamespaceSpecifier" || specifier.type === "ImportDefaultSpecifier") {
933
- meta.reactNamespace = specifier.local.name;
934
- }
935
- }
936
- }
937
- },
938
-
939
- MemberExpression: (p) => {
940
- if (!meta.needsClient &&
941
- p.node.object.name === meta.reactNamespace &&
942
- CLIENT_HOOKS.has(p.node.property.name)) {
943
- meta.needsClient = true;
944
- }
945
- },
946
-
947
- CallExpression: (p) => {
948
- if (!meta.needsClient &&
949
- p.node.callee.type === "Identifier" &&
950
- /^use[A-Z]/.test(p.node.callee.name)) {
951
- meta.needsClient = true;
952
- }
953
- },
954
-
955
- ExportNamedDeclaration: (p) => this.#extractNamedExports(p, meta),
956
- ExportDefaultDeclaration: () => { meta.hasDefault = true; },
957
- JSXElement: () => { meta.jsx = true; },
958
- JSXFragment: () => { meta.jsx = true; }
959
- });
960
-
961
- if (meta.needsClient && !meta.isClient) {
962
- throw this.#createError('Missing "use client"', {
963
- File: path.relative(Paths.project, filePath),
964
- Fix: 'Add "use client" at top'
965
- });
966
- }
967
-
968
- return {
969
- ...meta,
970
- imports: [...meta.imports],
971
- css: [...meta.css]
972
- };
973
- }
974
-
975
- #extractNamedExports(path, meta) {
976
- const declaration = path.node.declaration;
977
-
978
- if (declaration?.type === "VariableDeclaration") {
979
- for (const decl of declaration.declarations) {
980
- if (decl.id.type === "Identifier" && decl.init?.type === "ArrowFunctionExpression") {
981
- meta.named.push(decl.id.name);
982
- }
983
- }
984
- }
985
-
986
- if (declaration?.type === "FunctionDeclaration" && declaration.id?.name) {
987
- meta.named.push(declaration.id.name);
988
- }
989
- }
990
-
991
- #findExports(ast) {
992
- const exports = [];
993
-
994
- this.#traverse(ast, {
995
- ExportDefaultDeclaration: (p) => {
996
- const declaration = p.node.declaration;
997
- let name;
998
-
999
- if (declaration.type === "Identifier") {
1000
- name = declaration.name;
1001
- } else if (declaration.type === "FunctionDeclaration" && declaration.id?.name) {
1002
- name = declaration.id.name;
1003
- } else if (declaration.type === "CallExpression" &&
1004
- ["memo", "forwardRef", "lazy"].includes(declaration.callee?.name)) {
1005
- name = declaration.arguments[0]?.name || "__default__";
1006
- } else {
1007
- name = "__default__";
1008
- }
1009
-
1010
- exports.push({ name, isDefault: true });
1011
- },
1012
-
1013
- ExportNamedDeclaration: (p) => {
1014
- for (const specifier of p.node.specifiers || []) {
1015
- if (specifier.type === "ExportSpecifier") {
1016
- const name = specifier.exported.name === "default"
1017
- ? specifier.local.name
1018
- : specifier.exported.name;
1019
- exports.push({
1020
- name,
1021
- isDefault: specifier.exported.name === "default"
1022
- });
1023
- }
1024
- }
1025
-
1026
- const declaration = p.node.declaration;
1027
-
1028
- if (declaration?.type === "FunctionDeclaration" && declaration.id?.name) {
1029
- exports.push({ name: declaration.id.name, isDefault: false });
1030
- }
1031
-
1032
- if (declaration?.type === "VariableDeclaration") {
1033
- for (const decl of declaration.declarations) {
1034
- if (decl.id.type === "Identifier" && decl.init?.type === "ArrowFunctionExpression") {
1035
- exports.push({ name: decl.id.name, isDefault: false });
1036
- }
1037
- }
1038
- }
1039
- }
1040
- });
1041
-
1042
- return exports;
1043
- }
1044
-
1045
- #validateGraph(graph, entries, importedBy) {
1046
- const entrySet = new Set(entries);
1047
- const relativePath = (p) => path.relative(Paths.project, p);
1048
-
1049
- for (const [filePath, meta] of graph.entries()) {
1050
- if (!meta.isClient) {
1051
- continue;
1052
- }
1053
-
1054
- if (entrySet.has(filePath) && meta.hasDefault && meta.jsx) {
1055
- const importers = importedBy.get(filePath);
1056
- if (importers?.length) {
1057
- throw this.#createError("Client Entry Imported", {
1058
- Entry: relativePath(filePath),
1059
- By: relativePath(importers[0]),
1060
- Fix: "Use as island"
1061
- });
1062
- }
1063
- }
1064
-
1065
- // Next.js pattern: Client component cannot import server component
1066
- // Server component can render client component as children
1067
- for (const importPath of meta.imports) {
1068
- const resolvedPath = this.#resolveImport(filePath, importPath);
1069
- const resolvedMeta = graph.get(resolvedPath);
1070
-
1071
- // If importing a non-client component (server component)
1072
- if (resolvedMeta && !resolvedMeta.isClient) {
1073
- throw this.#createError("Boundary Violation", {
1074
- Client: relativePath(filePath),
1075
- Server: relativePath(resolvedPath),
1076
- Fix: `Server components cannot be imported into client components. Use composition pattern: render "${path.basename(resolvedPath, ".tsx")}" as a parent and pass client component as children.`
1077
- });
1078
- }
1079
- }
1080
- }
1081
- }
1082
-
1083
- #addToManifest(entries, meta, baseDir, namespace) {
1084
- for (const file of entries) {
1085
- const fileMeta = meta.get(file);
1086
- const viewPath = path.relative(baseDir, file)
515
+ for (const layout of layouts) {
516
+ const layoutPath = path.relative(baseDir, layout)
1087
517
  .replace(/\.tsx$/, "")
1088
- .toLowerCase()
1089
518
  .replace(/\\/g, "/");
1090
519
 
1091
- const key = `${namespace}:${viewPath}`;
520
+ const key = `${namespace}:layout:${layoutPath.toLowerCase()}`;
1092
521
 
1093
522
  if (this.#manifest[key]) {
1094
- throw new Error(`Duplicate: ${key}`);
523
+ throw new Error(`Duplicate layout: ${key}`);
1095
524
  }
1096
525
 
1097
- const cssFiles = [...this.#collectCss(file, meta, new Set())]
526
+ const cssFiles = [...this.#analyzer.collectCss(layout, meta, new Set())]
1098
527
  .map(cssPath => `/css/${path.basename(cssPath)}`);
1099
528
 
1100
529
  this.#manifest[key] = {
1101
530
  css: cssFiles,
1102
- hydrationScript: null
531
+ isLayout: true
1103
532
  };
1104
533
  }
1105
534
  }
1106
535
 
1107
536
  #writeManifest() {
1108
- const manifestPath = path.join(Paths.project, "build", "manifest.json");
537
+ const manifestPath = path.join(Paths.build, "manifest.json");
1109
538
  fs.mkdirSync(path.dirname(manifestPath), { recursive: true });
1110
539
  fs.writeFileSync(manifestPath, JSON.stringify(this.#manifest, null, 2));
1111
540
  }
1112
541
 
1113
- #collectCss(file, meta, seen, depth = 0) {
1114
- if (depth > MAX_DEPTH || seen.has(file)) {
1115
- return new Set();
1116
- }
1117
- seen.add(file);
1118
-
1119
- const fileMeta = meta.get(file);
1120
- if (!fileMeta) {
1121
- return new Set();
1122
- }
1123
-
1124
- const result = new Set(fileMeta.css);
1125
-
1126
- for (const importPath of fileMeta.imports) {
1127
- const resolvedPath = this.#resolveImport(file, importPath);
1128
-
1129
- if (meta.has(resolvedPath)) {
1130
- const childCss = this.#collectCss(resolvedPath, meta, seen, depth + 1);
1131
- for (const cssPath of childCss) {
1132
- result.add(cssPath);
1133
- }
1134
- }
1135
- }
1136
-
1137
- return result;
1138
- }
1139
-
1140
- #resolveImport(fromFile, relativePath) {
1141
- const cacheKey = `${fromFile}|${relativePath}`;
1142
- const cached = this.#cache.imports.get(cacheKey);
1143
-
1144
- if (cached) {
1145
- return cached;
1146
- }
1147
-
1148
- const resolved = path.resolve(path.dirname(fromFile), relativePath);
1149
-
1150
- if (!this.#isInRoot(resolved)) {
1151
- throw this.#createError("Path Traversal", {
1152
- Import: relativePath,
1153
- Outside: resolved
1154
- });
1155
- }
1156
-
1157
- const extensions = [".tsx", ".ts", ".jsx", ".js", "/index.tsx", "/index.ts", ""];
1158
-
1159
- for (const ext of extensions) {
1160
- const fullPath = resolved + ext;
1161
-
1162
- if (ext === "" && fs.existsSync(fullPath) && fs.statSync(fullPath).isFile()) {
1163
- this.#cache.imports.set(cacheKey, fullPath);
1164
- return fullPath;
1165
- }
1166
-
1167
- if (ext && fs.existsSync(fullPath)) {
1168
- this.#cache.imports.set(cacheKey, fullPath);
1169
- return fullPath;
1170
- }
1171
- }
1172
-
1173
- this.#cache.imports.set(cacheKey, resolved);
1174
- return resolved;
1175
- }
1176
-
1177
- #isInRoot(filePath) {
1178
- const normalized = path.normalize(filePath);
1179
- const projectNormalized = path.normalize(Paths.project);
1180
- const frameworkNormalized = path.normalize(Paths.framework);
1181
-
1182
- // Allow both project paths and framework paths
1183
- return normalized.startsWith(projectNormalized + path.sep) ||
1184
- normalized === projectNormalized ||
1185
- normalized.startsWith(frameworkNormalized + path.sep) ||
1186
- normalized === frameworkNormalized;
1187
- }
1188
-
1189
542
  #writeJsxRuntime() {
1190
543
  fs.mkdirSync(path.dirname(this.#paths.jsxRuntime), { recursive: true });
1191
544
  fs.writeFileSync(this.#paths.jsxRuntime, JSX_RUNTIME);
@@ -1198,8 +551,11 @@ class Builder {
1198
551
  this.#paths.nitronTemp
1199
552
  ];
1200
553
 
554
+ const projectDir = path.normalize(Paths.project) + path.sep;
555
+
1201
556
  for (const dir of dirsToClean) {
1202
- if (!dir.startsWith(Paths.project)) {
557
+ const normalizedDir = path.normalize(dir);
558
+ if (!normalizedDir.startsWith(projectDir)) {
1203
559
  throw new Error(`Unsafe path: ${dir}`);
1204
560
  }
1205
561
 
@@ -1210,22 +566,20 @@ class Builder {
1210
566
  }
1211
567
 
1212
568
  #cleanupTemp() {
1213
- if (fs.existsSync(this.#paths.nitronTemp)) {
1214
- if (!this.#paths.nitronTemp.startsWith(Paths.project)) {
1215
- throw new Error(`Unsafe path: ${this.#paths.nitronTemp}`);
1216
- }
1217
- fs.rmSync(this.#paths.nitronTemp, { recursive: true, force: true });
1218
- }
1219
- }
1220
-
1221
- #createError(title, details = {}) {
1222
- let message = `\n${COLORS.red}✖ ${title}${COLORS.reset}\n\n`;
569
+ const projectDir = path.normalize(Paths.project) + path.sep;
570
+ const normalizedTemp = path.normalize(this.#paths.nitronTemp);
1223
571
 
1224
- for (const [key, value] of Object.entries(details)) {
1225
- message += ` ${key}: ${COLORS.yellow}${value}${COLORS.reset}\n`;
572
+ if (!normalizedTemp.startsWith(projectDir)) {
573
+ throw new Error(`Unsafe path: ${this.#paths.nitronTemp}`);
1226
574
  }
1227
575
 
1228
- return new Error(message);
576
+ if (fs.existsSync(this.#paths.nitronTemp)) {
577
+ for (const entry of fs.readdirSync(this.#paths.nitronTemp)) {
578
+ if (entry === "build-cache.json") continue;
579
+ const fullPath = path.join(this.#paths.nitronTemp, entry);
580
+ fs.rmSync(fullPath, { recursive: true, force: true });
581
+ }
582
+ }
1229
583
  }
1230
584
  }
1231
585