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