catalyst-core 1.0.1 → 1.0.2-canary.2

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.
Files changed (114) hide show
  1. package/bin/catalyst.js +105 -1
  2. package/dist/cli/README.md +119 -0
  3. package/dist/cli/appConfig.js +50 -0
  4. package/dist/cli/buildErrors.js +170 -0
  5. package/dist/cli/buildWarnings.js +28 -0
  6. package/dist/cli/devOutput.js +524 -0
  7. package/dist/cli/diagnostic.js +388 -0
  8. package/dist/cli/hints.js +69 -0
  9. package/dist/cli/package.json +3 -0
  10. package/dist/cli/spinner.js +141 -0
  11. package/dist/cli/theme.js +155 -0
  12. package/dist/errors/index.js +7 -3
  13. package/dist/errors/registry.js +8 -0
  14. package/dist/errors-index.json +7 -0
  15. package/dist/index.d.ts +1 -1
  16. package/dist/index.jsx +1 -1
  17. package/dist/logger.js +61 -6
  18. package/dist/native/androidProject/app/build.gradle.kts +2 -1
  19. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/CustomWebview.kt +2 -0
  20. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/MainActivity.kt +9 -0
  21. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/SplashActivity.kt +65 -34
  22. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/SplashOverlay.kt +109 -0
  23. package/dist/native/androidSetup.js +46 -57
  24. package/dist/native/buildAndroid/assets.js +22 -13
  25. package/dist/native/buildAndroid/build.js +98 -26
  26. package/dist/native/buildAndroid/config.js +43 -12
  27. package/dist/native/buildAndroid/index.js +49 -191
  28. package/dist/native/buildAppAndroid.js +19 -8
  29. package/dist/native/buildAppIos.js +35 -16
  30. package/dist/native/buildIos/assets.js +1 -6
  31. package/dist/native/buildIos/build.js +61 -94
  32. package/dist/native/buildIos/config.js +26 -8
  33. package/dist/native/buildIos/index.js +143 -64
  34. package/dist/native/buildIos/plugins.js +12 -15
  35. package/dist/native/internalPluginUtils.js +0 -1
  36. package/dist/native/iosnativeWebView/CoreLogic/Sources/CatalystCoreLogic/Constants/ConfigConstants.swift +2 -0
  37. package/dist/native/iosnativeWebView/Sources/Core/WebView/SplashHTMLView.swift +31 -0
  38. package/dist/native/iosnativeWebView/Sources/Core/WebView/SplashView.swift +17 -1
  39. package/dist/native/iosnativeWebView/Sources/Core/WebView/SplashViewModel.swift +41 -1
  40. package/dist/native/iosnativeWebView/Sources/Core/WebView/WebViewModel.swift +2 -0
  41. package/dist/native/iosnativeWebView/Sources/Core/WebView/WebViewNavigationDelegate.swift +3 -0
  42. package/dist/native/pluginComposerAndroid.js +2 -3
  43. package/dist/native/renameAndroidProject.js +42 -20
  44. package/dist/native/setupEmulatorIos.js +28 -49
  45. package/dist/native/setupServer.js +12 -15
  46. package/dist/native/terminalProgress.js +186 -147
  47. package/dist/native/utils.js +335 -54
  48. package/dist/otel.js +15 -9
  49. package/dist/scripts/build.js +314 -42
  50. package/dist/scripts/generateOfflineManifest.js +11 -1
  51. package/dist/scripts/loadEnvironmentVariables.js +24 -2
  52. package/dist/scripts/preServerInit.js +32 -10
  53. package/dist/scripts/preflight.js +115 -11
  54. package/dist/scripts/scriptUtils.js +34 -0
  55. package/dist/scripts/serve.js +65 -9
  56. package/dist/scripts/start.js +58 -21
  57. package/dist/server/expressServer.js +167 -32
  58. package/dist/server/renderer/document/Body.d.ts +0 -11
  59. package/dist/server/renderer/document/Body.jsx +0 -10
  60. package/dist/server/renderer/document/Head.d.ts +0 -13
  61. package/dist/server/renderer/document/Head.jsx +0 -12
  62. package/dist/server/renderer/handler.jsx +87 -11
  63. package/dist/server/utils/validator.js +14 -11
  64. package/dist/vite/manifest-categorization-plugin.js +7 -5
  65. package/dist/vite/resolveDevServerConfig.js +9 -2
  66. package/dist/vite/vite.config.client.js +19 -1
  67. package/dist/vite/vite.config.js +96 -19
  68. package/dist/vite/vite.config.server.js +21 -0
  69. package/dist/web-router/components/MetaTag.jsx +20 -14
  70. package/dist/web-router/components/MetaTag.test.jsx +108 -32
  71. package/dist/web-router/components/RouterDataProvider.d.ts +25 -1
  72. package/dist/web-router/components/RouterDataProvider.jsx +35 -1
  73. package/dist/web-router/components/RouterDataProvider.test.jsx +44 -0
  74. package/dist/web-router/context.d.ts +15 -1
  75. package/dist/web-router/context.jsx +11 -0
  76. package/dist/webmcp/WebMcpProvider.jsx +234 -0
  77. package/dist/webmcp/declarative.js +207 -0
  78. package/dist/webmcp/declarative.test.js +122 -0
  79. package/dist/webmcp/errors.js +59 -0
  80. package/dist/webmcp/errors.test.js +47 -0
  81. package/dist/webmcp/index.js +40 -0
  82. package/dist/webmcp/native.js +37 -0
  83. package/dist/webmcp/native.test.js +41 -0
  84. package/dist/webmcp/pageInfo.js +119 -0
  85. package/dist/webmcp/pageInfo.test.js +85 -0
  86. package/dist/webmcp/registry.dom.test.js +175 -0
  87. package/dist/webmcp/registry.js +540 -0
  88. package/dist/webmcp/registry.test.js +228 -0
  89. package/dist/webmcp/routeContext.js +11 -0
  90. package/dist/webmcp/shim-entry.js +8 -0
  91. package/dist/webmcp/shim.js +149 -0
  92. package/dist/webmcp/useTool.js +124 -0
  93. package/package.json +4 -7
  94. package/test/cli/buildErrors.test.ts +173 -0
  95. package/test/cli/buildWarnings.test.cjs +62 -0
  96. package/test/cli/devOutput.test.ts +549 -0
  97. package/test/cli/diagnostic.test.ts +434 -0
  98. package/test/cli/output.test.cjs +90 -0
  99. package/test/cli/spinner.test.ts +177 -0
  100. package/test/cli/theme.test.ts +163 -0
  101. package/test/native/redactSecrets.test.ts +72 -0
  102. package/test/native/utils.test.ts +532 -0
  103. package/test/scripts/build.test.ts +275 -0
  104. package/test/scripts/preflight.test.ts +100 -2
  105. package/test/scripts/serve.test.ts +102 -0
  106. package/test/server/fixtures/template/src/js/routes/index.jsx +8 -0
  107. package/test/server/handler.test.ts +17 -0
  108. package/test/server/logger.test.ts +185 -0
  109. package/test/server/manifestCache.test.ts +13 -0
  110. package/test/server/otel.test.ts +556 -0
  111. package/test/server/validator.test.ts +19 -8
  112. package/vitest.config.ts +4 -0
  113. package/dist/scripts/checkVersion.js +0 -3
  114. package/dist/scripts/cleanCache.js +0 -30
