@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
@@ -0,0 +1,120 @@
1
+ // Signatures ordered by total check bytes (offset + bytes.length) descending
2
+ const SIGNATURES = [
3
+ // 12-byte region: WebP (RIFF at 0, WEBP at 8)
4
+ { regions: [{ bytes: [0x52, 0x49, 0x46, 0x46], offset: 0 }, { bytes: [0x57, 0x45, 0x42, 0x50], offset: 8 }], mime: "image/webp" },
5
+ // 8 bytes
6
+ { bytes: [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A], offset: 0, mime: "image/png" },
7
+ // 8-byte region: MP4/ISOBMFF (ftyp at offset 4)
8
+ { bytes: [0x66, 0x74, 0x79, 0x70], offset: 4, mime: "video/mp4" },
9
+ // 6 bytes
10
+ { bytes: [0x37, 0x7A, 0xBC, 0xAF, 0x27, 0x1C], offset: 0, mime: "application/x-7z-compressed" },
11
+ { bytes: [0x52, 0x61, 0x72, 0x21, 0x1A, 0x07], offset: 0, mime: "application/x-rar-compressed" },
12
+ // 4 bytes
13
+ { bytes: [0x25, 0x50, 0x44, 0x46], offset: 0, mime: "application/pdf" },
14
+ { bytes: [0x50, 0x4B, 0x03, 0x04], offset: 0, mime: "application/zip" },
15
+ { bytes: [0x47, 0x49, 0x46, 0x38], offset: 0, mime: "image/gif" },
16
+ { bytes: [0x1A, 0x45, 0xDF, 0xA3], offset: 0, mime: "video/webm" },
17
+ { bytes: [0x00, 0x00, 0x01, 0x00], offset: 0, mime: "image/x-icon" },
18
+ // 3 bytes
19
+ { bytes: [0xFF, 0xD8, 0xFF], offset: 0, mime: "image/jpeg" },
20
+ { bytes: [0x49, 0x44, 0x33], offset: 0, mime: "audio/mpeg" },
21
+ // 2 bytes
22
+ { bytes: [0x1F, 0x8B], offset: 0, mime: "application/gzip" },
23
+ { bytes: [0x42, 0x4D], offset: 0, mime: "image/bmp" }
24
+ ];
25
+
26
+ const MIME_FAMILIES = [
27
+ // ZIP family (jar/apk excluded)
28
+ ["application/zip", "application/vnd.openxmlformats-officedocument.wordprocessingml.document", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "application/vnd.openxmlformats-officedocument.presentationml.presentation", "application/vnd.oasis.opendocument.text", "application/vnd.oasis.opendocument.spreadsheet", "application/vnd.oasis.opendocument.presentation"],
29
+ // MPEG-4/ISOBMFF family
30
+ ["video/mp4", "audio/mp4", "audio/m4a", "video/quicktime", "video/3gpp"],
31
+ // EBML family
32
+ ["video/webm", "video/x-matroska", "audio/webm"]
33
+ ];
34
+
35
+ /**
36
+ * Detect MIME type from buffer magic bytes.
37
+ * Returns null if buffer is empty or no signature matches.
38
+ */
39
+ export function detectMime(buffer) {
40
+ if (!buffer || buffer.length === 0) {
41
+ return null;
42
+ }
43
+
44
+ for (const sig of SIGNATURES) {
45
+ if (sig.regions) {
46
+ let allMatch = true;
47
+
48
+ for (const region of sig.regions) {
49
+ if (buffer.length < region.offset + region.bytes.length) {
50
+ allMatch = false;
51
+ break;
52
+ }
53
+
54
+ for (let i = 0; i < region.bytes.length; i++) {
55
+ if (buffer[region.offset + i] !== region.bytes[i]) {
56
+ allMatch = false;
57
+ break;
58
+ }
59
+ }
60
+
61
+ if (!allMatch) break;
62
+ }
63
+
64
+ if (allMatch) return sig.mime;
65
+ }
66
+ else {
67
+ if (buffer.length < sig.offset + sig.bytes.length) continue;
68
+
69
+ let match = true;
70
+
71
+ for (let i = 0; i < sig.bytes.length; i++) {
72
+ if (buffer[sig.offset + i] !== sig.bytes[i]) {
73
+ match = false;
74
+ break;
75
+ }
76
+ }
77
+
78
+ if (match) return sig.mime;
79
+ }
80
+ }
81
+
82
+ return null;
83
+ }
84
+
85
+ /**
86
+ * Check if two MIME types belong to the same family.
87
+ * ZIP family includes Office XML formats. MPEG-4 family shares ftyp. EBML family shares header.
88
+ */
89
+ export function isSameMimeFamily(mime1, mime2) {
90
+ if (!mime1 || !mime2) return false;
91
+
92
+ const m1 = mime1.toLowerCase(), m2 = mime2.toLowerCase();
93
+
94
+ if (m1 === m2) return true;
95
+
96
+ for (const family of MIME_FAMILIES) {
97
+ const has1 = family.includes(m1) || (family === MIME_FAMILIES[0] && (m1.startsWith("application/vnd.openxmlformats-officedocument.") || m1.startsWith("application/vnd.oasis.opendocument.")));
98
+ const has2 = family.includes(m2) || (family === MIME_FAMILIES[0] && (m2.startsWith("application/vnd.openxmlformats-officedocument.") || m2.startsWith("application/vnd.oasis.opendocument.")));
99
+
100
+ if (has1 && has2) return true;
101
+ }
102
+
103
+ return false;
104
+ }
105
+
106
+ // Regex: null bytes, URL-encoded null, bidi/invisible Unicode, control chars
107
+ const UNSAFE_CHARS = /\x00|%00|[\u202A-\u202E\u200E\u200F\u2066-\u2069\u200B-\u200D\uFEFF]|[\x01-\x08\x0B\x0C\x0E-\x1F]/gi;
108
+
109
+ /**
110
+ * Strip dangerous characters from filename.
111
+ * Removes null bytes, URL-encoded nulls, Unicode bidi/invisible chars, control chars.
112
+ * Returns "unnamed" if result is empty.
113
+ */
114
+ export function sanitizeFilename(filename) {
115
+ if (!filename) return "unnamed";
116
+
117
+ const cleaned = filename.replace(UNSAFE_CHARS, "");
118
+
119
+ return cleaned || "unnamed";
120
+ }
@@ -1,4 +1,5 @@
1
1
  import { MIME_TYPES } from "./MimeTypes.js";
