catalyst-core 0.3.0-beta.2 → 0.3.0-beta.4

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.
@@ -209,13 +209,15 @@ async function checkEmulator(ADB_PATH) {
209
209
  }
210
210
  async function startEmulator(EMULATOR_PATH, androidConfig) {
211
211
  progress.log(`Starting emulator: ${androidConfig.emulatorName}...`);
212
- // nosemgrep: javascript.lang.security.detect-child-process.detect-child-process - EMULATOR_PATH is derived from SDK configuration and emulatorName is local native setup config.
213
- // eslint-disable-next-line security/detect-child-process
214
- (0, _child_process.exec)(`${EMULATOR_PATH} -avd ${androidConfig.emulatorName} -read-only > /dev/null &`, error => {
215
- if (error) {
216
- progress.log(`Error starting emulator: ${error}`, "error");
217
- }
212
+ // nosemgrep: javascript.lang.security.detect-child-process.detect-child-process - spawn with an argv array (no shell), so androidConfig.emulatorName can't be interpreted as shell syntax even though it comes from a locally-editable config file.
213
+ const child = (0, _child_process.spawn)(EMULATOR_PATH, ["-avd", androidConfig.emulatorName, "-read-only"], {
214
+ detached: true,
215
+ stdio: "ignore"
216
+ });
217
+ child.on("error", error => {
218
+ progress.log(`Error starting emulator: ${error}`, "error");
218
219
  });
220
+ child.unref();
219
221
  }
220
222
  async function updateLocalProperties(sdkPath) {
221
223
  progress.start("properties");
@@ -99,6 +99,7 @@ function isOfflineRoute(url, manifest) {
99
99
  const { pathname } = new URL(url)
100
100
  return (manifest.routes || []).some((route) => {
101
101
  try {
102
+ // nosemgrep: javascript.lang.security.audit.detect-non-literal-regexp.detect-non-literal-regexp - route.regex is generated at build time by generateOfflineManifest.js's routeRegex(), which escapes every regex metacharacter in literal path segments and only ever emits flat (non-nested, non-repeating) patterns for :param and * segments — it cannot produce a catastrophic-backtracking shape. The manifest itself is same-origin build output, not user-submitted at runtime.
102
103
  return new RegExp(route.regex).test(pathname)
103
104
  } catch (_) {
104
105
  return false
@@ -4,6 +4,7 @@ import { arrayToObject } from "./scriptUtils.js"
4
4
  import { fileURLToPath } from "url"
5
5
  import { dirname } from "path"
6
6
  import { readFileSync, existsSync, rmSync } from "fs"
7
+ import { createRequire } from "module"
7
8
 
8
9
  const __filename = fileURLToPath(import.meta.url)
9
10
  const __dirname = dirname(__filename)
@@ -11,16 +12,19 @@ const loaderPath = path.resolve(__dirname, "../../dist/vite/node-loader.mjs")
11
12
  const configPath = path.join(process.env.PWD, "config/config.json")
12
13
  const configJSON = JSON.parse(readFileSync(configPath), "utf-8")
13
14
 
15
+ // Resolve vite's actual JS entry point rather than spawning the "vite" command name,
16
+ // which on Windows only resolves via the npm-installed .cmd shim (i.e. requires a shell).
17
+ // Invoking process.execPath + this path directly works cross-platform with no shell.
18
+ const require = createRequire(import.meta.url)
19
+ const viteBinPath = path.join(path.dirname(require.resolve("vite/package.json")), "bin", "vite.js")
20
+
14
21
  /**
15
- * @param {string} command
22
+ * @param {string[]} args
16
23
  * @param {import('child_process').SpawnOptions} options
17
24
  */
18
- function runBuildStep(command, options) {
25
+ function runBuildStep(args, options) {
19
26
  return new Promise((resolve, reject) => {
20
- const child = spawn(command, [], {
21
- ...options,
22
- shell: true,
23
- })
27
+ const child = spawn(process.execPath, args, options)
24
28
  child.on("close", (code) => {
25
29
  if (code === 0) {
26
30
  resolve()
@@ -69,8 +73,8 @@ async function build() {
69
73
  ]),
70
74
  }
71
75
 
72
- const serverBuildCommand = `vite build --config ./dist/vite/vite.config.server.js --ssr`
73
- const clientBuildCommand = `vite build --config ./dist/vite/vite.config.client.js`
76
+ const serverBuildArgs = [viteBinPath, "build", "--config", "./dist/vite/vite.config.server.js", "--ssr"]
77
+ const clientBuildArgs = [viteBinPath, "build", "--config", "./dist/vite/vite.config.client.js"]
74
78
  const spawnBase = {
75
79
  cwd: dirname,
76
80
  stdio: "inherit",
@@ -80,11 +84,11 @@ async function build() {
80
84
 
81
85
  try {
82
86
  await Promise.all([
83
- runBuildStep(serverBuildCommand, {
87
+ runBuildStep(serverBuildArgs, {
84
88
  ...spawnBase,
85
89
  env: { ...baseEnv, CATALYST_VITE_CACHE_ID: "ssr" },
86
90
  }),
87
- runBuildStep(clientBuildCommand, {
91
+ runBuildStep(clientBuildArgs, {
88
92
  ...spawnBase,
89
93
  env: { ...baseEnv, CATALYST_VITE_CACHE_ID: "client" },
90
94
  }),
@@ -96,7 +100,7 @@ async function build() {
96
100
 
97
101
  console.log("✅ Server and client builds completed!")
98
102
 
99
- await runBuildStep("node ./dist/scripts/generateOfflineManifest.js", {
103
+ await runBuildStep(["./dist/scripts/generateOfflineManifest.js"], {
100
104
  ...spawnBase,
101
105
  env: baseEnv,
102
106
  })
@@ -68,6 +68,7 @@ async function loadAppRoutes(appRoot) {
68
68
  })
69
69
 
70
70
  try {
71
+ // nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal - appRoot is the app's own build root and the remainder is a hardcoded literal path, not request input.
71
72
  const routesModule = await vite.ssrLoadModule(path.join(appRoot, "src/js/routes/utils.js"))
72
73
  return typeof routesModule.getRoutes === "function" ? routesModule.getRoutes() : []
73
74
  } finally {
@@ -23,6 +23,7 @@ export const printBundleInformation = () => {
23
23
  const files = fs.readdirSync(directoryPath)
24
24
  files.forEach((file) => {
25
25
  if (!file.includes("txt") && !file.includes("json")) {
26
+ // nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal - file comes from fs.readdirSync(directoryPath), i.e. actual filenames already on disk in the build output, not request input.
26
27
  const filePath = path.join(directoryPath, file)
27
28
  const fileSize = getFileSizeSync(filePath)
28
29
  if (fileSize !== null) {
@@ -24,28 +24,30 @@ function startProd() {
24
24
 
25
25
  console.log("🚀 Starting production server...")
26
26
 
27
- const inspectFlag = commandLineArguments.includes("--inspect") ? "--inspect" : ""
28
- const command = `node ${inspectFlag} --import ${preInitPath} --loader ${loaderPath} ./dist/server/expressServer.js`
29
- spawnSync(command, [], {
30
- cwd: dirname,
31
- stdio: "inherit",
32
- shell: true,
33
- env: {
34
- ...process.env,
35
- src_path: process.env.PWD,
36
- NODE_ENV: "production",
37
- IS_DEV_COMMAND: false,
38
- APPLICATION: name || "catalyst_app",
39
- ...argumentsObject,
40
- filterKeys: JSON.stringify([
41
- "src_path",
42
- "NODE_ENV",
43
- "IS_DEV_COMMAND",
44
- "APPLICATION",
45
- ...Object.keys(argumentsObject),
46
- ]),
47
- },
48
- })
27
+ const inspectArgs = commandLineArguments.includes("--inspect") ? ["--inspect"] : []
28
+ spawnSync(
29
+ "node",
30
+ [...inspectArgs, "--import", preInitPath, "--loader", loaderPath, "./dist/server/expressServer.js"],
31
+ {
32
+ cwd: dirname,
33
+ stdio: "inherit",
34
+ env: {
35
+ ...process.env,
36
+ src_path: process.env.PWD,
37
+ NODE_ENV: "production",
38
+ IS_DEV_COMMAND: false,
39
+ APPLICATION: name || "catalyst_app",
40
+ ...argumentsObject,
41
+ filterKeys: JSON.stringify([
42
+ "src_path",
43
+ "NODE_ENV",
44
+ "IS_DEV_COMMAND",
45
+ "APPLICATION",
46
+ ...Object.keys(argumentsObject),
47
+ ]),
48
+ },
49
+ }
50
+ )
49
51
  }
50
52
 
51
53
  startProd()
@@ -22,11 +22,9 @@ function start() {
22
22
  const packageJson = JSON.parse(readFileSync(path.join(process.env.PWD, "package.json"), "utf-8"))
23
23
  const { name } = packageJson
24
24
 
25
- const command = `node --import ${preInitPath} --loader ${loaderPath} ./dist/server/expressServer.js`
26
- spawnSync(command, [], {
25
+ spawnSync("node", ["--import", preInitPath, "--loader", loaderPath, "./dist/server/expressServer.js"], {
27
26
  cwd: dirname,
28
27
  stdio: "inherit",
29
- shell: true,
30
28
  env: {
31
29
  ...process.env,
32
30
  src_path: process.env.PWD,
@@ -10,6 +10,7 @@ import pc from "picocolors"
10
10
  import fs from "fs"
11
11
  const { cyan, yellow, green } = pc
12
12
 
13
+ import { toMountPathPrefix } from "../vite/resolveDevServerConfig.js"
13
14
  import { validateMiddleware, safeCall } from "./utils/validator.js"
14
15
  import { botDetectionMiddleware } from "./utils/botDetectionMiddleware.js"
15
16
  import { cjsRequire } from "./utils/cjsRequire.js"
@@ -126,6 +127,7 @@ const isProduction = process.env.NODE_ENV === "production"
126
127
 
127
128
  function serveBuildFile(app, buildPath, urlPath, fileName, headers = {}) {
128
129
  app.get(urlPath, (_req, res, next) => {
130
+ // nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal - fileName is always a hardcoded literal passed at each serveBuildFile call site, never derived from the request.
129
131
  const filePath = path.join(buildPath, fileName)
130
132
  if (!fs.existsSync(filePath)) return next()
131
133
  res.set(headers)
@@ -207,6 +209,12 @@ async function createServer() {
207
209
  appType: "custom",
208
210
  root: process.env.src_path,
209
211
  })
212
+
213
+ // Expose Vite's resolved base to the SSR renderer so the URLs it injects
214
+ // (client entry, react-refresh preamble) match the path Vite serves under.
215
+ // Vite normalizes base with a trailing slash; trim it for clean URL joins.
216
+ process.env.APP_MOUNT_PATH = toMountPathPrefix(vite.config.base)
217
+
210
218
  app.use(vite.middlewares)
211
219
  }
212
220
 
@@ -30,7 +30,10 @@ export function Body(props) {
30
30
  __html: `window.__SAFE_AREA_INITIAL__ = ${JSON.stringify(safeArea)}; window.__CATALYST_NATIVE_WEBVIEW__ = ${nativeWebView ? "true" : "false"}`,
31
31
  }}
32
32
  />
33
- {process.env.NODE_ENV === "development" && <script type="module" src="/client/index.js"></script>}
33
+ {process.env.NODE_ENV === "development" && (
34
+ // Base pinned to Vite's resolved base by server/expressServer.js.
35
+ <script type="module" src={`${process.env.APP_MOUNT_PATH || ""}/client/index.js`}></script>
36
+ )}
34
37
  {jsx}
35
38
  <script
36
39
  /* eslint-disable */
@@ -34,9 +34,11 @@ export function Head(props) {
34
34
  {metaTags && metaTags}
35
35
 
36
36
  {/* Inline critical CSS — prevents FOUC/CLS */}
37
+ {/* nosemgrep: typescript.react.security.audit.react-dangerouslysetinnerhtml.react-dangerouslysetinnerhtml - inlineCss is compiled CSS read from disk (readCssFromDisk), sourced from the app's own build output, never from request/user input. */}
37
38
  {inlineCss && <style dangerouslySetInnerHTML={{ __html: inlineCss }} />}
38
39
 
39
40
  {/* Deferred CSS from prior SSRs on this route — avoids late layout from post-body <style> */}
41
+ {/* nosemgrep: typescript.react.security.audit.react-dangerouslysetinnerhtml.react-dangerouslysetinnerhtml - deferredRouteInlineCss is compiled CSS read from disk (readCssFromDisk), sourced from the app's own build output, never from request/user input. */}
40
42
  {deferredRouteInlineCss && <style dangerouslySetInnerHTML={{ __html: deferredRouteInlineCss }} />}
41
43
 
42
44
  {/* JS modules */}
@@ -103,6 +103,7 @@ export const readCssFromDisk = (cssPaths = [], basePath) => {
103
103
  seen.add(asset)
104
104
  if (asset.startsWith("http")) continue
105
105
 
106
+ // nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal - asset paths come from the Vite build manifest generated at build time, never from a request, so there is no user-controlled input here.
106
107
  const filePath = path.isAbsolute(asset) ? asset : path.join(basePath, asset.replace(/^\/+/, ""))
107
108
 
108
109
  try {
@@ -265,6 +265,7 @@ const _renderMarkUp = async (
265
265
  if (chunkExtractor) {
266
266
  const renderedKeys = chunkExtractor.getRenderedComponentKeys()
267
267
  this.push(
268
+ // 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.
268
269
  `<script>window.__SSR_RENDERED_COMPONENTS__=new Set(${JSON.stringify(renderedKeys)})</script>`
269
270
  )
270
271
  }
@@ -6,8 +6,10 @@ const FastRefresh = () => {
6
6
  type="module"
7
7
  // eslint-disable-next-line risxss/catch-potential-xss-react
8
8
  dangerouslySetInnerHTML={{
9
+ // Base-prefixed so the preamble resolves under a sub-path; otherwise
10
+ // plugin-react modules throw "$RefreshSig$ is not defined".
9
11
  __html: `
10
- import { injectIntoGlobalHook } from "/@react-refresh";
12
+ import { injectIntoGlobalHook } from "${process.env.APP_MOUNT_PATH || ""}/@react-refresh";
11
13
  injectIntoGlobalHook(window);
12
14
  window.$RefreshReg$ = () => {};
13
15
  window.$RefreshSig$ = () => (type) => type;
@@ -45,9 +45,11 @@ function loadAliases() {
45
45
  for (const [alias, aliasPath] of Object.entries(allAliases)) {
46
46
  if (aliasPath && typeof aliasPath === "string") {
47
47
  try {
48
+ // nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal - appRoot is the app's own build root and aliasPath comes from the developer's package.json config, not request/user input.
48
49
  const resolvedPath = path.resolve(appRoot, ...aliasPath.split("/"))
49
50
  aliasCache[alias] = resolvedPath
50
51
  } catch (error) {
52
+ // nosemgrep: javascript.lang.security.audit.unsafe-formatstring.unsafe-formatstring - alias is a key from the developer's own package.json config, not attacker-controlled; console.warn here does no printf-style substitution.
51
53
  console.warn(`Failed to resolve alias ${alias}:`, error.message)
52
54
  }
53
55
  }
@@ -87,6 +89,7 @@ function resolveWithExtensions(basePath) {
87
89
  }
88
90
 
89
91
  for (const ext of extensions) {
92
+ // nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal - basePath is derived from module import specifiers resolved by this plugin's own alias/relative-import logic, never from a request.
90
93
  const candidate = path.join(basePath, `index${ext}`)
91
94
  if (existsSync(candidate)) {
92
95
  return candidate
@@ -107,9 +110,11 @@ function resolveToAbsolutePath(importPath, importerId) {
107
110
  for (const [alias, aliasPath] of Object.entries(aliases)) {
108
111
  if (importPath === alias || importPath.startsWith(alias + "/")) {
109
112
  const remainingPath = importPath === alias ? "" : importPath.slice(alias.length + 1)
113
+ // nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal - importPath comes from the app's own module import graph and aliasPath from the developer's package.json config, never from a request.
110
114
  const rawResolved = path.join(aliasPath, remainingPath)
111
115
  const finalResolved = resolveWithExtensions(rawResolved)
112
116
  if (finalResolved) {
117
+ // nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal - finalResolved is derived from the same alias/import resolution above, not request input.
113
118
  return path.resolve(finalResolved)
114
119
  }
115
120
  }
@@ -118,6 +123,7 @@ function resolveToAbsolutePath(importPath, importerId) {
118
123
  // 2. Relative imports — resolve from the importer's directory
119
124
  if (importPath.startsWith("./") || importPath.startsWith("../")) {
120
125
  const importerDir = path.dirname(importerId)
126
+ // nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal - importPath comes from the app's own module import graph and importerId from Vite's module resolution, never from a request.
121
127
  const absolutePath = path.resolve(importerDir, importPath)
122
128
  const resolved = resolveWithExtensions(absolutePath)
123
129
  if (resolved) {
@@ -149,6 +155,7 @@ function resolveImportPath(importPath, importerId) {
149
155
  // Fallback: strip leading ./ and return as-is
150
156
  return importPath.replace(/^\.\//, "")
151
157
  } catch (error) {
158
+ // nosemgrep: javascript.lang.security.audit.unsafe-formatstring.unsafe-formatstring - importPath comes from the app's own module import graph resolved by Vite at build time, not attacker-controlled; console.warn here does no printf-style substitution.
152
159
  console.warn(`Error resolving import path ${importPath}:`, error.message)
153
160
  return importPath.replace(/^\.\//, "")
154
161
  }
@@ -5,11 +5,40 @@ import { pathToFileURL } from "url"
5
5
  const emptyConfig = {
6
6
  ssrPlugins: [],
7
7
  clientPlugins: [],
8
+ /**
9
+ * devServer lets apps tune the Vite dev server without editing framework
10
+ * internals. All fields are optional and map 1-to-1 onto Vite's `server`
11
+ * config (https://vite.dev/config/server-options).
12
+ *
13
+ * The most common use-case is an app served under a sub-path behind an SSL
14
+ * reverse proxy (nginx/caddy):
15
+ *
16
+ * // buildConfig.js
17
+ * export default {
18
+ * devServer: {
19
+ * // Vite's base path — the single source of truth for the dev mount
20
+ * // path; must match the reverse-proxy location prefix. Defaults to "/".
21
+ * base: "/o-mweb",
22
+ *
23
+ * // HMR WebSocket tuning when the browser connects via the proxy
24
+ * hmr: {
25
+ * clientPort: 443, // port the browser uses (proxy's SSL port)
26
+ * path: "__vite_hmr" // WebSocket pathname (relative to base)
27
+ * },
28
+ *
29
+ * // Vite 6 rejects requests whose Host header isn't localhost/127.0.0.1.
30
+ * // Add your proxy hostname here so the dev server accepts them.
31
+ * allowedHosts: ["local.odinweb.1mg.com"],
32
+ * },
33
+ * }
34
+ */
35
+ devServer: {},
8
36
  }
9
37
 
10
38
  export async function loadCustomViteConfig() {
11
39
  const configNames = ["buildConfig.js", "webpackConfig.js"]
12
40
  const configPath = configNames
41
+ // nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal - configName is one of two hardcoded literals above, and process.env.src_path is the app root set by the framework's own build tooling, not request/user input.
13
42
  .map((configName) => path.join(process.env.src_path, configName))
14
43
  .find((candidatePath) => existsSync(candidatePath))
15
44
 
@@ -293,6 +293,7 @@ export function manifestCategorizationPlugin(options = {}) {
293
293
  // branch of the import graph, the affected CSS would vanish from server responses
294
294
  // with no runtime error — this check surfaces that as a build-time warning.
295
295
  function validateCssCoverage(categorizedManifest, buildDir) {
296
+ // nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal - buildDir is the build tool's own output directory and the remaining segments are hardcoded literals, not request input.
296
297
  const cssDir = path.join(buildDir, "client", "assets", "css")
297
298
  if (!fs.existsSync(cssDir)) return
298
299
 
@@ -403,6 +404,7 @@ export function manifestCategorizationPlugin(options = {}) {
403
404
  })
404
405
  }
405
406
  } catch (err) {
407
+ // nosemgrep: javascript.lang.security.audit.unsafe-formatstring.unsafe-formatstring - importPath comes from the app's own module import graph resolved by Vite at build time, not attacker-controlled; console.log here does no printf-style substitution.
406
408
  console.log(`❌ Error resolving ${importPath}:`, err.message)
407
409
  }
408
410
  })
@@ -56,6 +56,7 @@ function loadAliases() {
56
56
  const resolvedPath = resolvePath(appRoot, ...aliasPath.split("/"))
57
57
  aliasCache[alias] = resolvedPath
58
58
  } catch (error) {
59
+ // nosemgrep: javascript.lang.security.audit.unsafe-formatstring.unsafe-formatstring - alias is a key from the developer's own package.json _moduleAliases config, not attacker-controlled; console.warn here does no printf-style substitution.
59
60
  console.warn(`Failed to resolve alias ${alias}:`, error.message)
60
61
  }
61
62
  }
@@ -100,6 +101,7 @@ function resolveWithExtensions(basePath) {
100
101
 
101
102
  // Try index files inside a directory
102
103
  for (const ext of extensions) {
104
+ // nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal - basePath is derived from module import specifiers resolved by the Node loader itself, never from a request.
103
105
  const candidate = join(basePath, `index${ext}`)
104
106
  if (existsSync(candidate)) {
105
107
  return candidate
@@ -119,10 +121,12 @@ function resolveAlias(specifier, parentURL) {
119
121
  for (const [alias, aliasPath] of Object.entries(aliases)) {
120
122
  if (specifier.startsWith(alias + "/") || specifier === alias) {
121
123
  const remainingPath = specifier === alias ? "" : specifier.slice(alias.length + 1)
124
+ // nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal - specifier comes from an ES module import statement in the app's own source code, and aliasPath from the developer's package.json config, never from a request.
122
125
  const rawResolvedPath = join(aliasPath, remainingPath)
123
126
  const finalResolvedPath = resolveWithExtensions(rawResolvedPath)
124
127
 
125
128
  if (finalResolvedPath) {
129
+ // nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal - finalResolvedPath is derived from the same alias/import resolution above, not request input.
126
130
  return resolvePath(finalResolvedPath)
127
131
  }
128
132
  }
@@ -140,6 +144,7 @@ function resolveRelative(specifier, parentURL) {
140
144
  try {
141
145
  const parentPath = fileURLToPath(parentURL)
142
146
  const parentDir = dirname(parentPath)
147
+ // nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal - specifier comes from an ES module import statement in the app's own source code, parentURL from the module loader itself, never from a request.
143
148
  const absolutePath = resolvePath(parentDir, specifier)
144
149
  return resolveWithExtensions(absolutePath)
145
150
  } catch {
@@ -225,6 +230,7 @@ export async function resolve(specifier, context, defaultResolve) {
225
230
  */
226
231
  function findNearestPackageType(dir) {
227
232
  while (dir !== dirname(dir)) {
233
+ // nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal - dir is walked up from the module's own filesystem location via dirname(), never from a request; "package.json" is a hardcoded literal.
228
234
  const pkgPath = join(dir, "package.json")
229
235
  if (existsSync(pkgPath)) {
230
236
  try {
@@ -302,9 +308,37 @@ function isValidExportName(name) {
302
308
  // Loader-scoped require for discovering CJS exports
303
309
  const loaderRequire = createRequire(import.meta.url)
304
310
 
305
- const CJS_PATTERN = /\b(module\.exports\b|exports\.\w+\s*=)/
306
311
  const REQUIRE_CALL_PATTERN = /\brequire\s*\(/
307
312
 
313
+ /**
314
+ * Decide whether a source file is really CommonJS by parsing it, not by
315
+ * regex-matching the raw text.
316
+ *
317
+ * A regex like /module\.exports\b/ cannot tell code from text, so any file that
318
+ * merely *mentions* module.exports inside a string, comment or template literal
319
+ * was misclassified as CJS. When such a file was real ESM with its own
320
+ * `export default`, the CJS shim appended a second one and Node failed the
321
+ * module with "Duplicate export of 'default'". Docs sites hit this constantly:
322
+ * a Markdown page documenting a CJS config compiles into a string literal in
323
+ * the bundled ESM output. Do not reintroduce source-regex detection.
324
+ *
325
+ * esbuild parses properly and lowers CJS itself, so a file it wraps in its
326
+ * __commonJS helper had no ESM syntax of its own — that is the CJS signal.
327
+ */
328
+ function isCjsSource(source, filePath) {
329
+ try {
330
+ const { code } = transformSync(source, {
331
+ loader: "jsx",
332
+ format: "esm",
333
+ sourcefile: filePath,
334
+ target: "node20",
335
+ })
336
+ return /\bvar __commonJS =/.test(code)
337
+ } catch {
338
+ return false
339
+ }
340
+ }
341
+
308
342
  // Shim __dirname and __filename for ESM app files (these globals only exist in CJS)
309
343
  const createDirnameShim = (source) => {
310
344
  const hasFilename = /\b(?:const|let|var)\s+__filename\b/.test(source)
@@ -390,7 +424,7 @@ export async function load(url, context, defaultLoad) {
390
424
  // We can't use createRequire here because the file lives in a "type":"module"
391
425
  // package — Node's CJS require refuses to load it. Instead, we wrap the
392
426
  // source inline so that `module.exports` becomes the default export.
393
- if (CJS_PATTERN.test(source)) {
427
+ if (isCjsSource(source, filePath)) {
394
428
  const shimmed = [
395
429
  createRequireShim(source),
396
430
  createDirnameShim(source),
@@ -0,0 +1,45 @@
1
+ // Pure resolvers for the Vite dev-server config derived from an app's
2
+ // `buildConfig.devServer`. Side-effect free so the base precedence and the fs
3
+ // allowlist merge can be unit-tested without spinning up Vite.
4
+
5
+ /**
6
+ * Vite `base` for the dev server. `buildConfig.devServer.base` is the single
7
+ * source of truth (default "/"). Normalized to a leading slash and no trailing
8
+ * slash so it doubles as a URL prefix for the SSR renderer.
9
+ */
10
+ export function resolveDevBase(devServer = {}) {
11
+ const base = devServer?.base
12
+ if (!base || base === "/") return "/"
13
+ return `/${String(base).trim().replace(/^\/+|\/+$/g, "")}`
14
+ }
15
+
16
+ /**
17
+ * A resolved base as a URL prefix. Root ("/") becomes "" so
18
+ * `${prefix}/client/index.js` stays single-slashed.
19
+ */
20
+ export function toMountPathPrefix(base) {
21
+ return !base || base === "/" ? "" : String(base).replace(/\/+$/, "")
22
+ }
23
+
24
+ /**
25
+ * fs.allow for the dev server. Framework paths are always present; apps may
26
+ * extend the list via `devServer.fs.allow` but cannot replace it or relax other
27
+ * fs guards (e.g. `strict`), so buildConfig can't widen filesystem exposure.
28
+ */
29
+ export function resolveDevFsAllow(devFs, frameworkPaths) {
30
+ const extra = Array.isArray(devFs?.allow) ? devFs.allow : []
31
+ return [...frameworkPaths, ...extra]
32
+ }
33
+
34
+ /**
35
+ * Vite `server` block from `buildConfig.devServer`. Passthrough keys are spread
36
+ * first so the framework-owned `hmr` and `fs` always win.
37
+ */
38
+ export function buildDevServer(devServer = {}, { frameworkPaths, isProduction = false } = {}) {
39
+ const { base, hmr, fs, ...serverOverrides } = devServer
40
+ return {
41
+ ...serverOverrides,
42
+ hmr: isProduction ? false : (hmr ?? true),
43
+ fs: { allow: resolveDevFsAllow(fs, frameworkPaths) },
44
+ }
45
+ }
@@ -1,7 +1,7 @@
1
1
  import loadEnvironmentVariables from "../scripts/loadEnvironmentVariables.js"
2
2
  loadEnvironmentVariables()
3
3
  import { defineConfig } from "vite"
4
- import baseConfig, { getClientEnvVariables } from "./vite.config.js"
4
+ import { sharedViteConfig as baseConfig, getClientEnvVariables } from "./vite.config.js"
5
5
  import path from "path"
6
6
  import { manifestCategorizationPlugin } from "./manifest-categorization-plugin.js"
7
7
  import { injectCacheKeyPlugin } from "./inject-cache-key-plugin.js"
@@ -2,6 +2,8 @@ import { defineConfig, transformWithEsbuild } 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"
5
+ import { loadCustomViteConfig } from "./loadCustomViteConfig.js"
6
+ import { resolveDevBase, buildDevServer } from "./resolveDevServerConfig.js"
5
7
 
6
8
  /**
7
9
  * Treat all .scss imports as CSS modules (webpack compat).
@@ -113,6 +115,7 @@ try {
113
115
  const packageJson = JSON.parse(packageJsonContent)
114
116
  _moduleAliases = packageJson._moduleAliases || {}
115
117
  } catch (error) {
118
+ // nosemgrep: javascript.lang.security.audit.unsafe-formatstring.unsafe-formatstring - packageJsonConfig is a build-time-resolved local path, not attacker-controlled; console.warn here does no printf-style substitution.
116
119
  console.warn(`Failed to read or parse package.json from ${packageJsonConfig}:`, error.message)
117
120
  }
118
121
 
@@ -122,6 +125,7 @@ try {
122
125
  catalyst_moduleAliases = catalystPackageJson._moduleAliases || {}
123
126
  } catch (error) {
124
127
  console.warn(
128
+ // nosemgrep: javascript.lang.security.audit.unsafe-formatstring.unsafe-formatstring - catalystPackageJsonConfig is a build-time-resolved local path, not attacker-controlled; console.warn here does no printf-style substitution.
125
129
  `Failed to read or parse catalyst package.json from ${catalystPackageJsonConfig}:`,
126
130
  error.message
127
131
  )
@@ -146,6 +150,7 @@ import { imageUrl, fontUrl } from "./scssParams.js"
146
150
  // options — so anything else is left alone and Vite falls back to its normal
147
151
  // auto-loaded postcss.config.js resolution.
148
152
  const resolvePostcssPlugins = () => {
153
+ // nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal - process.env.src_path is the app root set by the framework's own build tooling, not request/user input.
149
154
  const configPath = path.join(process.env.src_path, "postcss.config.js")
150
155
  if (!fs.existsSync(configPath)) return null
151
156
 
@@ -168,6 +173,7 @@ const resolvePostcssPlugins = () => {
168
173
  return plugin(finalOptions)
169
174
  })
170
175
  } catch (error) {
176
+ // nosemgrep: javascript.lang.security.audit.unsafe-formatstring.unsafe-formatstring - configPath is a build-time-resolved local path, not attacker-controlled; console.warn here does no printf-style substitution.
171
177
  console.warn(`Failed to patch postcss config at ${configPath}:`, error.message)
172
178
  return null
173
179
  }
@@ -183,9 +189,11 @@ const alias = () => {
183
189
  return Object.keys(allAliases).reduce((moduleEnvMap, alias) => {
184
190
  if (allAliases[alias] && typeof allAliases[alias] === "string") {
185
191
  try {
192
+ // nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal - process.env.src_path is the app root and allAliases comes from the developer's own package.json _moduleAliases config, not request/user input.
186
193
  const aliasPath = path.join(process.env.src_path, ...allAliases[alias].split("/"))
187
194
  moduleEnvMap[alias] = aliasPath
188
195
  } catch (error) {
196
+ // nosemgrep: javascript.lang.security.audit.unsafe-formatstring.unsafe-formatstring - alias is a key from the developer's own package.json _moduleAliases config, not attacker-controlled; console.warn here does no printf-style substitution.
189
197
  console.warn(`Failed to configure alias ${alias}:`, error.message)
190
198
  }
191
199
  }
@@ -356,7 +364,10 @@ export const isNodeOnlyExternal = (id) =>
356
364
  id === "@grpc/grpc-js" ||
357
365
  id.startsWith("@opentelemetry/")
358
366
 
359
- export default defineConfig({
367
+ // ─── Shared config ────────────────────────────────────────────────────────────
368
+ // Exported for the production build (vite.config.client.js spreads this in).
369
+ // Does NOT include `base` or `server` — those differ between dev and prod.
370
+ export const sharedViteConfig = {
360
371
  // Parallel `vite build` (SSR + client) must use separate dirs or Vite will block on shared `node_modules/.vite`.
361
372
  cacheDir: path.join(
362
373
  process.env.src_path,
@@ -482,17 +493,34 @@ export default defineConfig({
482
493
  "**/*.woff2",
483
494
  ],
484
495
 
485
- // Server configuration
486
- server: {
487
- hmr: !isProduction,
488
- fs: {
489
- allow: [process.env.src_path, __dirname],
490
- },
491
- },
492
-
493
496
  // Preview configuration for production preview
494
497
  preview: {
495
498
  port: process.env.NODE_SERVER_PORT ? parseInt(process.env.NODE_SERVER_PORT) : 3005,
496
499
  host: process.env.NODE_SERVER_HOSTNAME || "localhost",
497
500
  },
501
+ }
502
+
503
+ // ─── Dev server config ────────────────────────────────────────────────────────
504
+ // Async factory so it can read the app's buildConfig.devServer for `base`, HMR
505
+ // and other Vite server options. `base` comes solely from devServer.base; the
506
+ // resolved value is propagated to the SSR renderer by server/expressServer.js.
507
+ // The production build (vite.config.client.js) ignores this and spreads
508
+ // sharedViteConfig directly.
509
+ export default defineConfig(async () => {
510
+ const { devServer = {}, ssrPlugins = [] } = await loadCustomViteConfig()
511
+
512
+ return {
513
+ ...sharedViteConfig,
514
+ base: resolveDevBase(devServer),
515
+ server: buildDevServer(devServer, {
516
+ frameworkPaths: [process.env.src_path, __dirname],
517
+ isProduction,
518
+ }),
519
+ // Apply the app's SSR plugins in dev too — the production build already
520
+ // does this in vite.config.server.js. This lets buildConfig.ssrPlugins
521
+ // inject SSR-only config (e.g. a plugin whose `config()` hook adds
522
+ // ssr.noExternal to bundle React-consuming deps so dev SSR shares the
523
+ // app's single React instance) without hardcoding anything in the framework.
524
+ plugins: [...(sharedViteConfig.plugins || []), ...ssrPlugins],
525
+ }
498
526
  })
@@ -1,7 +1,11 @@
1
1
  import loadEnvironmentVariables from "../scripts/loadEnvironmentVariables.js"
2
2
  loadEnvironmentVariables()
3
3
  import { defineConfig } from "vite"
4
- import baseConfig, { getClientEnvVariables, isNodeOnlyExternal } from "./vite.config.js"
4
+ // Spread the static shared config (a plain object), NOT the default export —
5
+ // the default export is now an async dev-server factory function, and spreading
6
+ // a function would silently drop cacheDir, resolve aliases, css, plugins and
7
+ // build from the production SSR bundle. Mirrors vite.config.client.js.
8
+ import { sharedViteConfig as baseConfig, getClientEnvVariables, isNodeOnlyExternal } from "./vite.config.js"
5
9
  import path from "path"
6
10
  import { fileURLToPath } from "url"
7
11
  import { dirname } from "path"
@@ -161,6 +161,7 @@ function safeReadText(filePath, maxBytes = 8000) {
161
161
 
162
162
  function runGit(root, command) {
163
163
  try {
164
+ // nosemgrep: javascript.lang.security.detect-child-process.detect-child-process - command is always one of the fixed git/node/npm strings hardcoded at call sites, never external input.
164
165
  return execSync(command, {
165
166
  cwd: root,
166
167
  encoding: "utf8",
@@ -183,6 +184,7 @@ function slugify(input) {
183
184
  }
184
185
 
185
186
  function ensureFallbackDir(root) {
187
+ // nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal - root is the project_path MCP tool argument, an intentional/documented parameter letting a local developer's coding agent point this tool at any project directory it already has filesystem access to (see mcp.js inputSchema). Not a network-facing input; the caller already has direct fs access via other tools. FALLBACK_DIR is a hardcoded literal.
186
188
  const dir = path.join(root, FALLBACK_DIR)
187
189
  fs.mkdirSync(dir, { recursive: true })
188
190
  return dir
@@ -406,7 +408,9 @@ function appendSection(sections, heading, content) {
406
408
  }
407
409
 
408
410
  function isPathInside(root, candidate) {
411
+ // nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal - this is the containment-check helper itself (used by callers below to reject paths outside root); root/candidate here are the values being validated, not yet trusted for file access.
409
412
  const resolvedRoot = path.resolve(root)
413
+ // nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal - this is the containment-check helper itself; candidate is the value being validated, not yet trusted for file access.
410
414
  const resolvedCandidate = path.resolve(candidate)
411
415
  const relative = path.relative(resolvedRoot, resolvedCandidate)
412
416
  return relative === "" || (relative && !relative.startsWith("..") && !path.isAbsolute(relative))
@@ -543,6 +547,7 @@ function normalizeImages(images, root) {
543
547
  continue
544
548
  }
545
549
 
550
+ // nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal - raw is an MCP tool argument (attachment path) and root the project_path argument; the isPathInside() check on the next line rejects anything that resolves outside root before it is ever read.
546
551
  const absolutePath = path.isAbsolute(raw) ? path.resolve(raw) : path.resolve(root, raw)
547
552
  if (!isPathInside(root, absolutePath)) {
548
553
  localFiles.push({ path: raw, exists: false, rejected: true, reason: "outside_project_root" })
@@ -789,6 +794,7 @@ function collectRelatedFiles(root, query, explicitFiles) {
789
794
  const candidates = Array.isArray(explicitFiles) ? explicitFiles : []
790
795
  for (const file of candidates) {
791
796
  if (typeof file !== "string") continue
797
+ // nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal - file is an MCP tool argument (explicit related-files list) and root the project_path argument; the isPathInside() check on the next line rejects anything that resolves outside root before it is ever read.
792
798
  const absolute = path.isAbsolute(file) ? path.resolve(file) : path.resolve(root, file)
793
799
  if (!isPathInside(root, absolute)) continue
794
800
  const content = safeReadText(absolute, 12000)
@@ -802,6 +808,7 @@ function collectRelatedFiles(root, query, explicitFiles) {
802
808
  const words = normalizeSearchText(query).split(/\s+/).filter(Boolean).slice(0, 5)
803
809
  if (words.length === 0) return files
804
810
 
811
+ // nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal - root is the project_path MCP tool argument, an intentional/documented parameter (see mcp.js inputSchema); "src" is a hardcoded literal.
805
812
  const srcDir = path.join(root, "src")
806
813
  const matches = []
807
814
  function walk(dir) {
@@ -813,6 +820,7 @@ function collectRelatedFiles(root, query, explicitFiles) {
813
820
  }
814
821
  for (const entry of entries) {
815
822
  if (entry.name === "node_modules" || entry.name === ".git") continue
823
+ // nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal - entry.name comes from fs.readdirSync(dir) above, i.e. actual filenames already on disk under srcDir, not request input.
816
824
  const full = path.join(dir, entry.name)
817
825
  if (entry.isDirectory()) {
818
826
  walk(full)
@@ -831,7 +839,9 @@ function collectRelatedFiles(root, query, explicitFiles) {
831
839
 
832
840
  function gatherGithubIssueContext(args = {}) {
833
841
  const root = getProjectRoot(args)
842
+ // nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal - root is the project_path MCP tool argument, an intentional/documented parameter (see mcp.js inputSchema); "package.json" is a hardcoded literal.
834
843
  const pkg = safeReadJson(path.join(root, "package.json"))
844
+ // nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal - root is the project_path MCP tool argument, an intentional/documented parameter (see mcp.js inputSchema); "config/config.json" is a hardcoded literal.
835
845
  const configJson = safeReadJson(path.join(root, "config", "config.json"))
836
846
  const branch = runGit(root, "git rev-parse --abbrev-ref HEAD")
837
847
  const commit = runGit(root, "git rev-parse --short HEAD")
@@ -874,6 +884,7 @@ function generateIssueMarkdown(args = {}) {
874
884
  const built = buildIssueBody(args, { enrich: true })
875
885
  const labels = resolveIssueLabels(args, built.body)
876
886
  const dir = ensureFallbackDir(root)
887
+ // nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal - dir is the FALLBACK_DIR under the project_path MCP argument; the filename is a generated ISO timestamp plus slugify(title), which strips to [a-z0-9-] only, so it cannot contain path separators.
877
888
  const filePath = path.join(dir, `${new Date().toISOString().replace(/[:.]/g, "-")}-${slugify(title)}.md`)
878
889
 
879
890
  const markdown = [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "catalyst-core",
3
- "version": "0.3.0-beta.2",
3
+ "version": "0.3.0-beta.4",
4
4
  "type": "module",
5
5
  "main": "./dist/index.jsx",
6
6
  "description": "Web framework that provides great performance out of the box",
@@ -30,6 +30,7 @@
30
30
  "scripts": {
31
31
  "lint": "eslint .",
32
32
  "lint-staged": "lint-staged",
33
+ "test:unit": "node --test \"test/**/*.test.js\"",
33
34
  "prettify": "prettier . --write",
34
35
  "prepare": "node ../../scripts/safe-rimraf.js ./dist && cp -r ./src ./dist && babel src/native --out-dir dist/native --config-file ./babel.native.config.cjs --ignore '**/.build/**' && babel src/sentry.js --out-file dist/sentry.cjs --config-file ./babel.native.config.cjs",
35
36
  "prepublishOnly": "npm run prepare",
@@ -51,7 +52,7 @@
51
52
  "react-refresh": "^0.14.0",
52
53
  "react-router-dom": "^6.16.0",
53
54
  "redux": "^4.2.1",
54
- "sanitize-html": "^2.13.1",
55
+ "sanitize-html": "^2.17.6",
55
56
  "sass": "^1.89.2",
56
57
  "ua-parser-js": "^1.0.37",
57
58
  "vite": "^6.4.3",
@@ -0,0 +1,113 @@
1
+ import { test } from "node:test"
2
+ import assert from "node:assert/strict"
3
+
4
+ import {
5
+ resolveDevBase,
6
+ toMountPathPrefix,
7
+ resolveDevFsAllow,
8
+ buildDevServer,
9
+ } from "../../src/vite/resolveDevServerConfig.js"
10
+
11
+ // ─── resolveDevBase ───────────────────────────────────────────────────────────
12
+
13
+ test("resolveDevBase: resolves from devServer.base", () => {
14
+ assert.equal(resolveDevBase({ base: "/o-mweb" }), "/o-mweb")
15
+ })
16
+
17
+ test("resolveDevBase: defaults to '/' when base is unset", () => {
18
+ assert.equal(resolveDevBase({}), "/")
19
+ assert.equal(resolveDevBase(undefined), "/")
20
+ assert.equal(resolveDevBase({ base: "/" }), "/")
21
+ })
22
+
23
+ test("resolveDevBase: normalizes to a leading slash, no trailing slash", () => {
24
+ assert.equal(resolveDevBase({ base: "o-mweb" }), "/o-mweb")
25
+ assert.equal(resolveDevBase({ base: "/o-mweb/" }), "/o-mweb")
26
+ assert.equal(resolveDevBase({ base: " /o-mweb// " }), "/o-mweb")
27
+ })
28
+
29
+ // ─── toMountPathPrefix ────────────────────────────────────────────────────────
30
+
31
+ test("toMountPathPrefix: root maps to empty string for clean URL joins", () => {
32
+ assert.equal(toMountPathPrefix("/"), "")
33
+ assert.equal(toMountPathPrefix(""), "")
34
+ assert.equal(toMountPathPrefix(undefined), "")
35
+ })
36
+
37
+ test("toMountPathPrefix: trims Vite's trailing slash", () => {
38
+ assert.equal(toMountPathPrefix("/o-mweb/"), "/o-mweb")
39
+ assert.equal(toMountPathPrefix("/o-mweb"), "/o-mweb")
40
+ // The joined result stays single-slashed.
41
+ assert.equal(`${toMountPathPrefix("/o-mweb/")}/client/index.js`, "/o-mweb/client/index.js")
42
+ assert.equal(`${toMountPathPrefix("/")}/client/index.js`, "/client/index.js")
43
+ })
44
+
45
+ // ─── resolveDevFsAllow: security-sensitive allowlist merge ─────────────────────
46
+
47
+ const FRAMEWORK = ["/app/src", "/framework/dist/vite"]
48
+
49
+ test("resolveDevFsAllow: framework paths always present, none provided", () => {
50
+ assert.deepEqual(resolveDevFsAllow(undefined, FRAMEWORK), FRAMEWORK)
51
+ assert.deepEqual(resolveDevFsAllow({}, FRAMEWORK), FRAMEWORK)
52
+ })
53
+
54
+ test("resolveDevFsAllow: apps may EXTEND but never drop framework paths", () => {
55
+ const result = resolveDevFsAllow({ allow: ["/extra/pkg"] }, FRAMEWORK)
56
+ assert.deepEqual(result, ["/app/src", "/framework/dist/vite", "/extra/pkg"])
57
+ })
58
+
59
+ test("resolveDevFsAllow: non-array allow is ignored (no widening via bad input)", () => {
60
+ assert.deepEqual(resolveDevFsAllow({ allow: "/" }, FRAMEWORK), FRAMEWORK)
61
+ assert.deepEqual(resolveDevFsAllow({ allow: null }, FRAMEWORK), FRAMEWORK)
62
+ })
63
+
64
+ // ─── buildDevServer: precedence + security ────────────────────────────────────
65
+
66
+ test("buildDevServer: hmr defaults to true in dev", () => {
67
+ const server = buildDevServer({}, { frameworkPaths: FRAMEWORK, isProduction: false })
68
+ assert.equal(server.hmr, true)
69
+ })
70
+
71
+ test("buildDevServer: hmr is false in production", () => {
72
+ const server = buildDevServer({ hmr: { clientPort: 443 } }, { frameworkPaths: FRAMEWORK, isProduction: true })
73
+ assert.equal(server.hmr, false)
74
+ })
75
+
76
+ test("buildDevServer: app hmr overrides are honored in dev", () => {
77
+ const hmr = { clientPort: 443, path: "__vite_hmr" }
78
+ const server = buildDevServer({ hmr }, { frameworkPaths: FRAMEWORK, isProduction: false })
79
+ assert.deepEqual(server.hmr, hmr)
80
+ })
81
+
82
+ test("buildDevServer: passthrough keys (proxy/allowedHosts) are forwarded", () => {
83
+ const server = buildDevServer(
84
+ { allowedHosts: ["local.example.com"], proxy: { "/api": "http://localhost:9000" } },
85
+ { frameworkPaths: FRAMEWORK, isProduction: false }
86
+ )
87
+ assert.deepEqual(server.allowedHosts, ["local.example.com"])
88
+ assert.deepEqual(server.proxy, { "/api": "http://localhost:9000" })
89
+ })
90
+
91
+ test("buildDevServer: app fs cannot replace the allowlist, only extend it", () => {
92
+ const server = buildDevServer(
93
+ { fs: { allow: ["/extra"] } },
94
+ { frameworkPaths: FRAMEWORK, isProduction: false }
95
+ )
96
+ assert.deepEqual(server.fs.allow, ["/app/src", "/framework/dist/vite", "/extra"])
97
+ })
98
+
99
+ test("buildDevServer: app cannot relax fs.strict or clobber fs via overrides", () => {
100
+ const server = buildDevServer(
101
+ { fs: { allow: ["/"], strict: false } },
102
+ { frameworkPaths: FRAMEWORK, isProduction: false }
103
+ )
104
+ // strict is dropped entirely (framework keeps Vite's secure default), and
105
+ // the framework paths remain — "/" is merely appended, not a replacement.
106
+ assert.equal(server.fs.strict, undefined)
107
+ assert.ok(server.fs.allow.includes("/app/src"))
108
+ })
109
+
110
+ test("buildDevServer: 'base' is not leaked into the server block", () => {
111
+ const server = buildDevServer({ base: "/o-mweb" }, { frameworkPaths: FRAMEWORK, isProduction: false })
112
+ assert.equal(server.base, undefined)
113
+ })