@@ -1,12 +1,29 @@
1
- import { readFileSync } from "fs"
1
+ import { readFileSync, writeFileSync } from "fs"
2
2
  import path from "path"
3
+ import { createRequire } from "module"
3
4
  import {
4
5
  validateConfigFile,
5
6
  validatePackageJson,
6
7
  validateModuleAlias,
7
8
  } from "../server/utils/validator.js"
8
9
  import { formatError, createError, ERROR_CODES } from "../errors/index.js"
9
- import { resolveOutputMode } from "./scriptUtils.js"
10
+ import { resolveOutputMode, getDebugEnvInfo } from "./scriptUtils.js"
11
+
12
+ const requireCjs = createRequire(import.meta.url)
13
+ const { registryFailure } = requireCjs("../cli/diagnostic.js")
14
+ const { GUTTER, glyph, t } = requireCjs("../cli/theme.js")
15
+
16
+ /**
17
+ * A registry error as the CLI shows it: the diagnostic block by default, the
18
+ * registry's box under --verbose and --debug.
19
+ * @param {import("../errors/index.js").CatalystError} err
20
+ * @param {string} mode - "default" | "verbose" | "debug"
21
+ * @param {{scope?: string}} [options]
22
+ */
23
+ export function renderRegistryError(err, mode, { scope } = {}) {
24
+ if (mode === "default") return registryFailure(err, { scope })
25
+ return formatError(err, mode, mode === "debug" ? getDebugEnvInfo() : undefined)
26
+ }
10
27
 
11
28
  // Static preflight — the file-based checks a developer's misconfiguration
12
29
  // would trip. Runs in the parent CLI process (start.js / serve.js / build.js)
