@nitronjs/framework 0.2.26 → 0.3.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.
- 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 +390 -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 +177 -24
- 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 +144 -1
- package/lib/Session/Redis.js +117 -0
- 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 +3 -2
- package/skeleton/config/app.js +1 -0
- package/skeleton/config/server.js +13 -0
- package/skeleton/config/session.js +4 -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/Client/spa.js
CHANGED
|
@@ -1,20 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SPA Client — Client-side navigation using RSC (React Server Components).
|
|
3
|
+
*
|
|
4
|
+
* Intercepts local <a> clicks and browser back/forward events,
|
|
5
|
+
* fetches a fresh RSC Flight payload from /__nitron/rsc, and lets
|
|
6
|
+
* React reconcile the DOM without a full page reload.
|
|
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 }
|
|
13
|
+
*/
|
|
1
14
|
(function() {
|
|
2
15
|
"use strict";
|
|
3
16
|
|
|
4
|
-
var
|
|
17
|
+
var RSC_ENDPOINT = "/__nitron/rsc";
|
|
18
|
+
|
|
19
|
+
// URLs that should never be intercepted by SPA navigation
|
|
5
20
|
var SKIP = /^(\/storage|\/api|\/__|#|javascript:|mailto:|tel:)/;
|
|
6
|
-
|
|
21
|
+
|
|
7
22
|
var navigating = false;
|
|
8
23
|
|
|
9
|
-
//
|
|
24
|
+
// --- Client-side route() helper ---
|
|
25
|
+
|
|
26
|
+
// Mirrors the server-side route() global — resolves named routes to URL paths.
|
|
27
|
+
// Route definitions are injected by the server into window.__NITRON_RUNTIME__.routes.
|
|
10
28
|
globalThis.route = function(name, params) {
|
|
11
29
|
var runtime = window.__NITRON_RUNTIME__;
|
|
30
|
+
|
|
12
31
|
if (!runtime || !runtime.routes) {
|
|
13
32
|
console.error("Route runtime not initialized");
|
|
14
33
|
return "";
|
|
15
34
|
}
|
|
16
35
|
|
|
17
36
|
var pattern = runtime.routes[name];
|
|
37
|
+
|
|
18
38
|
if (!pattern) {
|
|
19
39
|
console.error('Route "' + name + '" not found');
|
|
20
40
|
return "";
|
|
@@ -28,89 +48,140 @@
|
|
|
28
48
|
});
|
|
29
49
|
};
|
|
30
50
|
|
|
51
|
+
// --- Link Interception ---
|
|
52
|
+
|
|
31
53
|
function init() {
|
|
32
|
-
|
|
33
|
-
if (rt && rt.layouts) layouts = rt.layouts;
|
|
54
|
+
// Capture phase (true) so we intercept before any stopPropagation
|
|
34
55
|
document.addEventListener("click", onClick, true);
|
|
35
56
|
window.addEventListener("popstate", onPopState);
|
|
36
57
|
}
|
|
37
58
|
|
|
59
|
+
// Intercepts <a> clicks — if the link is local, navigates via RSC instead of full reload
|
|
38
60
|
function onClick(e) {
|
|
39
61
|
if (navigating) return;
|
|
40
62
|
var link = e.target.closest("a");
|
|
63
|
+
|
|
41
64
|
if (!link) return;
|
|
65
|
+
|
|
42
66
|
var href = link.getAttribute("href");
|
|
67
|
+
|
|
43
68
|
if (!href || link.target === "_blank" || link.download) return;
|
|
44
69
|
if (e.ctrlKey || e.metaKey || e.shiftKey || e.altKey) return;
|
|
45
70
|
if (!isLocal(href)) return;
|
|
71
|
+
|
|
46
72
|
e.preventDefault();
|
|
47
73
|
navigate(href, true);
|
|
48
74
|
}
|
|
49
75
|
|
|
76
|
+
// Browser back/forward — re-fetch RSC payload for the new URL
|
|
50
77
|
function onPopState() {
|
|
51
78
|
navigate(location.pathname + location.search, false);
|
|
52
79
|
}
|
|
53
80
|
|
|
81
|
+
// Returns true if the href points to the same origin and is not in the SKIP list
|
|
54
82
|
function isLocal(href) {
|
|
55
83
|
if (!href || SKIP.test(href)) return false;
|
|
84
|
+
|
|
85
|
+
// Absolute URLs — check if same origin
|
|
56
86
|
if (/^https?:\/\/|^\/\//.test(href)) {
|
|
57
87
|
try { return new URL(href, location.origin).origin === location.origin; }
|
|
58
88
|
catch (e) { return false; }
|
|
59
89
|
}
|
|
90
|
+
|
|
60
91
|
return true;
|
|
61
92
|
}
|
|
62
93
|
|
|
94
|
+
// --- RSC Wire Format Parser ---
|
|
95
|
+
|
|
96
|
+
// Parses the length-prefixed response from /__nitron/rsc.
|
|
97
|
+
// Format: "<length>\n<flight payload><json metadata>"
|
|
98
|
+
// Returns { payload, meta, css, translations } or null on malformed input.
|
|
99
|
+
function parseLengthPrefixed(text) {
|
|
100
|
+
var nl = text.indexOf("\n");
|
|
101
|
+
if (nl === -1) return null;
|
|
102
|
+
|
|
103
|
+
var len = parseInt(text.substring(0, nl), 10);
|
|
104
|
+
if (isNaN(len) || len < 0) return null;
|
|
105
|
+
|
|
106
|
+
var flight = text.substring(nl + 1, nl + 1 + len);
|
|
107
|
+
var jsonStr = text.substring(nl + 1 + len);
|
|
108
|
+
var data;
|
|
109
|
+
|
|
110
|
+
try { data = JSON.parse(jsonStr); }
|
|
111
|
+
catch (e) { return null; }
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
payload: flight,
|
|
115
|
+
meta: data.meta || null,
|
|
116
|
+
css: data.css || null,
|
|
117
|
+
translations: data.translations || null
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// --- SPA Navigation ---
|
|
122
|
+
|
|
123
|
+
// Core navigation: fetches RSC payload and lets React reconcile.
|
|
124
|
+
// Falls back to full page load on error or timeout (10s).
|
|
63
125
|
function navigate(url, push) {
|
|
64
126
|
if (navigating) return;
|
|
127
|
+
|
|
128
|
+
var rsc = window.__NITRON_RSC__;
|
|
129
|
+
|
|
130
|
+
// RSC root not available (e.g. initial page was server-rendered without hydration)
|
|
131
|
+
if (!rsc || !rsc.root) {
|
|
132
|
+
location.href = url;
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
|
|
65
136
|
navigating = true;
|
|
66
137
|
|
|
138
|
+
// Abort + fallback after 10 seconds to prevent hanging navigations
|
|
67
139
|
var ctrl = typeof AbortController !== "undefined" ? new AbortController() : null;
|
|
68
140
|
var timer = setTimeout(function() { if (ctrl) ctrl.abort(); fallback(); }, 10000);
|
|
69
141
|
|
|
70
|
-
fetch(
|
|
142
|
+
fetch(RSC_ENDPOINT + "?url=" + encodeURIComponent(url), {
|
|
71
143
|
headers: { "X-Nitron-SPA": "1" },
|
|
72
144
|
credentials: "same-origin",
|
|
73
145
|
signal: ctrl ? ctrl.signal : undefined
|
|
74
146
|
})
|
|
75
147
|
.then(function(r) {
|
|
76
148
|
clearTimeout(timer);
|
|
149
|
+
|
|
77
150
|
if (!r.ok) throw new Error("HTTP " + r.status);
|
|
78
|
-
|
|
151
|
+
|
|
152
|
+
return r.text().then(function(text) {
|
|
153
|
+
return parseLengthPrefixed(text);
|
|
154
|
+
});
|
|
79
155
|
})
|
|
80
156
|
.then(function(d) {
|
|
81
|
-
if (d.
|
|
82
|
-
if (d.redirect) {
|
|
83
|
-
navigating = false;
|
|
84
|
-
isLocal(d.redirect) ? navigate(d.redirect, push) : (location.href = d.redirect);
|
|
85
|
-
return;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
if (!layouts.length || !arrEq(layouts, d.layouts || [])) {
|
|
89
|
-
fallback();
|
|
90
|
-
return;
|
|
91
|
-
}
|
|
157
|
+
if (!d || !d.payload) { fallback(); return; }
|
|
92
158
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
159
|
+
// Merge translations BEFORE rsc.navigate — persistent components (layouts) keep their keys
|
|
160
|
+
if (d.translations) {
|
|
161
|
+
var prev = window.__NITRON_TRANSLATIONS__;
|
|
96
162
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
loadScript("/storage" + d.hydrationScript + "?t=" + Date.now(), true);
|
|
163
|
+
window.__NITRON_TRANSLATIONS__ = prev
|
|
164
|
+
? Object.assign({}, prev, d.translations)
|
|
165
|
+
: d.translations;
|
|
101
166
|
}
|
|
102
167
|
|
|
168
|
+
// Use RSC consumer to render new payload via React reconciliation
|
|
169
|
+
rsc.navigate(d.payload);
|
|
170
|
+
|
|
103
171
|
if (push) history.pushState({ nitron: 1 }, "", url);
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
172
|
+
|
|
173
|
+
if (d.meta && d.meta.title) document.title = d.meta.title;
|
|
174
|
+
setMeta("description", d.meta ? d.meta.description : null);
|
|
175
|
+
|
|
176
|
+
// Load new CSS stylesheets if needed
|
|
177
|
+
if (d.css) loadNewCss(d.css);
|
|
108
178
|
|
|
109
179
|
scrollTo(0, 0);
|
|
110
180
|
dispatchEvent(new CustomEvent("nitron:navigate", { detail: { url: url } }));
|
|
111
181
|
navigating = false;
|
|
112
182
|
})
|
|
113
183
|
.catch(function() {
|
|
184
|
+
// RSC fetch failed or aborted — full page load as last resort
|
|
114
185
|
clearTimeout(timer);
|
|
115
186
|
fallback();
|
|
116
187
|
});
|
|
@@ -121,34 +192,32 @@
|
|
|
121
192
|
}
|
|
122
193
|
}
|
|
123
194
|
|
|
124
|
-
|
|
125
|
-
if (!a || !b || a.length !== b.length) return false;
|
|
126
|
-
for (var i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
|
|
127
|
-
return true;
|
|
128
|
-
}
|
|
195
|
+
// --- CSS & Meta Helpers ---
|
|
129
196
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
tmp.innerHTML = html;
|
|
138
|
-
var inner = tmp.querySelector("[data-nitron-slot='page']");
|
|
139
|
-
slot.innerHTML = inner ? inner.innerHTML : html;
|
|
140
|
-
}
|
|
197
|
+
// Loads CSS files that the new page needs but the current page doesn't have
|
|
198
|
+
function loadNewCss(cssFiles) {
|
|
199
|
+
var existing = new Set();
|
|
200
|
+
|
|
201
|
+
document.querySelectorAll('link[rel="stylesheet"]').forEach(function(link) {
|
|
202
|
+
existing.add(link.getAttribute("href"));
|
|
203
|
+
});
|
|
141
204
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
205
|
+
for (var i = 0; i < cssFiles.length; i++) {
|
|
206
|
+
if (!existing.has(cssFiles[i])) {
|
|
207
|
+
var link = document.createElement("link");
|
|
208
|
+
link.rel = "stylesheet";
|
|
209
|
+
link.href = cssFiles[i];
|
|
210
|
+
document.head.appendChild(link);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
147
213
|
}
|
|
148
214
|
|
|
215
|
+
// Creates or updates a <meta> tag (e.g. description)
|
|
149
216
|
function setMeta(name, val) {
|
|
150
217
|
if (!val) return;
|
|
218
|
+
|
|
151
219
|
var m = document.querySelector('meta[name="' + name + '"]');
|
|
220
|
+
|
|
152
221
|
if (m) m.content = val;
|
|
153
222
|
else {
|
|
154
223
|
m = document.createElement("meta");
|
|
@@ -158,6 +227,8 @@
|
|
|
158
227
|
}
|
|
159
228
|
}
|
|
160
229
|
|
|
230
|
+
// --- Initialize ---
|
|
231
|
+
|
|
161
232
|
document.readyState === "loading"
|
|
162
233
|
? document.addEventListener("DOMContentLoaded", init)
|
|
163
234
|
: init();
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { existsSync, readFileSync, statSync } from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import Paths from "../Core/Paths.js";
|
|
4
|
+
import Environment from "../Core/Environment.js";
|
|
5
|
+
|
|
6
|
+
const MANIFEST_PATH = path.join(Paths.build, "client-manifest.json");
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Maps "use client" components to their browser-side chunk locations.
|
|
10
|
+
* Build writes client-manifest.json, this class loads it at render time.
|
|
11
|
+
*
|
|
12
|
+
* The Flight renderer looks up client components in this manifest to know
|
|
13
|
+
* which JS file the browser should load for each client component reference.
|
|
14
|
+
*
|
|
15
|
+
* Format (what react-server-dom-webpack expects):
|
|
16
|
+
* {
|
|
17
|
+
* "file:///abs/path/to/SearchBar.tsx": {
|
|
18
|
+
* id: "SearchBar",
|
|
19
|
+
* chunks: ["SearchBar", "js/Components/SearchBar.js"],
|
|
20
|
+
* name: "*"
|
|
21
|
+
* }
|
|
22
|
+
* }
|
|
23
|
+
*/
|
|
24
|
+
class ClientManifest {
|
|
25
|
+
static #entries = null;
|
|
26
|
+
static #mtime = null;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Get the manifest object for FlightRenderer.
|
|
30
|
+
* Loads from client-manifest.json with mtime-based cache busting in dev.
|
|
31
|
+
*
|
|
32
|
+
* @returns {object}
|
|
33
|
+
*/
|
|
34
|
+
static get() {
|
|
35
|
+
if (Environment.isDev && this.#entries) {
|
|
36
|
+
try {
|
|
37
|
+
const mtime = statSync(MANIFEST_PATH).mtimeMs;
|
|
38
|
+
|
|
39
|
+
if (mtime !== this.#mtime) {
|
|
40
|
+
this.#entries = null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
catch {}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (!this.#entries) {
|
|
47
|
+
if (!existsSync(MANIFEST_PATH)) {
|
|
48
|
+
return {};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
this.#entries = JSON.parse(readFileSync(MANIFEST_PATH, "utf8"));
|
|
52
|
+
this.#mtime = statSync(MANIFEST_PATH).mtimeMs;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return this.#entries;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export default ClientManifest;
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { createRequire } from "module";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { PassThrough } from "stream";
|
|
4
|
+
import React from "react";
|
|
5
|
+
import Environment from "../Core/Environment.js";
|
|
6
|
+
|
|
7
|
+
const require = createRequire(import.meta.url);
|
|
8
|
+
|
|
9
|
+
// The Flight renderer expects React's server internals (H = hooks dispatcher,
|
|
10
|
+
// A = async dispatcher). Normal React only exports __CLIENT_INTERNALS_..., so we
|
|
11
|
+
// provide __SERVER_INTERNALS_... as a separate object. Both dispatchers are set by
|
|
12
|
+
// their respective renderers before rendering, so parallel execution is safe:
|
|
13
|
+
// - react-dom/server uses __CLIENT_INTERNALS_... → no conflict
|
|
14
|
+
// - Flight renderer uses __SERVER_INTERNALS_... → no conflict
|
|
15
|
+
if (!React.__SERVER_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE) {
|
|
16
|
+
React.__SERVER_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE = { H: null, A: null };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Load production Flight renderer via absolute path (bypasses package.json exports).
|
|
20
|
+
const serverNodePath = require.resolve("react-server-dom-webpack/server.node");
|
|
21
|
+
const prodPath = path.join(path.dirname(serverNodePath), "cjs/react-server-dom-webpack-server.node.production.js");
|
|
22
|
+
|
|
23
|
+
const { renderToPipeableStream } = require(prodPath);
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Produces RSC Flight payloads from React element trees.
|
|
27
|
+
* Flight payload = serialized React element tree that the browser React can reconcile.
|
|
28
|
+
*
|
|
29
|
+
* Server components become inline data, client components become import references.
|
|
30
|
+
* The browser React reads the payload and builds its virtual DOM from it,
|
|
31
|
+
* enabling surgical DOM updates via reconciliation.
|
|
32
|
+
*/
|
|
33
|
+
class FlightRenderer {
|
|
34
|
+
/**
|
|
35
|
+
* Render a React element tree to an RSC Flight payload string.
|
|
36
|
+
*
|
|
37
|
+
* @param {React.ReactElement} element - The full component tree (page + layouts)
|
|
38
|
+
* @param {object} clientManifest - Maps client component IDs to chunk info
|
|
39
|
+
* @param {object} [options] - Optional: onError callback
|
|
40
|
+
* @returns {Promise<string>} - The complete Flight payload
|
|
41
|
+
*/
|
|
42
|
+
static render(element, clientManifest, options = {}) {
|
|
43
|
+
return new Promise((resolve, reject) => {
|
|
44
|
+
const chunks = [];
|
|
45
|
+
const stream = new PassThrough();
|
|
46
|
+
let done = false;
|
|
47
|
+
let renderError = null;
|
|
48
|
+
|
|
49
|
+
const finish = (result) => {
|
|
50
|
+
if (done) return;
|
|
51
|
+
done = true;
|
|
52
|
+
clearTimeout(timer);
|
|
53
|
+
|
|
54
|
+
if (renderError) {
|
|
55
|
+
reject(renderError);
|
|
56
|
+
}
|
|
57
|
+
else {
|
|
58
|
+
resolve(result);
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const timeout = Environment.isDev ? 10000 : 3000;
|
|
63
|
+
const timer = setTimeout(() => {
|
|
64
|
+
const error = new Error("RSC render timeout - component took too long");
|
|
65
|
+
error.statusCode = 500;
|
|
66
|
+
renderError = error;
|
|
67
|
+
stream.destroy();
|
|
68
|
+
finish(null);
|
|
69
|
+
}, timeout);
|
|
70
|
+
|
|
71
|
+
stream.on("data", chunk => chunks.push(chunk));
|
|
72
|
+
stream.on("end", () => finish(Buffer.concat(chunks).toString("utf-8")));
|
|
73
|
+
stream.on("error", (error) => {
|
|
74
|
+
renderError = error;
|
|
75
|
+
finish(null);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
try {
|
|
79
|
+
const { pipe } = renderToPipeableStream(element, clientManifest, {
|
|
80
|
+
onError: (error) => {
|
|
81
|
+
if (options.onError) {
|
|
82
|
+
options.onError(error);
|
|
83
|
+
}
|
|
84
|
+
renderError = error;
|
|
85
|
+
finish(null);
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
pipe(stream);
|
|
90
|
+
}
|
|
91
|
+
catch (error) {
|
|
92
|
+
renderError = error;
|
|
93
|
+
finish(null);
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export default FlightRenderer;
|
package/lib/View/Layout.js
CHANGED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime prop filtering for Flight payload security.
|
|
3
|
+
* Strips unused data from props before Flight serialization
|
|
4
|
+
* based on build-time AST analysis of component prop usage.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** Props that always pass through without filtering. */
|
|
8
|
+
const BYPASS_PROPS = new Set(["children", "key", "ref"]);
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class PropFilter {
|
|
12
|
+
/**
|
|
13
|
+
* Filter props before Flight serialization.
|
|
14
|
+
*
|
|
15
|
+
* @param {object} params - Raw props from the controller.
|
|
16
|
+
* @param {object|null} propUsage - Usage tree from build manifest (null = passthrough).
|
|
17
|
+
* @returns {object} Filtered props safe for client delivery.
|
|
18
|
+
*/
|
|
19
|
+
static apply(params, propUsage) {
|
|
20
|
+
if (!params || typeof params !== "object") return params;
|
|
21
|
+
if (!propUsage || typeof propUsage !== "object") return params;
|
|
22
|
+
|
|
23
|
+
return pruneByUsage(params, propUsage);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Prunes an object to only include properties matching the usage tree.
|
|
30
|
+
*
|
|
31
|
+
* Usage tree format:
|
|
32
|
+
* - true → pass the value as-is
|
|
33
|
+
* - { sub: ... } → pass only matching sub-properties
|
|
34
|
+
* - absent key → strip entirely
|
|
35
|
+
*
|
|
36
|
+
* @param {*} data - Data to prune.
|
|
37
|
+
* @param {object|true} usage - Usage tree node.
|
|
38
|
+
* @returns {*} Pruned data.
|
|
39
|
+
*/
|
|
40
|
+
function pruneByUsage(data, usage) {
|
|
41
|
+
if (usage === true) return data;
|
|
42
|
+
|
|
43
|
+
if (data === null || data === undefined) return data;
|
|
44
|
+
if (typeof data !== "object") return data;
|
|
45
|
+
|
|
46
|
+
if (Array.isArray(data)) {
|
|
47
|
+
if (usage["[]"]) {
|
|
48
|
+
return data.map(item => pruneByUsage(item, usage["[]"]));
|
|
49
|
+
}
|
|
50
|
+
// No element iteration in usage — fall through to object pruning
|
|
51
|
+
// to preserve scalar properties (e.g. length) without leaking elements.
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const result = Object.create(null);
|
|
55
|
+
|
|
56
|
+
for (const key of Object.keys(usage)) {
|
|
57
|
+
if (key === "__proto__" || key === "constructor" || key === "prototype") continue;
|
|
58
|
+
if (!(key in data)) continue;
|
|
59
|
+
|
|
60
|
+
const subUsage = usage[key];
|
|
61
|
+
const value = data[key];
|
|
62
|
+
|
|
63
|
+
if (subUsage === true) {
|
|
64
|
+
result[key] = value;
|
|
65
|
+
}
|
|
66
|
+
else if (subUsage && typeof subUsage === "object") {
|
|
67
|
+
result[key] = pruneByUsage(value, subUsage);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Bypass props always pass through even if not in usage tree
|
|
72
|
+
for (const bp of BYPASS_PROPS) {
|
|
73
|
+
if (bp in data && !(bp in result)) {
|
|
74
|
+
result[bp] = data[bp];
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return result;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export default PropFilter;
|