catalyst-core 1.1.0 → 1.2.0-canary.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/changelog.md +27 -1
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/CustomWebview.kt +72 -0
- package/dist/native/buildAndroid/assets.js +24 -1
- package/dist/native/buildAndroid/index.js +4 -1
- package/dist/native/buildIos/config.js +6 -0
- package/dist/offline/registerServiceWorker.js +2 -0
- package/dist/otel.js +503 -14
- package/dist/scripts/build.js +30 -22
- package/dist/server/renderer/document/Body.jsx +5 -4
- package/dist/server/renderer/document/Head.jsx +2 -1
- package/dist/server/renderer/extract.d.ts +11 -4
- package/dist/server/renderer/extract.js +21 -7
- package/dist/server/renderer/handler.jsx +32 -14
- package/dist/vite/FastRefresh.jsx +2 -1
- package/dist/vite/vite.config.js +33 -3
- package/dist/web-router/components/RouterDataProvider.jsx +5 -3
- package/dist/web-router/components/RouterDataProvider.server.test.jsx +16 -1
- package/dist/web-router/components/RouterDataProvider.test.jsx +3 -1
- package/package.json +1 -1
- package/test/native/locationPermissions.test.js +56 -0
- package/test/server/handler.assets.test.ts +77 -0
- package/test/server/otel.test.ts +3 -2
|
@@ -35,12 +35,14 @@ export declare const getDeferredPreloadScriptUrls: (routeKey: any, excludeUrls?:
|
|
|
35
35
|
* React <link rel="modulepreload"> elements (deduped). Use before matching <script type="module">.
|
|
36
36
|
* @param {string[]} jsUrls
|
|
37
37
|
* @param {string} [keyPrefix] - Unique prefix for React keys when rendering multiple lists.
|
|
38
|
+
* @param {string} [nonce] - CSP nonce, applied when nonce-based CSP is enabled (see CSP_NONCE_ENABLE).
|
|
38
39
|
*/
|
|
39
|
-
export declare const generateModulePreloadLinkElements: (jsUrls?: any, keyPrefix?: string) => React.DetailedReactHTMLElement<{
|
|
40
|
+
export declare const generateModulePreloadLinkElements: (jsUrls?: any, keyPrefix?: string, nonce?: string) => React.DetailedReactHTMLElement<{
|
|
40
41
|
key: string;
|
|
41
42
|
rel: string;
|
|
42
43
|
href: any;
|
|
43
44
|
fetchPriority: string;
|
|
45
|
+
nonce?: string;
|
|
44
46
|
}, HTMLElement>[];
|
|
45
47
|
/**
|
|
46
48
|
* Read CSS files from disk and return concatenated CSS string for inlining.
|
|
@@ -51,17 +53,22 @@ export declare const generateModulePreloadLinkElements: (jsUrls?: any, keyPrefix
|
|
|
51
53
|
export declare const readCssFromDisk: (cssPaths?: any, basePath?: string) => string;
|
|
52
54
|
/**
|
|
53
55
|
* <script type="module"> React elements for JS assets.
|
|
56
|
+
* @param {string[]} jsUrls
|
|
57
|
+
* @param {string} [nonce] - CSP nonce, applied when nonce-based CSP is enabled (see CSP_NONCE_ENABLE).
|
|
54
58
|
*/
|
|
55
|
-
export declare const generateScriptElements: (jsUrls?: any) => React.
|
|
59
|
+
export declare const generateScriptElements: (jsUrls?: any, nonce?: string) => React.DetailedReactHTMLElement<{
|
|
56
60
|
key: string;
|
|
57
61
|
type: string;
|
|
58
62
|
src: any;
|
|
59
|
-
|
|
63
|
+
nonce?: string;
|
|
64
|
+
}, HTMLElement>[];
|
|
60
65
|
/**
|
|
61
66
|
* <link rel="stylesheet"> HTML strings for deferred CSS (non-blocking, after body).
|
|
62
67
|
*/
|
|
63
68
|
export declare const generateCssLinkStrings: (cssUrls?: any) => string;
|
|
64
69
|
/**
|
|
65
70
|
* <link rel="modulepreload"> + <script type="module"> HTML strings.
|
|
71
|
+
* @param {string[]} jsUrls
|
|
72
|
+
* @param {string} [nonce] - CSP nonce, applied when nonce-based CSP is enabled (see CSP_NONCE_ENABLE).
|
|
66
73
|
*/
|
|
67
|
-
export declare const generateScriptStrings: (jsUrls?: any) => string;
|
|
74
|
+
export declare const generateScriptStrings: (jsUrls?: any, nonce?: string) => string;
|
|
@@ -88,12 +88,16 @@ export const getDeferredPreloadScriptUrls = (routeKey, excludeUrls = []) => {
|
|
|
88
88
|
* React <link rel="modulepreload"> elements (deduped). Use before matching <script type="module">.
|
|
89
89
|
* @param {string[]} jsUrls
|
|
90
90
|
* @param {string} [keyPrefix] - Unique prefix for React keys when rendering multiple lists.
|
|
91
|
+
* @param {string} [nonce] - CSP nonce, applied when nonce-based CSP is enabled (see CSP_NONCE_ENABLE).
|
|
91
92
|
*/
|
|
92
|
-
export const generateModulePreloadLinkElements = (jsUrls = [], keyPrefix = "modulepreload") => [...new Set(jsUrls)].map((url, i) => React.createElement("link", {
|
|
93
|
+
export const generateModulePreloadLinkElements = (jsUrls = [], keyPrefix = "modulepreload", nonce) => [...new Set(jsUrls)].map((url, i) => React.createElement("link", {
|
|
93
94
|
key: `${keyPrefix}-${i}`,
|
|
94
95
|
rel: "modulepreload",
|
|
95
96
|
href: url,
|
|
96
|
-
fetchPriority: "high"
|
|
97
|
+
fetchPriority: "high",
|
|
98
|
+
...(nonce ? {
|
|
99
|
+
nonce
|
|
100
|
+
} : {})
|
|
97
101
|
}));
|
|
98
102
|
|
|
99
103
|
/**
|
|
@@ -131,11 +135,16 @@ export const readCssFromDisk = (cssPaths = [], basePath) => {
|
|
|
131
135
|
|
|
132
136
|
/**
|
|
133
137
|
* <script type="module"> React elements for JS assets.
|
|
138
|
+
* @param {string[]} jsUrls
|
|
139
|
+
* @param {string} [nonce] - CSP nonce, applied when nonce-based CSP is enabled (see CSP_NONCE_ENABLE).
|
|
134
140
|
*/
|
|
135
|
-
export const generateScriptElements = (jsUrls = []) => [...new Set(jsUrls)].map((url, i) => React.createElement("script", {
|
|
141
|
+
export const generateScriptElements = (jsUrls = [], nonce) => [...new Set(jsUrls)].map((url, i) => React.createElement("script", {
|
|
136
142
|
key: `js-${i}`,
|
|
137
143
|
type: "module",
|
|
138
|
-
src: url
|
|
144
|
+
src: url,
|
|
145
|
+
...(nonce ? {
|
|
146
|
+
nonce
|
|
147
|
+
} : {})
|
|
139
148
|
}));
|
|
140
149
|
|
|
141
150
|
// ── HTML strings (for streaming injection after body via res.write) ────
|
|
@@ -147,7 +156,12 @@ export const generateCssLinkStrings = (cssUrls = []) => [...new Set(cssUrls)].ma
|
|
|
147
156
|
|
|
148
157
|
/**
|
|
149
158
|
* <link rel="modulepreload"> + <script type="module"> HTML strings.
|
|
159
|
+
* @param {string[]} jsUrls
|
|
160
|
+
* @param {string} [nonce] - CSP nonce, applied when nonce-based CSP is enabled (see CSP_NONCE_ENABLE).
|
|
150
161
|
*/
|
|
151
|
-
export const generateScriptStrings = (jsUrls = []
|
|
152
|
-
|
|
153
|
-
|
|
162
|
+
export const generateScriptStrings = (jsUrls = [], nonce) => {
|
|
163
|
+
const nonceAttr = nonce ? ` nonce="${nonce}"` : "";
|
|
164
|
+
return [...new Set(jsUrls)].map(url => {
|
|
165
|
+
return `<script type="module"${nonceAttr} src="${url}"></script>`;
|
|
166
|
+
}).join("");
|
|
167
|
+
};
|
|
@@ -14,6 +14,7 @@ import { validateConfigureStore, validateGetRoutes, validateCustomDocument, hand
|
|
|
14
14
|
import { ChunkExtractor } from "./ChunkExtractor.js";
|
|
15
15
|
import { readCssFromDisk, generateScriptElements, generateScriptStrings, getDeferredRouteKey, getCachedDeferredCssPathsForRoute, registerDeferredAssetsForRoute, getDeferredPreloadScriptUrls, generateModulePreloadLinkElements } from "./extract.js";
|
|
16
16
|
import path from "path";
|
|
17
|
+
import crypto from "node:crypto";
|
|
17
18
|
import { Transform } from "node:stream";
|
|
18
19
|
import CustomDocument from "@catalyst/template/server/document";
|
|
19
20
|
import App from "@catalyst/template/src/js/containers/App/index";
|
|
@@ -185,6 +186,13 @@ if (process.env.OTEL_ENABLE === true) {
|
|
|
185
186
|
}
|
|
186
187
|
}
|
|
187
188
|
const SSR_SERVICE = process.env.SERVICE_NAME || `pwa-${process.env.APPLICATION}-node-server`;
|
|
189
|
+
|
|
190
|
+
// Config-driven (config.json → CSP_NONCE_ENABLE): when on, every script Catalyst injects
|
|
191
|
+
// carries a per-request nonce so the app can serve a nonce-based CSP without opening up
|
|
192
|
+
// 'unsafe-inline'. Off by default — no behavior change unless explicitly enabled.
|
|
193
|
+
// Same boolean contract as OTEL_ENABLE above.
|
|
194
|
+
const CSP_NONCE_ENABLE = process.env.CSP_NONCE_ENABLE === true;
|
|
195
|
+
const generateNonce = () => crypto.randomBytes(16).toString("base64");
|
|
188
196
|
const traceHook = (fn, spanName) => typeof fn === "function" ? withSyncObservability(SSR_SERVICE, fn, spanName) : fn;
|
|
189
197
|
const onRouteMatch = traceHook(_onRouteMatch, "onRouteMatch");
|
|
190
198
|
const onFetcherSuccess = traceHook(_onFetcherSuccess, "onFetcherSuccess");
|
|
@@ -235,7 +243,7 @@ const getComponent = (store, context, req, fetcherData, isBot) => <div id="app">
|
|
|
235
243
|
</div>;
|
|
236
244
|
|
|
237
245
|
// ── Render and stream ──────────────────────────────────────────────────
|
|
238
|
-
const _renderMarkUp = async (errorCode, req, res, metaTags, fetcherData, store, allMatches, context, chunkExtractor) => {
|
|
246
|
+
const _renderMarkUp = async (errorCode, req, res, metaTags, fetcherData, store, allMatches, context, chunkExtractor, nonce) => {
|
|
239
247
|
const deviceDetails = getUserAgentDetails(req.headers["user-agent"] || "");
|
|
240
248
|
// Match mweb's wider definition: synthetic monitors (StatusCake) and AI crawlers
|
|
241
249
|
// also need the bot code path — no JS, and split(... ssr: false ...) widgets that
|
|
@@ -265,10 +273,10 @@ const _renderMarkUp = async (errorCode, req, res, metaTags, fetcherData, store,
|
|
|
265
273
|
const inlineCss = readCssFromDisk(criticalAssets.css, buildDir);
|
|
266
274
|
const deferredRouteKey = getDeferredRouteKey(req, allMatches);
|
|
267
275
|
const deferredRouteInlineCss = readCssFromDisk(getCachedDeferredCssPathsForRoute(deferredRouteKey), buildDir);
|
|
268
|
-
const jsScripts = generateScriptElements(criticalAssets.js);
|
|
269
|
-
const criticalPreloadLinks = generateModulePreloadLinkElements(criticalAssets.js, "critical-js");
|
|
276
|
+
const jsScripts = generateScriptElements(criticalAssets.js, nonce);
|
|
277
|
+
const criticalPreloadLinks = generateModulePreloadLinkElements(criticalAssets.js, "critical-js", nonce);
|
|
270
278
|
const deferredPreloadUrls = getDeferredPreloadScriptUrls(deferredRouteKey, criticalAssets.js);
|
|
271
|
-
const deferredPreloadLinks = generateModulePreloadLinkElements(deferredPreloadUrls, "deferred-js");
|
|
279
|
+
const deferredPreloadLinks = generateModulePreloadLinkElements(deferredPreloadUrls, "deferred-js", nonce);
|
|
272
280
|
|
|
273
281
|
// Build Head props
|
|
274
282
|
const shellStart = renderStart({
|
|
@@ -295,7 +303,8 @@ const _renderMarkUp = async (errorCode, req, res, metaTags, fetcherData, store,
|
|
|
295
303
|
req,
|
|
296
304
|
res,
|
|
297
305
|
safeArea,
|
|
298
|
-
statusCode: status
|
|
306
|
+
statusCode: status,
|
|
307
|
+
nonce
|
|
299
308
|
};
|
|
300
309
|
const CompleteDocument = () => {
|
|
301
310
|
// CustomDocument is an OPTIONAL export (server/document.js) — its
|
|
@@ -307,8 +316,8 @@ const _renderMarkUp = async (errorCode, req, res, metaTags, fetcherData, store,
|
|
|
307
316
|
if (customDocErr) handleError(customDocErr);else return CustomDocument(finalProps);
|
|
308
317
|
}
|
|
309
318
|
return <html lang={finalProps.lang}>
|
|
310
|
-
<Head isBot={finalProps.isBot} inlineCss={finalProps.inlineCss} deferredRouteInlineCss={finalProps.deferredRouteInlineCss} jsScripts={finalProps.jsScripts} criticalPreloadLinks={finalProps.criticalPreloadLinks} deferredPreloadLinks={finalProps.deferredPreloadLinks} fetcherData={finalProps.fetcherData} metaTags={finalProps.metaTags} publicAssetPath={finalProps.publicAssetPath} />
|
|
311
|
-
<Body initialState={finalProps.initialState} jsx={finalProps.jsx} statusCode={finalProps.statusCode} fetcherData={finalProps.fetcherData} safeArea={finalProps.safeArea} />
|
|
319
|
+
<Head isBot={finalProps.isBot} inlineCss={finalProps.inlineCss} deferredRouteInlineCss={finalProps.deferredRouteInlineCss} jsScripts={finalProps.jsScripts} criticalPreloadLinks={finalProps.criticalPreloadLinks} deferredPreloadLinks={finalProps.deferredPreloadLinks} fetcherData={finalProps.fetcherData} metaTags={finalProps.metaTags} publicAssetPath={finalProps.publicAssetPath} nonce={finalProps.nonce} />
|
|
320
|
+
<Body initialState={finalProps.initialState} jsx={finalProps.jsx} statusCode={finalProps.statusCode} fetcherData={finalProps.fetcherData} safeArea={finalProps.safeArea} nonce={finalProps.nonce} />
|
|
312
321
|
</html>;
|
|
313
322
|
};
|
|
314
323
|
try {
|
|
@@ -333,15 +342,16 @@ const _renderMarkUp = async (errorCode, req, res, metaTags, fetcherData, store,
|
|
|
333
342
|
js: [],
|
|
334
343
|
css: []
|
|
335
344
|
};
|
|
345
|
+
const nonceAttr = nonce ? ` nonce="${nonce}"` : "";
|
|
336
346
|
|
|
337
347
|
// Tell client which components were SSR'd so split() can
|
|
338
348
|
// eagerly import them (prevents Suspense fallback flash)
|
|
339
|
-
this.push(`<script>window.__CATALYST_IS_BOT__=${isBot ? "true" : "false"};</script>`);
|
|
349
|
+
this.push(`<script${nonceAttr}>window.__CATALYST_IS_BOT__=${isBot ? "true" : "false"};</script>`);
|
|
340
350
|
if (chunkExtractor) {
|
|
341
351
|
const renderedKeys = chunkExtractor.getRenderedComponentKeys();
|
|
342
352
|
this.push(
|
|
343
353
|
// nosemgrep: javascript.lang.security.audit.unknown-value-with-script-tag.unknown-value-with-script-tag - renderedKeys are internal bundler component-module keys tracked by ChunkExtractor, never request/user input, and are JSON.stringify-escaped before embedding.
|
|
344
|
-
`<script>window.__SSR_RENDERED_COMPONENTS__=new Set(${JSON.stringify(renderedKeys)})</script>`);
|
|
354
|
+
`<script${nonceAttr}>window.__SSR_RENDERED_COMPONENTS__=new Set(${JSON.stringify(renderedKeys)})</script>`);
|
|
345
355
|
}
|
|
346
356
|
const {
|
|
347
357
|
newCssPaths
|
|
@@ -350,7 +360,7 @@ const _renderMarkUp = async (errorCode, req, res, metaTags, fetcherData, store,
|
|
|
350
360
|
this.push(`<style>${readCssFromDisk(newCssPaths, buildDir)}</style>`);
|
|
351
361
|
}
|
|
352
362
|
if (!isBot) {
|
|
353
|
-
this.push(generateScriptStrings(deferredAssets.js));
|
|
363
|
+
this.push(generateScriptStrings(deferredAssets.js, nonce));
|
|
354
364
|
}
|
|
355
365
|
cb();
|
|
356
366
|
}
|
|
@@ -363,6 +373,8 @@ const _renderMarkUp = async (errorCode, req, res, metaTags, fetcherData, store,
|
|
|
363
373
|
const {
|
|
364
374
|
pipe
|
|
365
375
|
} = renderToPipeableStream(<CompleteDocument />, {
|
|
376
|
+
// Nonces React's own streamed inline scripts (Suspense boundary swaps)
|
|
377
|
+
nonce,
|
|
366
378
|
onShellReady() {
|
|
367
379
|
res.setHeader("content-type", "text/html");
|
|
368
380
|
pipe(tail);
|
|
@@ -430,6 +442,12 @@ async function _handler(req, res) {
|
|
|
430
442
|
return;
|
|
431
443
|
}
|
|
432
444
|
const store = await createStore({}, req, res);
|
|
445
|
+
|
|
446
|
+
// If app-level middleware already generated a nonce for its CSP header
|
|
447
|
+
// (res.locals.cspNonce), reuse it so the header and the script tags match.
|
|
448
|
+
// Otherwise generate one here and expose it the same way.
|
|
449
|
+
const nonce = CSP_NONCE_ENABLE ? res.locals.cspNonce || generateNonce() : undefined;
|
|
450
|
+
if (nonce) res.locals.cspNonce = nonce;
|
|
433
451
|
const cachedRoutes = getCachedRoutes();
|
|
434
452
|
const allMatches = cachedRoutes ? NestedMatchRoutes(cachedRoutes, req.originalUrl) || [] : [];
|
|
435
453
|
let allTags = [];
|
|
@@ -474,7 +492,7 @@ async function _handler(req, res) {
|
|
|
474
492
|
});
|
|
475
493
|
if (res.headersSent) return;
|
|
476
494
|
const statusCode = err.status_code || 404;
|
|
477
|
-
await tracedRenderMarkUp(statusCode, req, res, allTags, fetcherData, store, allMatches, context, chunkExtractor);
|
|
495
|
+
await tracedRenderMarkUp(statusCode, req, res, allTags, fetcherData, store, allMatches, context, chunkExtractor, nonce);
|
|
478
496
|
} else {
|
|
479
497
|
safeCall(onFetcherSuccess, {
|
|
480
498
|
req,
|
|
@@ -482,7 +500,7 @@ async function _handler(req, res) {
|
|
|
482
500
|
store
|
|
483
501
|
});
|
|
484
502
|
if (res.headersSent) return;
|
|
485
|
-
await tracedRenderMarkUp(null, req, res, allTags, fetcherData, store, allMatches, context, chunkExtractor);
|
|
503
|
+
await tracedRenderMarkUp(null, req, res, allTags, fetcherData, store, allMatches, context, chunkExtractor, nonce);
|
|
486
504
|
}
|
|
487
505
|
} catch (error) {
|
|
488
506
|
if (isRenderFailure(error)) throw error;
|
|
@@ -495,7 +513,7 @@ async function _handler(req, res) {
|
|
|
495
513
|
});
|
|
496
514
|
if (res.headersSent) return;
|
|
497
515
|
const chunkExtractor = collectAssets(req, allMatches);
|
|
498
|
-
await tracedRenderMarkUp(404, req, res, allTags, fetcherData, store, allMatches, context, chunkExtractor);
|
|
516
|
+
await tracedRenderMarkUp(404, req, res, allTags, fetcherData, store, allMatches, context, chunkExtractor, nonce);
|
|
499
517
|
}
|
|
500
518
|
} catch (error) {
|
|
501
519
|
if (isRenderFailure(error)) throw error;
|
|
@@ -508,7 +526,7 @@ async function _handler(req, res) {
|
|
|
508
526
|
});
|
|
509
527
|
if (res.headersSent) return;
|
|
510
528
|
const chunkExtractor = collectAssets(req, allMatches);
|
|
511
|
-
await tracedRenderMarkUp(error.status_code, req, res, allTags, fetcherData, store, allMatches, context, chunkExtractor);
|
|
529
|
+
await tracedRenderMarkUp(error.status_code, req, res, allTags, fetcherData, store, allMatches, context, chunkExtractor, nonce);
|
|
512
530
|
}
|
|
513
531
|
} catch (error) {
|
|
514
532
|
logSSRError("REQUEST_HANDLING", error, req);
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import React from "react"
|
|
2
2
|
|
|
3
|
-
const FastRefresh = () => {
|
|
3
|
+
const FastRefresh = ({ nonce }) => {
|
|
4
4
|
return (
|
|
5
5
|
<script
|
|
6
6
|
type="module"
|
|
7
|
+
nonce={nonce}
|
|
7
8
|
// eslint-disable-next-line risxss/catch-potential-xss-react
|
|
8
9
|
dangerouslySetInnerHTML={{
|
|
9
10
|
// Base-prefixed so the preamble resolves under a sub-path; otherwise
|
package/dist/vite/vite.config.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { defineConfig, transformWithEsbuild } from "vite"
|
|
1
|
+
import { defineConfig, transformWithEsbuild, defaultServerConditions } from "vite"
|
|
2
2
|
import react from "@vitejs/plugin-react"
|
|
3
3
|
import svgr from "vite-plugin-svgr"
|
|
4
4
|
import { builtinModules, createRequire } from "node:module"
|
|
@@ -192,18 +192,31 @@ const alias = () => {
|
|
|
192
192
|
}, {})
|
|
193
193
|
}
|
|
194
194
|
|
|
195
|
+
/**
|
|
196
|
+
* Route offline snapshots (catalyst-sw.js + offline manifest) are on unless
|
|
197
|
+
* config.json sets OFFLINE_SNAPSHOTS_ENABLE to false, e.g. for apps that ship
|
|
198
|
+
* their own service worker at the same scope.
|
|
199
|
+
*/
|
|
200
|
+
export const isOfflineSnapshotsEnabled = (value) => value !== false && value !== "false"
|
|
201
|
+
|
|
195
202
|
export const getClientEnvVariables = () => {
|
|
196
203
|
const clientEnvVars = process.env.CLIENT_ENV_VARIABLES
|
|
204
|
+
// Always defined so the service worker registration can read it in the browser.
|
|
205
|
+
const offlineSnapshotsDefinition = {
|
|
206
|
+
"process.env.OFFLINE_SNAPSHOTS_ENABLE": JSON.stringify(
|
|
207
|
+
isOfflineSnapshotsEnabled(process.env.OFFLINE_SNAPSHOTS_ENABLE)
|
|
208
|
+
),
|
|
209
|
+
}
|
|
197
210
|
|
|
198
211
|
if (!clientEnvVars) {
|
|
199
|
-
return
|
|
212
|
+
return offlineSnapshotsDefinition
|
|
200
213
|
}
|
|
201
214
|
|
|
202
215
|
// Parse CLIENT_ENV_VARIABLES if it's a JSON string
|
|
203
216
|
const allowedVars = typeof clientEnvVars === "string" ? JSON.parse(clientEnvVars) : clientEnvVars
|
|
204
217
|
|
|
205
218
|
// Create define object with only allowed environment variables
|
|
206
|
-
const envVarDefinitions = {}
|
|
219
|
+
const envVarDefinitions = { ...offlineSnapshotsDefinition }
|
|
207
220
|
|
|
208
221
|
allowedVars.forEach((varName) => {
|
|
209
222
|
if (process.env[varName] !== undefined) {
|
|
@@ -334,6 +347,7 @@ const candidateBrowserDeps = [
|
|
|
334
347
|
"react-detect-offline",
|
|
335
348
|
"react-async-script",
|
|
336
349
|
"normalize.css",
|
|
350
|
+
"sanitize-html",
|
|
337
351
|
]
|
|
338
352
|
|
|
339
353
|
// Node-only / instrumentation dependencies that must never be bundled into the
|
|
@@ -387,6 +401,22 @@ export const sharedViteConfig = {
|
|
|
387
401
|
ssr: {
|
|
388
402
|
// Keep selected React-side packages bundled for SSR (transformed by Vite, NOT pre-bundled)
|
|
389
403
|
// noExternal: ["@tata1mg/slowboi-react"],
|
|
404
|
+
resolve: {
|
|
405
|
+
// react-router/react-router-dom v7 ship dual CJS/ESM builds selected via the
|
|
406
|
+
// "module-sync" exports condition. Node 20.19+/22 understands this condition
|
|
407
|
+
// and resolves to the real ESM build even via require() — but Vite's SSR
|
|
408
|
+
// resolver does not know "module-sync" and falls back to the CJS build,
|
|
409
|
+
// including for packages Vite treats as external and hands off to Node's own
|
|
410
|
+
// loader (e.g. a consumer dependency that itself imports react-router-dom).
|
|
411
|
+
// When Vite and Node disagree on which build to load, the app ends up with
|
|
412
|
+
// two separate react-router module instances — each with its own React
|
|
413
|
+
// Context — so hooks like useLocation() throw "may be used only in the
|
|
414
|
+
// context of a <Router>" even though a Router is mounted, because the
|
|
415
|
+
// Provider populated one Context instance and the consumer read the other.
|
|
416
|
+
// Adding "module-sync" here makes Vite's resolution agree with Node's.
|
|
417
|
+
conditions: ["module-sync", ...defaultServerConditions],
|
|
418
|
+
externalConditions: ["module-sync", ...defaultServerConditions],
|
|
419
|
+
},
|
|
390
420
|
// Ensure Node-only instrumentation and low-level Node deps stay external so
|
|
391
421
|
// the bundler never tries to resolve the optional (opt-in) OTEL packages.
|
|
392
422
|
external: nodeOnlyExternalDeps,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import React, { useEffect, useState, createContext, useContext, useMemo, useRef } from "react";
|
|
2
2
|
import { useLocation, useMatch, useNavigate, useParams, useSearchParams, UNSAFE_RouteContext, matchRoutes } from "react-router";
|
|
3
3
|
import { OneMgRouterContext } from "../context.jsx";
|
|
4
|
-
|
|
4
|
+
import sanitizeHtml from "sanitize-html";
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* Data a route fetcher produced, as exposed by useCurrentRouteData / useRouterData.
|
|
@@ -148,8 +148,10 @@ const generateRouteKey = (match, searchParamsString = "") => {
|
|
|
148
148
|
pathname,
|
|
149
149
|
route
|
|
150
150
|
} = match;
|
|
151
|
-
|
|
152
|
-
|
|
151
|
+
// The key ends up JSON-serialized into an inline SSR <script> (fetcherData),
|
|
152
|
+
// so strip markup from the request-controlled pathname and query string.
|
|
153
|
+
const sanitizedPathname = sanitizeHtml(pathname);
|
|
154
|
+
const sanitizedParams = sanitizeHtml(searchParamsString);
|
|
153
155
|
if (route.children) {
|
|
154
156
|
return `index${sanitizedPathname}${sanitizedParams}`;
|
|
155
157
|
}
|
|
@@ -93,6 +93,21 @@ describe("serverDataFetcher (node environment — serverFetcher branch)", () =>
|
|
|
93
93
|
const req = { query: { a: "1", b: "2" } }
|
|
94
94
|
|
|
95
95
|
const result = await serverDataFetcher({ routes, url: "/page", req }, {})
|
|
96
|
-
|
|
96
|
+
// sanitizeHtml entity-encodes "&"; the client key goes through the same
|
|
97
|
+
// function, so server and client keys still match.
|
|
98
|
+
expect(Object.keys(result)[0]).toBe("/page?a=1&b=2")
|
|
99
|
+
})
|
|
100
|
+
it("strips markup from a request-controlled query value before it becomes a route key", async () => {
|
|
101
|
+
// The key is serialized into the inline SSR <script> as part of
|
|
102
|
+
// window.__ROUTER_INITIAL_DATA__ (#471).
|
|
103
|
+
const routes = [{ path: "/page", component: loadableComponent({ default: {} }) }]
|
|
104
|
+
const req = { query: { lang: '"></script><script>alert(document.domain)</script>' } }
|
|
105
|
+
|
|
106
|
+
const result = await serverDataFetcher({ routes, url: "/page", req }, {})
|
|
107
|
+
const key = Object.keys(result)[0]
|
|
108
|
+
|
|
109
|
+
expect(key.startsWith("/page?lang=")).toBe(true)
|
|
110
|
+
expect(key).not.toContain("<script")
|
|
111
|
+
expect(key).not.toContain("</script")
|
|
97
112
|
})
|
|
98
113
|
})
|
|
@@ -89,7 +89,9 @@ describe("serverDataFetcher", () => {
|
|
|
89
89
|
const routes = [{ path: "/page", component: { default: {} } }]
|
|
90
90
|
const req = { query: { a: "1", b: "2" } }
|
|
91
91
|
const result = await serverDataFetcher({ routes, url: "/page", req }, {})
|
|
92
|
-
|
|
92
|
+
// sanitizeHtml entity-encodes "&"; the client key goes through the same
|
|
93
|
+
// function, so server and client keys still match.
|
|
94
|
+
expect(Object.keys(result)[0]).toBe("/page?a=1&b=2")
|
|
93
95
|
})
|
|
94
96
|
})
|
|
95
97
|
|
package/package.json
CHANGED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import test from "node:test"
|
|
2
|
+
import assert from "node:assert/strict"
|
|
3
|
+
import fs from "fs"
|
|
4
|
+
import os from "os"
|
|
5
|
+
import path from "path"
|
|
6
|
+
import { createRequire } from "module"
|
|
7
|
+
import { fileURLToPath } from "url"
|
|
8
|
+
|
|
9
|
+
// src/native is a CommonJS subtree (see its package.json); load it with require from this ESM test.
|
|
10
|
+
const require = createRequire(import.meta.url)
|
|
11
|
+
const createAssetsPhase = require("../../src/native/buildAndroid/assets.js")
|
|
12
|
+
|
|
13
|
+
const here = path.dirname(fileURLToPath(import.meta.url))
|
|
14
|
+
const templateManifest = path.resolve(
|
|
15
|
+
here,
|
|
16
|
+
"../../src/native/androidProject/app/src/main/AndroidManifest.xml"
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
const LOCATION_LINES =
|
|
20
|
+
/<uses-permission android:name="android\.permission\.ACCESS_(FINE|COARSE)_LOCATION" \/>/g
|
|
21
|
+
|
|
22
|
+
function fakeAndroidProject() {
|
|
23
|
+
const pwd = fs.mkdtempSync(path.join(os.tmpdir(), "catalyst-location-"))
|
|
24
|
+
const manifestDir = path.join(pwd, "androidProject", "app", "src", "main")
|
|
25
|
+
fs.mkdirSync(manifestDir, { recursive: true })
|
|
26
|
+
const manifestPath = path.join(manifestDir, "AndroidManifest.xml")
|
|
27
|
+
fs.copyFileSync(templateManifest, manifestPath)
|
|
28
|
+
const { processLocation } = createAssetsPhase({ pwd, progress: { log: () => {} } })
|
|
29
|
+
const readManifest = () => fs.readFileSync(manifestPath, "utf8")
|
|
30
|
+
return { processLocation, readManifest }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
test("the template manifest declares no location permissions", () => {
|
|
34
|
+
assert.equal((fs.readFileSync(templateManifest, "utf8").match(LOCATION_LINES) || []).length, 0)
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
test("location.enabled adds fine and coarse location permissions once, across repeated builds", async () => {
|
|
38
|
+
const { processLocation, readManifest } = fakeAndroidProject()
|
|
39
|
+
|
|
40
|
+
await processLocation({ location: { enabled: true } })
|
|
41
|
+
await processLocation({ location: { enabled: true } })
|
|
42
|
+
|
|
43
|
+
const manifest = readManifest()
|
|
44
|
+
assert.equal((manifest.match(LOCATION_LINES) || []).length, 2)
|
|
45
|
+
// Inserted with the other permissions, ahead of <uses-feature>
|
|
46
|
+
assert.ok(manifest.indexOf("ACCESS_FINE_LOCATION") < manifest.indexOf("<uses-feature"))
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
test("location off (or missing) removes permissions a previous build added", async () => {
|
|
50
|
+
const { processLocation, readManifest } = fakeAndroidProject()
|
|
51
|
+
|
|
52
|
+
await processLocation({ location: { enabled: true } })
|
|
53
|
+
await processLocation({})
|
|
54
|
+
|
|
55
|
+
assert.equal((readManifest().match(LOCATION_LINES) || []).length, 0)
|
|
56
|
+
})
|
|
@@ -328,3 +328,80 @@ describe("SSR handler — deferred assets discovered during render", () => {
|
|
|
328
328
|
expect(html).not.toContain("widget.def456.js")
|
|
329
329
|
})
|
|
330
330
|
})
|
|
331
|
+
|
|
332
|
+
describe("SSR handler — CSP nonce (CSP_NONCE_ENABLE)", () => {
|
|
333
|
+
let realEnv: NodeJS.ProcessEnv
|
|
334
|
+
|
|
335
|
+
beforeEach(() => {
|
|
336
|
+
realEnv = process.env
|
|
337
|
+
})
|
|
338
|
+
|
|
339
|
+
afterEach(() => {
|
|
340
|
+
process.env = realEnv
|
|
341
|
+
})
|
|
342
|
+
|
|
343
|
+
async function renderWithNonce(url: string, presetNonce?: string) {
|
|
344
|
+
// config.json booleans reach process.env as real booleans because
|
|
345
|
+
// loadEnvironmentVariables reassigns process.env with a plain object
|
|
346
|
+
// (assigning through the real process.env would coerce to "true").
|
|
347
|
+
// The handler reads the flag at import time.
|
|
348
|
+
process.env = { ...process.env, CSP_NONCE_ENABLE: true as any }
|
|
349
|
+
vi.doMock(TEMPLATE_DOC, () => ({ default: undefined }))
|
|
350
|
+
mockManifest()
|
|
351
|
+
|
|
352
|
+
const { default: handler } = await import("../../src/server/renderer/handler.jsx")
|
|
353
|
+
const { req, res } = makeReqRes(url)
|
|
354
|
+
res.locals = presetNonce ? { cspNonce: presetNonce } : {}
|
|
355
|
+
|
|
356
|
+
await handler(req, res)
|
|
357
|
+
await res.waitForEnd()
|
|
358
|
+
return { html: res.getHtml() as string, res }
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
it("stamps one per-request nonce on every script and modulepreload in the document, but not on <style>", async () => {
|
|
362
|
+
const { html, res } = await renderWithNonce("/")
|
|
363
|
+
|
|
364
|
+
const nonce = res.locals.cspNonce
|
|
365
|
+
expect(nonce).toMatch(/^[A-Za-z0-9+/]+=*$/)
|
|
366
|
+
|
|
367
|
+
const scripts = html.match(/<script\b[^>]*>/g) || []
|
|
368
|
+
expect(scripts.length).toBeGreaterThan(0)
|
|
369
|
+
for (const tag of scripts) expect(tag).toContain(`nonce="${nonce}"`)
|
|
370
|
+
|
|
371
|
+
const preloads = html.match(/<link rel="modulepreload"[^>]*>/g) || []
|
|
372
|
+
expect(preloads.length).toBeGreaterThan(0)
|
|
373
|
+
for (const tag of preloads) expect(tag).toContain(`nonce="${nonce}"`)
|
|
374
|
+
|
|
375
|
+
expect(html).toContain("<style>.home{color:red}</style>")
|
|
376
|
+
expect(html).not.toMatch(/<style[^>]*nonce=/)
|
|
377
|
+
})
|
|
378
|
+
|
|
379
|
+
it("reuses res.locals.cspNonce set by app middleware, including on the scripts flushed after the document", async () => {
|
|
380
|
+
const { html, res } = await renderWithNonce("/widget", "app-nonce-123")
|
|
381
|
+
|
|
382
|
+
expect(res.locals.cspNonce).toBe("app-nonce-123")
|
|
383
|
+
const htmlEnd = html.indexOf("</html>")
|
|
384
|
+
const tail = html.slice(htmlEnd)
|
|
385
|
+
expect(tail).toContain('<script nonce="app-nonce-123">window.__CATALYST_IS_BOT__=false;</script>')
|
|
386
|
+
expect(tail).toContain('<script nonce="app-nonce-123">window.__SSR_RENDERED_COMPONENTS__=')
|
|
387
|
+
expect(tail).toContain(
|
|
388
|
+
'<script type="module" nonce="app-nonce-123" src="http://localhost/assets/widget.def456.js"></script>',
|
|
389
|
+
)
|
|
390
|
+
expect(tail).toContain("<style>.widget{color:blue}</style>")
|
|
391
|
+
})
|
|
392
|
+
|
|
393
|
+
it("renders no nonce attributes when the flag is off", async () => {
|
|
394
|
+
vi.doMock(TEMPLATE_DOC, () => ({ default: undefined }))
|
|
395
|
+
mockManifest()
|
|
396
|
+
|
|
397
|
+
const { default: handler } = await import("../../src/server/renderer/handler.jsx")
|
|
398
|
+
const { req, res } = makeReqRes("/widget")
|
|
399
|
+
res.locals = {}
|
|
400
|
+
|
|
401
|
+
await handler(req, res)
|
|
402
|
+
await res.waitForEnd()
|
|
403
|
+
|
|
404
|
+
expect(res.getHtml()).not.toContain("nonce=")
|
|
405
|
+
expect(res.locals.cspNonce).toBeUndefined()
|
|
406
|
+
})
|
|
407
|
+
})
|
package/test/server/otel.test.ts
CHANGED
|
@@ -120,6 +120,7 @@ vi.mock("@opentelemetry/sdk-trace-node", () => ({
|
|
|
120
120
|
TraceIdRatioBasedSampler: fake.record("TraceIdRatioBasedSampler"),
|
|
121
121
|
ParentBasedSampler: fake.record("ParentBasedSampler"),
|
|
122
122
|
}))
|
|
123
|
+
vi.mock("@opentelemetry/core", () => ({ setGlobalErrorHandler: vi.fn() }))
|
|
123
124
|
vi.mock("@opentelemetry/sdk-metrics", () => ({
|
|
124
125
|
PeriodicExportingMetricReader: fake.record("PeriodicExportingMetricReader"),
|
|
125
126
|
}))
|
|
@@ -350,7 +351,7 @@ describe("otel init()", () => {
|
|
|
350
351
|
|
|
351
352
|
addedListener("SIGTERM")()
|
|
352
353
|
expect(console.info).toHaveBeenCalledWith(
|
|
353
|
-
"
|
|
354
|
+
expect.stringContaining("Received SIGTERM, shutting down OpenTelemetry gracefully...")
|
|
354
355
|
)
|
|
355
356
|
await vi.waitFor(() => expect(exit).toHaveBeenCalledTimes(1))
|
|
356
357
|
expect(sdk.shutdown).toHaveBeenCalledTimes(1)
|
|
@@ -358,7 +359,7 @@ describe("otel init()", () => {
|
|
|
358
359
|
|
|
359
360
|
addedListener("SIGINT")()
|
|
360
361
|
expect(console.info).toHaveBeenCalledWith(
|
|
361
|
-
"
|
|
362
|
+
expect.stringContaining("Received SIGINT, shutting down OpenTelemetry gracefully...")
|
|
362
363
|
)
|
|
363
364
|
await vi.waitFor(() => expect(exit).toHaveBeenCalledTimes(2))
|
|
364
365
|
})
|