@@ -47,12 +64,19 @@ export function runStaticPreflight(appDir = process.env.PWD || process.cwd()) {
47
64
  const cfgPath = path.join(appDir, "config", "config.json")
48
65
  const cfg = readJson(cfgPath)
49
66
  if (cfg.error) {
67
+ // A file that exists but does not parse is not "missing": the default
68
+ // message and its "create the file" advice would send people the
69
+ // wrong way.
70
+ const unparsable = cfg.error.code !== "ENOENT"
50
71
  failures.push(
51
72
  createError(ERROR_CODES.PREFLIGHT_CONFIG_MISSING, {
52
- details:
53
- cfg.error.code === "ENOENT"
54
- ? `config/config.json not found at ${cfgPath}`
55
- : `config/config.json could not be parsed: ${cfg.error.message}`,
73
+ details: unparsable
74
+ ? `config/config.json could not be parsed: ${cfg.error.message}`
75
+ : `config/config.json not found at ${cfgPath}`,
76
+ ...(unparsable && {
77
+ message: "config/config.json is not valid JSON",
78
+ suggestedAction: "Fix the JSON syntax in config/config.json at the position shown.",
79
+ }),
56
80
  })
57
81
  )
58
82
  } else {
@@ -101,11 +125,91 @@ export function runStaticPreflightOrExit(appDir = process.env.PWD || process.cwd
101
125
  if (failures.length === 0) return
102
126
 
103
127
  for (const err of failures) {
104
- console.error(formatError(err, mode))
128
+ console.error(renderRegistryError(err, mode, { scope: "preflight" }))
129
+ }
130
+ // One block speaks for itself; a count only helps when there are several.
131
+ if (failures.length > 1) {
132
+ console.error(`${GUTTER}${t.dim(`${failures.length} problems found. Fix them and re-run.`)}\n`)
105
133
  }
106
- console.error(
107
- `\nPreflight failed with ${failures.length} error${failures.length === 1 ? "" : "s"}. ` +
108
- `Fix the above and re-run.`
109
- )
110
134
  process.exit(1)
111
135
  }
136
+
137
+ // Records the config values a build was made with, so `serve` can refuse a
138
+ // build that no longer matches config/config.json (issue #251).
139
+ const BUILD_INFO_FILE = ".catalyst-build.json"
140
+
141
+ /**
142
+ * Config values Vite inlines into the bundles at build time (see
143
+ * getClientEnvVariables in vite.config.js). KEY=value CLI arguments win over
144
+ * config.json, and objects are stored as JSON, as in loadEnvironmentVariables.
145
+ * @param {Record<string, unknown>} config - parsed config/config.json
146
+ * @param {Record<string, string>} [args] - KEY=value CLI arguments
147
+ */
148
+ export function getInlinedConfig(config, args = {}) {
149
+ const values = { ...config, ...args }
150
+ let clientVars = values.CLIENT_ENV_VARIABLES ?? []
151
+ if (typeof clientVars === "string") clientVars = JSON.parse(clientVars)
152
+ const keys = ["PUBLIC_STATIC_ASSET_URL", "PUBLIC_STATIC_ASSET_PATH", ...clientVars]
153
+ return Object.fromEntries(
154
+ keys.map((key) => [key, typeof values[key] === "object" ? JSON.stringify(values[key]) : values[key]])
155
+ )
156
+ }
157
+
158
+ function buildInfoPath(appDir, config, args) {
159
+ return path.join(appDir, args.BUILD_OUTPUT_PATH || config.BUILD_OUTPUT_PATH || "build", BUILD_INFO_FILE)
160
+ }
161
+
162
+ /**
163
+ * @param {string} appDir
164
+ * @param {Record<string, unknown>} config
165
+ * @param {Record<string, string>} [args]
166
+ */
167
+ export function writeBuildInfo(appDir, config, args = {}) {
168
+ writeFileSync(
169
+ buildInfoPath(appDir, config, args),
170
+ JSON.stringify(getInlinedConfig(config, args), null, 4)
171
+ )
172
+ }
173
+
174
+ /**
175
+ * Compare the values a build was made with against the current config.
176
+ * Anything that stops the comparison (a missing or unreadable build info
177
+ * file, e.g. from an older catalyst-core, an unparseable CLIENT_ENV_VARIABLES)
178
+ * only warns, so the build still serves.
179
+ * @param {string} [appDir]
180
+ * @param {Record<string, string>} [args]
181
+ * @returns {import("../errors/index.js").CatalystError | null}
182
+ */
183
+ export function checkBuildMatchesConfig(appDir = process.env.PWD || process.cwd(), args = {}) {
184
+ const skip = (reason) => {
185
+ console.warn(`${GUTTER}${t.warn(glyph.warn)} ${t.dim(`Skipping the build/config check: ${reason}`)}`)
186
+ return null
187
+ }
188
+
189
+ // runStaticPreflightOrExit has already checked config.json.
190
+ const config = JSON.parse(readFileSync(path.join(appDir, "config", "config.json"), "utf8"))
191
+ const infoPath = buildInfoPath(appDir, config, args)
192
+ const info = readJson(infoPath)
193
+ if (info.error || !info.value || typeof info.value !== "object" || Array.isArray(info.value)) {
194
+ return skip(`${infoPath} is missing or invalid. Rebuild to enable it.`)
195
+ }
196
+
197
+ let current
198
+ try {
199
+ current = getInlinedConfig(config, args)
200
+ } catch (e) {
201
+ return skip(`could not read CLIENT_ENV_VARIABLES: ${e.message}`)
202
+ }
203
+
204
+ const keys = new Set([...Object.keys(info.value), ...Object.keys(current)])
205
+ const changed = [...keys].filter((key) => info.value[key] !== current[key])
206
+ if (changed.length === 0) return null
207
+
208
+ const show = (value) => (value === undefined ? "unset" : JSON.stringify(value))
209
+ const lines = changed.map(
210
+ (key) => `${key}: built with ${show(info.value[key])}, now ${show(current[key])}`
211
+ )
212
+ return createError(ERROR_CODES.PREFLIGHT_BUILD_STALE, {
213
+ details: `These values were inlined into the build and differ from config/config.json:\n ${lines.join("\n ")}`,
214
+ })
215
+ }
@@ -2,6 +2,7 @@ import fs from "fs"
2
2
  import path from "path"
3
3
  import { fileURLToPath } from "url"
4
4
  import util from "node:util"
5
+ import { pathToFileURL } from "url"
5
6
  import pkg from "ansis"
6
7
  const { gray, cyan } = pkg
7
8
 
@@ -49,6 +50,39 @@ export const printBundleInformation = () => {
49
50
  })
50
51
  }