2
+ import { isSameMimeFamily } from "./MagicBytes.js";
2
3
 
3
4
  /**
4
5
  * Laravel-style Validator for NitronJS
@@ -8,13 +9,12 @@ import { MIME_TYPES } from "./MimeTypes.js";
8
9
  * - Use "required" to make a field mandatory
9
10
  * - Use "nullable" to explicitly allow null (skips ALL validation)
10
11
  *
11
- * SECURITY WARNING:
12
- * File validation (mimes, mime_types) checks MIME headers only.
13
- * This is NOT secure - attackers can spoof MIME types.
14
- * Always verify file signatures (magic bytes) server-side before storage.
12
+ * SECURITY:
13
+ * File validation (mimes, mime_types) uses magic byte verification via preHandler
14
+ * when available. Falls back to declared MIME type for unrecognized formats.
15
15
  *
16
16
  * UNITS:
17
- * - min/max for files: BYTES (e.g., max:2097152 = 2MB)
17
+ * - min/max for files: KB (e.g., max:2048 = 2MB)
18
18
  * - min/max for strings: characters
19
19
  * - min/max for arrays: item count
20
20
  * - min/max for numbers: numeric value
@@ -76,6 +76,9 @@ class Validator {
76
76
 
77
77
  for (let i = 0; i < parts.length - 1; i++) {
78
78
  const part = parts[i];
79
+
80
+ if (part === "__proto__" || part === "constructor" || part === "prototype") break;
81
+
79
82
  const nextPart = parts[i + 1];
80
83
  const isNextIndex = /^\d+$/.test(nextPart);
81
84
 
@@ -138,9 +141,21 @@ class Validator {
138
141
 
139
142
  // Backend Multipart (Fastify @fastify/multipart)
140
143
  if (typeof value.mimetype === "string" && typeof value.toBuffer === "function") {
144
+ let mime = value.mimetype.toLowerCase();
145
+
146
+ // Three-way _magicMime branch:
147
+ // undefined → magic byte check not performed, use declared MIME
148
+ // null → check performed but unrecognized, fallback to declared MIME
149
+ // string → check performed and recognized, use magic MIME (with family check)
150
+ if (typeof value._magicMime === "string") {
151
+ if (!isSameMimeFamily(mime, value._magicMime)) {
152
+ mime = value._magicMime;
153
+ }
154
+ }
155
+
141
156
  return {
142
- mime: value.mimetype.toLowerCase(),
143
- size: value._buf?.length ?? null
157
+ mime,
158
+ size: value._fileSize ?? value._buf?.length ?? null
144
159
  };
145
160
  }
146
161
 
@@ -494,7 +509,7 @@ class Validator {
494
509
  * Minimum size/length/value
495
510
  * - Arrays: minimum item count
496
511
  * - Strings: minimum character count
497
- * - Files: minimum size in BYTES
512
+ * - Files: minimum size in KB
498
513
  * - Numbers: minimum value
499
514
  */
