catalyst-core 0.3.0-beta.3 → 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.
@@ -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"
@@ -208,6 +209,12 @@ async function createServer() {
208
209
  appType: "custom",
209
210
  root: process.env.src_path,
210
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
+
211
218
  app.use(vite.middlewares)
212
219
  }
213
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 */
@@ -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;
@@ -5,6 +5,34 @@ 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() {
@@ -308,9 +308,37 @@ function isValidExportName(name) {
308
308
  // Loader-scoped require for discovering CJS exports
309
309
  const loaderRequire = createRequire(import.meta.url)
310
310
 
311
- const CJS_PATTERN = /\b(module\.exports\b|exports\.\w+\s*=)/
312
311
  const REQUIRE_CALL_PATTERN = /\brequire\s*\(/
313
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
+
314
342
  // Shim __dirname and __filename for ESM app files (these globals only exist in CJS)
315
343
  const createDirnameShim = (source) => {
316
344
  const hasFilename = /\b(?:const|let|var)\s+__filename\b/.test(source)
@@ -396,7 +424,7 @@ export async function load(url, context, defaultLoad) {
396
424
  // We can't use createRequire here because the file lives in a "type":"module"
397
425
  // package — Node's CJS require refuses to load it. Instead, we wrap the
398
426
  // source inline so that `module.exports` becomes the default export.
399
- if (CJS_PATTERN.test(source)) {
427
+ if (isCjsSource(source, filePath)) {
400
428
  const shimmed = [
401
429
  createRequireShim(source),
402
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).
@@ -362,7 +364,10 @@ export const isNodeOnlyExternal = (id) =>
362
364
  id === "@grpc/grpc-js" ||
363
365
  id.startsWith("@opentelemetry/")
364
366
 
365
- 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 = {
366
371
  // Parallel `vite build` (SSR + client) must use separate dirs or Vite will block on shared `node_modules/.vite`.
367
372
  cacheDir: path.join(
368
373
  process.env.src_path,
@@ -488,17 +493,34 @@ export default defineConfig({
488
493
  "**/*.woff2",
489
494
  ],
490
495
 
491
- // Server configuration
492
- server: {
493
- hmr: !isProduction,
494
- fs: {
495
- allow: [process.env.src_path, __dirname],
496
- },
497
- },
498
-
499
496
  // Preview configuration for production preview
500
497
  preview: {
501
498
  port: process.env.NODE_SERVER_PORT ? parseInt(process.env.NODE_SERVER_PORT) : 3005,
502
499
  host: process.env.NODE_SERVER_HOSTNAME || "localhost",
503
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
+ }
504
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"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "catalyst-core",
3
- "version": "0.3.0-beta.3",
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",
@@ -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
+ })