51
52
 
53
+ /**
54
+ * Propagate a spawnSync result to this process's exit code.
55
+ *
56
+ * Without this the wrapper always exits 0, so a server that failed to boot
57
+ * still reads as a successful run to CI and to shell `&&` chains.
58
+ *
59
+ * @param {import('child_process').SpawnSyncReturns<Buffer>} result
60
+ * @param {string} label - what was being started, for the error message
61
+ */
62
+
63
+ /**
64
+ * A `--import` argument that registers a module loader.
65
+ *
66
+ * `--loader` is deprecated and makes Node print a four-line
67
+ * ExperimentalWarning to stderr for every process that uses it -- which meant
68
+ * 18 lines of warnings in a single `catalyst build`, since the flag is passed
69
+ * to both bundles and the manifest step. register() is the supported form and
70
+ * is silent.
71
+ *
72
+ * @param {string} loaderPath - absolute path to the loader module
73
+ */
74
+ export function loaderImportArg(loaderPath) {
75
+ const url = pathToFileURL(loaderPath).href
76
+ return (
77
+ `data:text/javascript,` +
78
+ encodeURIComponent(
79
+ `import { register } from "node:module";` +
80
+ `import { pathToFileURL } from "node:url";` +
81
+ `register(${JSON.stringify(url)}, pathToFileURL("./"));`
82
+ )
83
+ )
84
+ }
85
+
52
86
  export function arrayToObject(array) {
53
87
  const obj = {}
54
88
  array.forEach((item) => {
@@ -1,20 +1,33 @@
1
1
  import path from "path"
2
- import { spawnSync } from "child_process"
3
- import { arrayToObject, resolveOutputMode } from "./scriptUtils.js"
4
- import { runStaticPreflightOrExit } from "./preflight.js"
2
+ import { spawn } from "child_process"
3
+ import { arrayToObject, loaderImportArg, resolveOutputMode } from "./scriptUtils.js"
4
+ import { runStaticPreflightOrExit, checkBuildMatchesConfig, renderRegistryError } from "./preflight.js"
5
5
  import { fileURLToPath } from "url"
6
6
  import { dirname } from "path"
7
- import { readFileSync } from "fs"
7
+ import { existsSync, readFileSync } from "fs"
8
+ import { createRequire } from "module"
8
9
 
9
10
  const __filename = fileURLToPath(import.meta.url)
10
11
  const __dirname = dirname(__filename)
12
+
13
+ const requireCjs = createRequire(import.meta.url)
14
+ const { diagnostic } = requireCjs("../cli/diagnostic.js")
15
+ const { superviseServer, ensurePortFree } = requireCjs("../cli/devOutput.js")
16
+ const { loadAppConfig } = requireCjs("../cli/appConfig.js")
17
+
11
18
  const loaderPath = path.resolve(__dirname, "../../dist/vite/node-loader.mjs")
12
19
  const preInitPath = path.resolve(__dirname, "preServerInit.js")
13
20
 
14
21
  /**
15
- * @description - starts the application in production mode
22
+ * @description - serves the production build
23
+ *
24
+ * Output is inherited, not piped through the `start` formatter. Production
25
+ * output is read by log shippers (pm2, Docker, Datadog) one JSON line at a
26
+ * time, so every byte the app writes must arrive unaltered and on the stream
27
+ * it was written to. CATALYST_WRAPPED stays unset, so the server prints its
28
+ * banner plainly with no fences to consume.
16
29
  */
17
- function startProd() {
30
+ async function startProd() {
18
31
  // Fail fast on a misconfigured app before spawning the production server.
19
32
  runStaticPreflightOrExit()
20
33
 
@@ -27,17 +40,58 @@ function startProd() {
27
40
  const packageJson = JSON.parse(readFileSync(path.join(process.env.PWD, "package.json"), "utf-8"))
28
41
  const { name } = packageJson
29
42
 
30
- console.log("🚀 Starting production server...")
43
+ // Serving a build that was never made produces a stack trace from deep
44
+ // inside the server. Say the useful thing instead.
45
+ // From config.json, not the env: config values are promoted to env only
46
+ // inside the child, so reading env here always saw the default.
47
+ const appConfig = loadAppConfig(process.env.PWD || process.cwd())
48
+ const buildDir = path.join(
49
+ process.env.PWD,
50
+ appConfig.BUILD_OUTPUT_PATH || process.env.BUILD_OUTPUT_PATH || "build"
51
+ )
52
+ if (!existsSync(buildDir)) {
53
+ process.stderr.write(
54
+ diagnostic({
55
+ message: `No production build in ${path.relative(process.env.PWD, buildDir) || buildDir}/`,
56
+ scope: "server",
57
+ hint: "Run catalyst build first, then catalyst serve.",
58
+ })
59
+ )
60
+ process.exit(1)
61
+ }
62
+
63
+ // After the missing-build check, so a missing build gets that message
64
+ // rather than a "skipping the check" warning first.
65
+ const staleBuild = checkBuildMatchesConfig(undefined, argumentsObject)
66
+ if (staleBuild) {
67
+ process.stderr.write(renderRegistryError(staleBuild, outputMode, { scope: "server" }))
68
+ process.exit(1)
69
+ }
70
+
71
+ await ensurePortFree({
72
+ port: argumentsObject.NODE_SERVER_PORT || appConfig.NODE_SERVER_PORT,
73
+ host: argumentsObject.NODE_SERVER_HOSTNAME || appConfig.NODE_SERVER_HOSTNAME,
74
+ scope: "server",
75
+ })
31
76
 
32
77
  const inspectArgs = commandLineArguments.includes("--inspect") ? ["--inspect"] : []
33
- spawnSync(
78
+ const child = spawn(
34
79
  "node",
35
- [...inspectArgs, "--import", preInitPath, "--loader", loaderPath, "./dist/server/expressServer.js"],
80
+ [
81
+ ...inspectArgs,
82
+ "--import",
83
+ preInitPath,
84
+ "--import",
85
+ loaderImportArg(loaderPath),
86
+ "./dist/server/expressServer.js",
87
+ ],
36
88
  {
37
89
  cwd: dirname,
38
90
  stdio: "inherit",
39
91
  env: {
40
92
  ...process.env,
93
+ // When the command was run, so "Ready in" counts the whole wait.
94
+ CATALYST_STARTED_AT: String(Math.round(performance.timeOrigin)),
41
95
  src_path: process.env.PWD,
42
96
  NODE_ENV: "production",
43
97
  APPLICATION: name || "catalyst_app",
@@ -52,6 +106,8 @@ function startProd() {
52
106
  },
53
107
  }
54
108
  )
109
+
110
+ superviseServer(child, { scope: "server", label: "production server", passthrough: true })
55
111
  }
56
112
 
57
113
  startProd()
@@ -1,20 +1,32 @@
1
1
  import path from "path"
2
- import { spawnSync } from "child_process"
3
- import { arrayToObject, resolveOutputMode } from "./scriptUtils.js"
2
+ import { spawn } from "child_process"
3
+ import { arrayToObject, loaderImportArg, resolveOutputMode } from "./scriptUtils.js"
4
4
  import { runStaticPreflightOrExit } from "./preflight.js"
5
5
  import { fileURLToPath } from "url"
6
6
  import { dirname } from "path"
7
7
  import { readFileSync } from "fs"
8
+ import { createRequire } from "module"
8
9
 
9
10
  const __filename = fileURLToPath(import.meta.url)
10
11
  const __dirname = dirname(__filename)
12
+
13
+ const requireCjs = createRequire(import.meta.url)
14
+ const { superviseServer, ensurePortFree } = requireCjs("../cli/devOutput.js")
15
+ const { loadAppConfig } = requireCjs("../cli/appConfig.js")
16
+
11
17
  const loaderPath = path.resolve(__dirname, "../../dist/vite/node-loader.mjs")
12
18
  const preInitPath = path.resolve(__dirname, "preServerInit.js")
13
19
 
14
20
  /**
15
- * @description - starts webpack dev server and node server.
21
+ * @description - starts the dev server
22
+ *
23
+ * stdio is piped rather than inherited so this command owns every line it
24
+ * prints. The server child speaks in three unrelated voices -- Node process
25
+ * warnings, Vite notices, and our own banner -- and inheriting stdio meant
26
+ * they interleaved raw, with the banner arriving last and reading as an
27
+ * afterthought.
16
28
  */
17
- function start() {
29
+ async function start() {
18
30
  // Fail fast on a misconfigured app (missing/invalid config.json, package.json,
19
31
  // or moduleAliases) with a coded, doc-linked error before spawning the server.
20
32
  runStaticPreflightOrExit()
@@ -28,24 +40,49 @@ function start() {
28
40
  const packageJson = JSON.parse(readFileSync(path.join(process.env.PWD, "package.json"), "utf-8"))
29
41
  const { name } = packageJson
30
42
 
31
- spawnSync("node", ["--import", preInitPath, "--loader", loaderPath, "./dist/server/expressServer.js"], {
32
- cwd: dirname,
33
- stdio: "inherit",
34
- env: {
35
- ...process.env,
36
- src_path: process.env.PWD,
37
- NODE_ENV: "development",
38
- APPLICATION: name || "catalyst_app",
39
- CATALYST_OUTPUT_MODE: outputMode,
40
- ...argumentsObject,
41
- filterKeys: JSON.stringify([
42
- "src_path",
43
- "NODE_ENV",
44
- "APPLICATION",
45
- ...Object.keys(argumentsObject),
46
- ]),
47
- },
43
+ const appConfig = loadAppConfig(process.env.PWD || process.cwd())
44
+ await ensurePortFree({
45
+ port: argumentsObject.NODE_SERVER_PORT || appConfig.NODE_SERVER_PORT,
46
+ host: argumentsObject.NODE_SERVER_HOSTNAME || appConfig.NODE_SERVER_HOSTNAME,
47
+ scope: "dev server",
48
48
  })
49
+
50
+ const child = spawn(
51
+ "node",
52
+ ["--import", preInitPath, "--import", loaderImportArg(loaderPath), "./dist/server/expressServer.js"],
53
+ {
54
+ cwd: dirname,
55
+ stdio: ["inherit", "pipe", "pipe"],
56
+ env: {
57
+ ...process.env,
58
+ // Piping the child's stdout makes it a non-TTY, so colour
59
+ // libraries in the child disable themselves and the banner
60
+ // arrives grey. Pass our own terminal's answer down, so the
61
+ // child colours only when there is a real terminal to colour
62
+ // for -- and a redirect to a file still comes out plain.
63
+ ...(process.stdout.isTTY && !process.env.NO_COLOR ? { FORCE_COLOR: "1" } : {}),
64
+ // Tells the server its output is being consumed, so it may
65
+ // fence the banner. Unset when run directly.
66
+ CATALYST_WRAPPED: "1",
67
+ // When the command was run, so "Ready in" counts the whole wait.
68
+ CATALYST_STARTED_AT: String(Math.round(performance.timeOrigin)),
69
+ src_path: process.env.PWD,
70
+ NODE_ENV: "development",
71
+ APPLICATION: name || "catalyst_app",
72
+ CATALYST_OUTPUT_MODE: outputMode,
73
+ ...argumentsObject,
74
+ filterKeys: JSON.stringify([
75
+ "CATALYST_WRAPPED",
76
+ "src_path",
77
+ "NODE_ENV",
78
+ "APPLICATION",
79
+ ...Object.keys(argumentsObject),
80
+ ]),
81
+ },
82
+ }
83
+ )
84
+
85
+ superviseServer(child, { scope: "dev server", label: "development server" })
49
86
  }
50
87
 
51
88
  start()
@@ -5,18 +5,106 @@ import compression from "compression";
5
5
  import cookieParser from "cookie-parser";
6
6
  import expressStaticGzip from "express-static-gzip";
7
7
  import { createServer as createViteServer } from "vite";
8
- import util from "node:util";
9
- import pc from "ansis";
8
+ import os from "node:os";
10
9
  import fs from "fs";
11
- const {
12
- cyan,
13
- yellow,
14
- green
15
- } = pc;
16
10
  import { toMountPathPrefix } from "../vite/resolveDevServerConfig.js";
17
11
  import { validateMiddleware, handleError, safeCall } from "./utils/validator.js";
18
12
  import { botDetectionMiddleware } from "./utils/botDetectionMiddleware.js";
19
13
  import { cjsRequire } from "./utils/cjsRequire.js";
14
+ const {
15
+ header,
16
+ row,
17
+ glyph,
18
+ t,
19
+ GUTTER,
20
+ duration
21
+ } = cjsRequire("../../cli/theme.js");
22
+ const {
23
+ diagnostic,
24
+ runtimeFailure
25
+ } = cjsRequire("../../cli/diagnostic.js");
26
+ const {
27
+ hintFor
28
+ } = cjsRequire("../../cli/hints.js");
29
+
30
+ // "Ready in" should measure what the person waited for: from the moment they
31
+ // ran the command, not from when this child process happened to load. The CLI
32
+ // stamps that moment; run directly, module load is the best we have.
33
+ const serverStartedAt = Number(process.env.CATALYST_STARTED_AT) || Date.now();
34
+ const outputMode = process.env.CATALYST_OUTPUT_MODE || "default";
35
+ const verbose = outputMode === "verbose" || outputMode === "debug";
36
+
37
+ // Set in development once Vite is up: maps a stack from Vite's transformed SSR
38
+ // modules back to the source lines the user wrote.
39
+ let fixStacktrace = null;
40
+ const stackFixed = new WeakSet();
41
+
42
+ // Reloading a broken page re-throws the same error on every request. After the
43
+ // first full report, a repeat gets one line until something different fails.
44
+ let lastReport = null;
45
+
46
+ /**
47
+ * Report a runtime error in the house style. Exposed on globalThis because the
48
+ * SSR handler is bundled into the app's build and cannot import this module's
49
+ * CLI helpers by path; it calls this when present.
50
+ */
51
+ function reportRuntimeError({
52
+ error,
53
+ code,
54
+ docs,
55
+ scope
56
+ }) {
57
+ if (fixStacktrace && error && typeof error === "object" && !stackFixed.has(error)) {
58
+ stackFixed.add(error);
59
+ try {
60
+ fixStacktrace(error);
61
+ } catch {
62
+ // A stack we cannot map is still worth printing as-is.
63
+ }
64
+ }
65
+ const fingerprint = `${code}|${error?.message ?? String(error)}|${String(error?.stack || "").split("\n")[1] || ""}`;
66
+ if (fingerprint === lastReport && outputMode === "default") {
67
+ process.stderr.write(`${GUTTER}${t.bad(glyph.fail)} ${t.dim(`Same error again${scope ? ` ${scope}` : ""}`)}\n`);
68
+ return;
69
+ }
70
+ lastReport = fingerprint;
71
+ process.stderr.write(runtimeFailure({
72
+ error,
73
+ code,
74
+ docs,
75
+ scope,
76
+ mode: outputMode,
77
+ env: outputMode === "debug" ? {
78
+ node: process.version,
79
+ platform: process.platform
80
+ } : undefined,
81
+ cwd: process.env.src_path || process.cwd()
82
+ }));
83
+ }
84
+ ;
85
+ globalThis.__catalystReportRuntimeError = reportRuntimeError;
86
+
87
+ // Invisible fences around the banner block. The wrapper strips these; if the
88
+ // server is run directly they are two blank-looking lines.
89
+ const {
90
+ BANNER_START,
91
+ BANNER_END,
92
+ NEXT_STEP
93
+ } = cjsRequire("../../cli/devOutput.js");
94
+
95
+ /** First non-internal IPv4 address, so the banner can offer a LAN URL. */
96
+ function lanAddress() {
97
+ try {
98
+ for (const interfaces of Object.values(os.networkInterfaces())) {
99
+ for (const entry of interfaces || []) {
100
+ if (entry.family === "IPv4" && !entry.internal) return entry.address;
101
+ }
102
+ }
103
+ } catch (error) {
104
+ // Not worth failing a server start over.
105
+ }
106
+ return null;
107
+ }
20
108
  const {
21
109
  addMiddlewares
22
110
  } = await import(path.join(process.env.src_path, "server/server.js"));
@@ -31,7 +119,8 @@ function mountAIRouter(app) {
31
119
  } catch (resolveErr) {
32
120
  if (resolveErr.code !== "MODULE_NOT_FOUND") {
33
121
  console.error("[catalyst-core/ai] Unexpected error resolving catalyst-ai:", resolveErr);
34
- } else {
122
+ } else if (verbose) {
123
+ // Not installing an optional package is not news on every start.
35
124
  console.debug("[catalyst-core/ai] catalyst-ai not installed — AI routes unavailable");
36
125
  }
37
126
  return;
@@ -54,7 +143,6 @@ function mountAIRouter(app) {
54
143
  return;
55
144
  }
56
145
  const aiBasePath = aiConfig.basePath || "/ai";
57
- console.log(`[catalyst-core/ai] mounting AI router at ${aiBasePath}`);
58
146
  app.use(aiBasePath, aiRouter);
59
147
  } catch (mountErr) {
60
148
  console.error("[catalyst-core/ai] Failed to mount AI router:", mountErr);
@@ -97,15 +185,34 @@ function safeStringify(err) {
97
185
  }
98
186
  }
99
187
  process.on("uncaughtException", (err, origin) => {
100
- console.log(process.stderr.fd);
101
- console.log(`Caught exception: ${err}\n` + `Exception origin: ${origin}`);
188
+ // A server that cannot bind its port is dead -- swallowing that left the
189
+ // process alive but useless, so `catalyst start` hung with a blank screen.
190
+ // Report it the way every other failure is reported, then exit.
191
+ const code = err?.code;
192
+ if (code === "EADDRINUSE" || code === "EACCES") {
193
+ const known = hintFor(err.message);
194
+ process.stderr.write(diagnostic({
195
+ message: err.message,
196
+ scope: "dev server",
197
+ hint: known?.hint,
198
+ docs: known?.docs
199
+ }));
200
+ process.exit(1);
201
+ }
202
+ reportRuntimeError({
203
+ error: err,
204
+ scope: origin === "unhandledRejection" ? "unhandled rejection" : "uncaught exception"
205
+ });
102
206
  });
103
- process.on("uncaughtExceptionMonitor", (err, origin) => {
104
- console.log(err, origin);
207
+ process.on("unhandledRejection", err => {
208
+ // A rejection with a non-Error reason still deserves a readable line.
209
+ const error = err instanceof Error ? err : new Error(safeStringify(err) || String(err));
210
+ reportRuntimeError({
211
+ error,
212
+ scope: "unhandled rejection"
213
+ });
105
214
  });
106
- process.on("unhandledRejection", err => console.log("unhandledRejection in Catalyst", err?.stack || safeStringify(err)));
107
215
  process.on("SIGINT", function () {
108
- console.log("SIGINT");
109
216
  process.exit(0);
110
217
  });
111
218
  process.on("message", function (msg) {
@@ -201,6 +308,7 @@ async function createServer() {
201
308
  appType: "custom",
202
309
  root: process.env.src_path
203
310
  });
311
+ fixStacktrace = error => vite.ssrFixStacktrace(error);
204
312
 
205
313
  // Expose Vite's resolved base to the SSR renderer so the URLs it injects
206
314
  // (client entry, react-refresh preamble) match the path Vite serves under.
@@ -241,11 +349,13 @@ async function createServer() {
241
349
  res.status(500).send("Error loading renderer");
242
350
  }
243
351
  } catch (err) {
244
- console.error("SSR Error:", err);
245
- if (vite) {
246
- vite.ssrFixStacktrace(err);
247
- }
248
- res.status(500).send("Internal Server Error");
352
+ // Reached when the renderer itself fails to load or throws before
353
+ // its own error handling -- e.g. a syntax error in an app module.
354
+ reportRuntimeError({
355
+ error: err,
356
+ scope: `${req.method} ${req.originalUrl}`
357
+ });
358
+ if (!res.headersSent) res.status(500).send("Internal Server Error");
249
359
  }
250
360
  });
251
361
  app.listen({
@@ -253,26 +363,51 @@ async function createServer() {
253
363
  host
254
364
  }, error => {
255
365
  const {
256
- APPLICATION,
257
366
  NODE_SERVER_HOSTNAME,
258
367
  NODE_SERVER_PORT
259
368
  } = process.env;
369
+ const isDev = process.env.NODE_ENV === "development";
260
370
  if (error) {
261
- console.log("An error occured while starting the Application server : ", error);
371
+ const known = hintFor(error.message);
372
+ process.stderr.write(diagnostic({
373
+ message: `Could not start the server: ${error.message}`,
374
+ scope: isDev ? "dev server" : "server",
375
+ hint: known?.hint,
376
+ docs: known?.docs
377
+ }));
262
378
  safeCall(onServerError);
263
379
  return;
264
380
  }
265
- if (process.env.NODE_ENV === "development") console.log(green("Compiled successfully!"));
266
- console.log(`\nYou can now view ${APPLICATION} in the browser.\n`);
267
- console.log(util.format("\tLocal:".padEnd(8), cyan(`http://${NODE_SERVER_HOSTNAME}:${NODE_SERVER_PORT}`)));
268
- if (process.env.NODE_ENV === "development") {
269
- console.log("\nNote that the development build is not optimized.");
270
- console.log("To create a production build, use " + cyan("npm run build"));
271
- } else {
272
- console.log(green(`\nProduction server running in ${isProduction ? "production" : "development"} mode`));
381
+
382
+ // Reached from the listen callback, so this means a port was bound --
383
+ // it says nothing about whether anything compiled. Claiming a
384
+ // successful compile here was an outright lie when the bundle failed.
385
+ const elapsed = Date.now() - serverStartedAt;
386
+
387
+ // Fence the banner so the wrapper can forward it verbatim instead of
388
+ // guessing from its text -- colour codes made pattern-matching the
389
+ // lines unreliable, and the guesswork mis-filed the rule and the URL.
390
+ const wrapped = process.env.CATALYST_WRAPPED === "1";
391
+ if (wrapped) process.stdout.write(`${BANNER_START}\n`);
392
+ process.stdout.write(header(`catalyst ${isDev ? "start" : "serve"}`, isDev ? "development" : "production"));
393
+ console.log(`${GUTTER}${t.ok(glyph.done)} Ready in ${t.bold(duration(elapsed))}\n`);
394
+
395
+ // An app can bind to a LAN address rather than localhost, in which case
396
+ // "Local" and "Network" would print the same URL twice. Show the bound
397
+ // address as Local, and only offer a Network line when it differs.
398
+ const boundHost = NODE_SERVER_HOSTNAME || "localhost";
399
+ console.log(row("Local", t.accent(`http://${boundHost}:${NODE_SERVER_PORT}`)));
400
+ const lan = lanAddress();
401
+ const reachableEverywhere = boundHost === "0.0.0.0" || boundHost === "::";
402
+ if (lan && lan !== boundHost && (reachableEverywhere || boundHost === "localhost")) {
403
+ console.log(row("Network", t.dim(`http://${lan}:${NODE_SERVER_PORT}`)));
273
404
  }
274
- console.log("\nFind out more about deployment here:");
275
- console.log(yellow("\n https://catalyst.1mg.com/public_docs/content/deployment\n"));
405
+
406
+ // The next-step line is the one thing to act on, so it is handed to the
407
+ // wrapper fenced rather than printed here -- the wrapper prints it after
408
+ // any notices, which would otherwise bury it.
409
+ process.stdout.write(`${wrapped ? NEXT_STEP : ""}${GUTTER}${t.dim(isDev ? "Watching for changes. " : "Serving ")}` + `${isDev ? "" : t.accent(`${process.env.BUILD_OUTPUT_PATH || "build"}/`)}` + `${t.dim(isDev ? "Press Ctrl+C to stop." : ". Press Ctrl+C to stop.")}\n`);
410
+ if (wrapped) process.stdout.write(`${BANNER_END}\n`);
276
411
  });
277
412
  }
278
413
  createServer().catch(err => {