500
515
  min: (value, params, field) => {
@@ -512,6 +527,17 @@ class Validator {
512
527
  };
513
528
  }
514
529
 
530
+ // File: size in KB (before generic object — file objects are also objects)
531
+ const file = this.#getFileInfo(value);
532
+ if (file && file.size !== null) {
533
+ const minBytes = min * 1024;
534
+
535
+ return {
536
+ passes: file.size >= minBytes,
537
+ message: `The ${field} file must be at least ${this.#formatBytes(minBytes)}.`
538
+ };
539
+ }
540
+
515
541
  // Object: property count
516
542
  if (value !== null && typeof value === "object") {
517
543
  const keyCount = Object.keys(value).length;
@@ -521,15 +547,6 @@ class Validator {
521
547
  };
522
548
  }
523
549
 
524
- // File: size in bytes
525
- const file = this.#getFileInfo(value);
526
- if (file && file.size !== null) {
527
- return {
528
- passes: file.size >= min,
529
- message: `The ${field} file must be at least ${this.#formatBytes(min)}.`
530
- };
531
- }
532
-
533
550
  // String: character count
534
551
  if (typeof value === "string") {
535
552
  return {
@@ -556,7 +573,7 @@ class Validator {
556
573
  * Maximum size/length/value
557
574
  * - Arrays: maximum item count
558
575
  * - Strings: maximum character count
559
- * - Files: maximum size in BYTES
576
+ * - Files: maximum size in KB
560
577
  * - Numbers: maximum value
561
578
  */
562
579
  max: (value, params, field) => {
@@ -574,6 +591,17 @@ class Validator {
574
591
  };
575
592
  }
576
593
 
594
+ // File: size in KB (before generic object — file objects are also objects)
595
+ const file = this.#getFileInfo(value);
596
+ if (file && file.size !== null) {
597
+ const maxBytes = max * 1024;
598
+
599
+ return {
600
+ passes: file.size <= maxBytes,
601
+ message: `The ${field} file must not exceed ${this.#formatBytes(maxBytes)}.`
602
+ };
603
+ }
604
+
577
605
  // Object: property count
578
606
  if (value !== null && typeof value === "object") {
579
607
  const keyCount = Object.keys(value).length;
@@ -583,15 +611,6 @@ class Validator {
583
611
  };
584
612
  }
585
613
 
586
- // File: size in bytes
587
- const file = this.#getFileInfo(value);
588
- if (file && file.size !== null) {
589
- return {
590
- passes: file.size <= max,
591
- message: `The ${field} file must not exceed ${this.#formatBytes(max)}.`
592
- };
593
- }
594
-
595
614
  // String: character count
596
615
  if (typeof value === "string") {
597
616
  return {
@@ -615,8 +634,6 @@ class Validator {
615
634
  },
616
635
 
617
636
  // ─── File Rules ──────────────────────────────────────────────────────
618
- // ⚠️ WARNING: These rules check MIME headers only, which can be spoofed.
619
- // Always verify file signatures (magic bytes) server-side!
620
637
 
621
638
  /** Must be a valid file object */
622
639
  file: (value, params, field) => {
@@ -1,14 +1,24 @@
1
1
  /**
2
- * HMR Client - Browser-side hot module replacement handler.
3
- * Connects to the HMR WebSocket server and handles live updates.
2
+ * HMR Client Browser-side hot module replacement handler.
3
+ *
4
+ * Connects to the dev server via Socket.IO WebSocket.
5
+ * On file change events, fetches a fresh RSC (React Server Components)
6
+ * Flight payload and lets React reconcile the DOM — no full page reload needed.
7
+ *
8
+ * Wire format from /__nitron/rsc:
9
+ * "<flightLength>\n<flightPayload><jsonMetadata>"
10
+ * - First line: byte length of the Flight payload
11
+ * - Then the Flight payload itself (React serialization format)
12
+ * - Then a JSON object with { meta, css, translations }
4
13
  */
5
14
  (function() {
6
15
  "use strict";
7
16
 
17
+ var RSC_ENDPOINT = "/__nitron/rsc";
8
18
  var socket = null;
9
- var errorOverlay = null;
10
19
 
11
- // Connection
20
+ // --- Connection ---
21
+
12
22
  function connect() {
13
23
  if (socket) return;
14
24
 
@@ -23,12 +33,14 @@
23
33
  path: "/__nitron_hmr",
24
34
  transports: ["websocket"],
25
35
  reconnection: true,
26
- reconnectionAttempts: 5,
36
+ reconnectionAttempts: Infinity,
27
37
  reconnectionDelay: 1000,
38
+ reconnectionDelayMax: 5000,
28
39
  timeout: 5000
29
40
  });
30
41
  }
31
42
  catch (e) {
43
+ // Socket.IO not available — HMR disabled silently (dev-only feature)
32
44
  return;
33
45
  }
34
46
 
@@ -45,131 +57,129 @@
45
57
  window.__nitron_hmr_connected__ = false;
46
58
  });
47
59
 
48
- socket.on("hmr:update", function() {
49
- refetchPage();
60
+ // Unified RSC-based change handler
61
+ socket.on("hmr:change", function(data) {
62
+ hideErrorOverlay();
63
+
64
+ if (data.changeType === "css") {
65
+ refreshCss();
66
+ return;
67
+ }
68
+
69
+ // page, layout — all use RSC refetch
70
+ refetchRSC(data.cssChanged);
50
71
  });
51
72
 
52
73
  socket.on("hmr:reload", function() {
53
74
  location.reload();
54
75
  });
55
76
 
56
- socket.on("hmr:css", function(data) {
57
- refreshCss(data.file);
58
- });
59
-
60
77
  socket.on("hmr:error", function(data) {
61
78
  showErrorOverlay(data.message || "Unknown error", data.file);
62
79
  });
63
80
  }
64
81
 
65
- // Page Updates
66
- function refetchPage() {
82
+ // --- RSC Wire Format Parser ---
83
+
84
+ // Parses the length-prefixed response from /__nitron/rsc.
85
+ // Format: "<length>\n<flight payload><json metadata>"
86
+ // Returns { payload, meta, css, translations } or null on malformed input.
87
+ function parseLengthPrefixed(text) {
88
+ var nl = text.indexOf("\n");
89
+ if (nl === -1) return null;
90
+
91
+ var len = parseInt(text.substring(0, nl), 10);
92
+ if (isNaN(len) || len < 0) return null;
93
+
94
+ var flight = text.substring(nl + 1, nl + 1 + len);
95
+ var jsonStr = text.substring(nl + 1 + len);
96
+ var data;
97
+
98
+ try { data = JSON.parse(jsonStr); }
99
+ catch (e) { return null; }
100
+
101
+ return {
102
+ payload: flight,
103
+ meta: data.meta || null,
104
+ css: data.css || null,
105
+ translations: data.translations || null
106
+ };
107
+ }
108
+
109
+ // --- RSC Page Update ---
110
+
111
+ // Fetches a fresh Flight payload for the current URL and hands it to React.
112
+ // React's reconciliation updates only the changed DOM nodes — no full reload.
113
+ function refetchRSC(cssChanged) {
114
+ var rsc = window.__NITRON_RSC__;
115
+
116
+ if (!rsc || !rsc.root) {
117
+ location.reload();
118
+ return;
119
+ }
120
+
67
121
  var url = location.pathname + location.search;
68
122
 
69
- fetch("/__nitron/navigate?url=" + encodeURIComponent(url), {
123
+ fetch(RSC_ENDPOINT + "?url=" + encodeURIComponent(url), {
70
124
  headers: { "X-Nitron-SPA": "1" },
71
125
  credentials: "same-origin"
72
126
  })
73
- .then(function(response) {
74
- if (!response.ok) throw new Error("HTTP " + response.status);
127
+ .then(function(r) {
128
+ if (!r.ok) throw new Error("HTTP " + r.status);
75
129
 
76
- return response.json();
130
+ return r.text().then(function(text) {
131
+ return parseLengthPrefixed(text);
132
+ });
77
133
  })
78
- .then(function(data) {
79
- if (data.error || data.redirect) {
134
+ .then(function(d) {
135
+ if (!d || !d.payload) {
80
136
  location.reload();
81
-
82
137
  return;
83
138
  }
84
139
 
85
- var slot = document.querySelector("[data-nitron-slot='page']");
86
-
87
- if (!slot || !data.html) {
88
- location.reload();
140
+ // Merge translations BEFORE navigate — persistent components (layouts) keep their keys
141
+ if (d.translations) {
142
+ var prev = window.__NITRON_TRANSLATIONS__;
89
143
 
90
- return;
144
+ window.__NITRON_TRANSLATIONS__ = prev
145
+ ? Object.assign({}, prev, d.translations)
146
+ : d.translations;
91
147
  }
92
148
 
93
- var container = document.createElement("div");
94
- container.innerHTML = data.html;
95
-
96
- var newSlot = container.querySelector("[data-nitron-slot='page']");
97
- slot.innerHTML = newSlot ? newSlot.innerHTML : data.html;
98
-
99
- if (data.hydrationScript) {
100
- if (data.runtime && window.__NITRON_RUNTIME__) {
101
- Object.assign(window.__NITRON_RUNTIME__, data.runtime);
102
- }
149
+ // React reconciliation — only changed DOM nodes update
150
+ rsc.navigate(d.payload);
103
151
 
104
- if (data.props) {
105
- window.__NITRON_PROPS__ = data.props;
106
- }
152
+ if (d.meta && d.meta.title) document.title = d.meta.title;
107
153
 
108
- loadHydrationScript(data.hydrationScript);
109
- }
154
+ if (cssChanged) refreshCss();
110
155
  })
111
156
  .catch(function() {
157
+ // RSC fetch failed — full reload as last resort
112
158
  location.reload();
113
159
  });
114
160
  }
115
161
 
116
- function loadHydrationScript(scriptPath) {
117
- var script = document.createElement("script");
118
- script.type = "module";
119
- script.src = "/storage" + scriptPath + "?t=" + Date.now();
120
-
121
- script.onload = function() {
122
- if (window.__NITRON_REFRESH__) {
123
- try {
124
- window.__NITRON_REFRESH__.performReactRefresh();
125
- }
126
- catch (e) {}
127
- }
162
+ // --- CSS Hot Reload ---
128
163
 
129
- script.remove();
130
- };
131
-
132
- document.head.appendChild(script);
133
- }
134
-
135
- // CSS Updates
136
- function refreshCss(filename) {
164
+ // Cache-busts all stylesheets by appending ?t=<timestamp>
165
+ function refreshCss() {
137
166
  var links = document.querySelectorAll('link[rel="stylesheet"]');
138
167
  var timestamp = Date.now();
139
168
 
140
- if (!filename) {
141
- for (var i = 0; i < links.length; i++) {
142
- var href = (links[i].href || "").split("?")[0];
143
- links[i].href = href + "?t=" + timestamp;
144
- }
145
-
146
- return;
147
- }
148
-
149
- var found = false;
150
- var target = filename.split(/[\\/]/).pop();
151
-
152
169
  for (var i = 0; i < links.length; i++) {
153
170
  var href = (links[i].href || "").split("?")[0];
154
-
155
- if (href.endsWith("/" + target)) {
156
- links[i].href = href + "?t=" + timestamp;
157
- found = true;
158
- }
159
- }
160
-
161
- if (!found) {
162
- location.reload();
171
+ links[i].href = href + "?t=" + timestamp;
163
172
  }
164
173
  }
165
174
 
166
- // Error Overlay
175
+ // --- Build Error Overlay ---
176
+
167
177
  function showErrorOverlay(message, file) {
168
178
  hideErrorOverlay();
169
179
 
170
- errorOverlay = document.createElement("div");
171
- errorOverlay.id = "__nitron_error__";
172
- errorOverlay.innerHTML =
180
+ var overlay = document.createElement("div");
181
+ overlay.id = "__nitron_error__";
182
+ overlay.innerHTML =
173
183
  '<div style="position:fixed;inset:0;background:rgba(0,0,0,.95);color:#ff4444;padding:32px;font-family:monospace;z-index:999999;overflow:auto">' +
174
184
  '<div style="font-size:24px;font-weight:bold;margin-bottom:16px">Build Error</div>' +
175
185
  '<div style="color:#888;margin-bottom:16px">' + escapeHtml(file || "") + '</div>' +
@@ -177,7 +187,7 @@
177
187
  '<button onclick="this.parentNode.parentNode.remove()" style="position:absolute;top:16px;right:16px;background:#333;color:#fff;border:none;padding:8px 16px;cursor:pointer;border-radius:4px">Close</button>' +
178
188
  '</div>';
179
189
 
180
- document.body.appendChild(errorOverlay);
190
+ document.body.appendChild(overlay);
181
191
  }
182
192
 
183
193
  function hideErrorOverlay() {
@@ -186,11 +196,10 @@
186
196
  if (el) {
187
197
  el.remove();
188
198
  }
189
-
190
- errorOverlay = null;
191
199
  }
192
200
 
193
- // Utilities
201
+ // --- Utilities ---
202
+
194
203
  function escapeHtml(str) {
195
204
  return String(str || "")
196
205
  .replace(/&/g, "&amp;")
@@ -198,7 +207,8 @@
198
207
  .replace(/>/g, "&gt;");
199
208
  }
200
209
 
201
- // Initialize
210
+ // --- Initialize ---
211
+
202
212
  if (document.readyState === "loading") {
203
213
  document.addEventListener("DOMContentLoaded", connect);
204
214
  }