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.
@@ -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.ReactElement<{
59
+ export declare const generateScriptElements: (jsUrls?: any, nonce?: string) => React.DetailedReactHTMLElement<{
56
60
  key: string;
57
61
  type: string;
58
62
  src: any;
59
- }, string | React.JSXElementConstructor<any>>[];
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 = []) => [...new Set(jsUrls)].map(url => {
152
- return `<script type="module" src="${url}"></script>`;
153
- }).join("");
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
@@ -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
- // import sanitizeHtml from "sanitize-html"
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
- const sanitizedPathname = pathname;
152
- const sanitizedParams = searchParamsString;
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
- expect(Object.keys(result)[0]).toBe("/page?a=1&b=2")
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&amp;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
- expect(Object.keys(result)[0]).toBe("/page?a=1&b=2")
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&amp;b=2")
93
95
  })
94
96
  })
95
97
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "catalyst-core",
3
- "version": "1.1.0",
3
+ "version": "1.2.0-canary.1",
4
4
  "type": "module",
5
5
  "main": "./dist/index.jsx",
6
6
  "description": "Web framework that provides great performance out of the box",
@@ -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
+ })
@@ -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
- "📡 Received SIGTERM, shutting down OpenTelemetry gracefully..."
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
- "📡 Received SIGINT, shutting down OpenTelemetry gracefully..."
362
+ expect.stringContaining("Received SIGINT, shutting down OpenTelemetry gracefully...")
362
363
  )
363
364
  await vi.waitFor(() => expect(exit).toHaveBeenCalledTimes(2))
364
365
  })