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,13 @@
1
1
  import path from "path"
2
2
  import { spawn } from "child_process"
3
- import { arrayToObject, resolveOutputMode, getDebugEnvInfo } from "./scriptUtils.js"
3
+ import { arrayToObject, loaderImportArg, resolveOutputMode, getDebugEnvInfo } from "./scriptUtils.js"
4
4
  import { fileURLToPath } from "url"
5
5
  import { dirname } from "path"
6
- import { readFileSync, existsSync, rmSync } from "fs"
6
+ import { readFileSync, readdirSync, existsSync, rmSync } from "fs"
7
+ import { gzipSync } from "zlib"
7
8
  import { createRequire } from "module"
8
- import { wrapForeignError, formatError } from "../errors/index.js"
9
- import { runStaticPreflightOrExit } from "./preflight.js"
9
+ import { getDocUrl, ERROR_CODES } from "../errors/index.js"
10
+ import { runStaticPreflightOrExit, writeBuildInfo } from "./preflight.js"
10
11
 
11
12
  // Fail fast on a misconfigured app (missing/invalid config.json, package.json,
12
13
  // or moduleAliases) with a coded, doc-linked error — must run before the raw
@@ -16,9 +17,23 @@ runStaticPreflightOrExit()
16
17
 
17
18
  const __filename = fileURLToPath(import.meta.url)
18
19
  const __dirname = dirname(__filename)
20
+
21
+ // src/cli is CommonJS so src/native (also CJS) can share it; pull it in here
22
+ // rather than duplicating the visual language for the web commands.
23
+ const requireCjs = createRequire(import.meta.url)
24
+ const { header, step, glyph, t, GUTTER, duration, bytes } = requireCjs("../cli/theme.js")
25
+ const { diagnostic, verboseOutput } = requireCjs("../cli/diagnostic.js")
26
+ const { Spinner } = requireCjs("../cli/spinner.js")
27
+ const { loadAppConfig } = requireCjs("../cli/appConfig.js")
28
+ const { bundleWarnings } = requireCjs("../cli/buildWarnings.js")
29
+ const { extractBuildErrors, outputTail } = requireCjs("../cli/buildErrors.js")
30
+ const { hintFor } = requireCjs("../cli/hints.js")
19
31
  const loaderPath = path.resolve(__dirname, "../../dist/vite/node-loader.mjs")
20
- const configPath = path.join(process.env.PWD, "config/config.json")
21
- const configJSON = JSON.parse(readFileSync(configPath), "utf-8")
32
+
33
+ // A missing or malformed config used to throw a raw ENOENT/SyntaxError stack at
34
+ // module load, before any output at all. Note the "utf-8" was being passed as
35
+ // JSON.parse's reviver argument, not as the read encoding.
36
+ const configJSON = loadAppConfig(process.env.PWD || process.cwd())
22
37
 
23
38
  // Resolve vite's actual JS entry point rather than spawning the "vite" command name,
24
39
  // which on Windows only resolves via the npm-installed .cmd shim (i.e. requires a shell).
@@ -27,27 +42,220 @@ const require = createRequire(import.meta.url)
27
42
  const viteBinPath = path.join(path.dirname(require.resolve("vite/package.json")), "bin", "vite.js")
28
43
 
29
44
  /**
45
+ * A child's exit code, or 1 when there is not a usable one.
46
+ *
47
+ * Spawn failures carry a string `code` like "ENOENT", and process.exit throws
48
+ * ERR_INVALID_ARG_TYPE on anything that is not an integer -- which would
49
+ * replace the friendly error with an internal stack trace.
50
+ */
51
+ const MAX_CAPTURED_BYTES = 1024 * 1024
52
+
53
+ function exitCodeFrom(error) {
54
+ return Number.isInteger(error?.code) ? error.code : 1
55
+ }
56
+
57
+ /**
58
+ * Run one build step, capturing its output.
59
+ *
60
+ * Output is piped rather than inherited. With inherit the bundler wrote
61
+ * straight to the terminal at column 0 -- so two parallel bundles interleaved,
62
+ * the same error printed once per bundle, and the parent held nothing but an
63
+ * exit code. Holding the text is what makes it possible to find the real
64
+ * compiler error, dedupe it, and show it in the house style.
65
+ *
30
66
  * @param {string[]} args
31
67
  * @param {import('child_process').SpawnOptions} options
68
+ * @param {string} label - names this step, e.g. "client"
32
69
  */
33
- function runBuildStep(args, options) {
70
+ function runBuildStep(args, options, label = "Build step") {
34
71
  return new Promise((resolve, reject) => {
35
- const child = spawn(process.execPath, args, options)
72
+ const child = spawn(process.execPath, args, {
73
+ ...options,
74
+ stdio: ["inherit", "pipe", "pipe"],
75
+ })
76
+
77
+ // Only the tail is ever shown, so a pathological build cannot grow
78
+ // this without bound.
79
+ let output = ""
80
+ const keep = (chunk) => {
81
+ output += chunk.toString()
82
+ if (output.length > MAX_CAPTURED_BYTES) output = output.slice(-MAX_CAPTURED_BYTES)
83
+ }
84
+ child.stdout.on("data", keep)
85
+ child.stderr.on("data", keep)
86
+
36
87
  child.on("close", (code) => {
37
88
  if (code === 0) {
38
- resolve()
89
+ resolve({ label, output })
39
90
  } else {
40
- reject(Object.assign(new Error(`Build step failed with exit code ${code}`), { code }))
91
+ reject(
92
+ Object.assign(new Error(`${label} failed with exit code ${code}`), {
93
+ code,
94
+ label,
95
+ output,
96
+ })
97
+ )
41
98
  }
42
99
  })
43
100
  child.on("error", reject)
44
101
  })
45
102
  }
46
103
 
104
+ /**
105
+ * Report bundle failures once each, not once per bundle.
106
+ *
107
+ * One broken import fails both bundles, so the naive loop printed the same
108
+ * error twice with two identical hints. Errors are keyed on their cause, and
109
+ * the bundles they broke become a dim suffix rather than a second block.
110
+ */
111
+ function shortenPaths(text, cwd) {
112
+ return String(text).split(`${cwd}/`).join("")
113
+ }
114
+
115
+ /** A step's own last words, fenced under the diagnostic that names it. */
116
+ function fencedTail(output, lines = 8) {
117
+ const tail = outputTail(output || "", lines)
118
+ return tail
119
+ ? tail
120
+ .split("\n")
121
+ .map((line) => `${GUTTER}${t.dim(glyph.pipe)} ${t.dim(line)}`)
122
+ .join("\n") + "\n\n"
123
+ : ""
124
+ }
125
+
126
+ function reportBundleFailures(failures, cwd) {
127
+ // Every bundle failure is the registry's upstream wrapper: its code and
128
+ // docs head each block, as they did before the extracted-error view.
129
+ const code = ERROR_CODES.BUNDLE_UPSTREAM_ERROR
130
+ const docs = getDocUrl(code)
131
+ const byCause = new Map()
132
+ const unrecognised = []
133
+
134
+ for (const failure of failures) {
135
+ const found = extractBuildErrors(failure.output, { toolchain: "vite" })
136
+ if (found.length === 0) {
137
+ unrecognised.push(failure)
138
+ continue
139
+ }
140
+ for (const error of found) {
141
+ const key = `${error.file || ""}:${error.line || ""}:${error.message}`
142
+ const existing = byCause.get(key)
143
+ if (existing) existing.bundles.push(failure.label)
144
+ else byCause.set(key, { ...error, bundles: [failure.label] })
145
+ }
146
+ }
147
+
148
+ // Nothing recognised: fence the bundler's own words rather than inventing
149
+ // a summary that says less than they do. Rendered even when the other
150
+ // bundle's error was recognised, so neither failure goes unreported.
151
+ const unrecognisedReport = unrecognised
152
+ .map(
153
+ (failure) =>
154
+ diagnostic({
155
+ message: `The ${failure.label || "production"} bundle failed`,
156
+ code,
157
+ scope: "production build",
158
+ docs,
159
+ cwd,
160
+ }) + fencedTail(failure.output)
161
+ )
162
+ .join("")
163
+
164
+ // Each cause renders as the same diagnostic block every other failure
165
+ // uses: message, location, the source around it, and a hint when the
166
+ // cause is one we understand.
167
+ const recognisedReport = [...byCause.values()]
168
+ .map((cause) =>
169
+ diagnostic({
170
+ message: shortenPaths(cause.message, cwd),
171
+ code,
172
+ scope: `${cause.bundles.join(" + ")} bundle`,
173
+ file: cause.file ? path.resolve(cwd, cause.file.replace(/^(\.\.\/)+/, "")) : undefined,
174
+ line: cause.line,
175
+ // esbuild reports columns 0-based; the frame's caret is 1-based.
176
+ column: cause.column != null ? cause.column + 1 : undefined,
177
+ hint: hintFor(cause.message)?.hint,
178
+ docs,
179
+ cwd,
180
+ })
181
+ )
182
+ .join("")
183
+
184
+ return recognisedReport + unrecognisedReport
185
+ }
186
+
187
+ /**
188
+ * List what was actually produced, largest first, with gzip where we have it.
189
+ *
190
+ * Reads the directory rather than parsing the bundler's output: the files on
191
+ * disk are the truth, and this keeps working if the bundler changes format.
192
+ */
193
+ function printAssetTable(outputPath, label) {
194
+ const entries = []
195
+
196
+ const walk = (dir, prefix) => {
197
+ let items
198
+ try {
199
+ items = readdirSync(dir, { withFileTypes: true })
200
+ } catch (error) {
201
+ return
202
+ }
203
+ for (const item of items) {
204
+ // nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal - names come from readdirSync of our own build output.
205
+ const full = path.join(dir, item.name)
206
+ const rel = prefix ? `${prefix}/${item.name}` : item.name
207
+ if (item.isDirectory()) {
208
+ walk(full, rel)
209
+ } else if (/\.(js|css|mjs|cjs)$/.test(item.name)) {
210
+ try {
211
+ const raw = readFileSync(full)
212
+ entries.push({ name: rel, size: raw.length, gzip: gzipSync(raw).length })
213
+ } catch (error) {
214
+ // A file we cannot read is not worth failing a build over.
215
+ }
216
+ }
217
+ }
218
+ }
219
+
220
+ walk(outputPath, "")
221
+ if (entries.length === 0) return
222
+
223
+ // The server bundle never reaches a browser, so its per-file sizes are not
224
+ // something to optimise; one total line keeps it visible without crowding
225
+ // the table people actually read.
226
+ const server = entries.filter((entry) => entry.name.startsWith("server/"))
227
+ entries.splice(0, entries.length, ...entries.filter((entry) => !entry.name.startsWith("server/")))
228
+
229
+ entries.sort((a, b) => b.size - a.size)
230
+ const shown = entries.slice(0, 12)
231
+ const width = Math.max(...shown.map((e) => e.name.length))
232
+
233
+ console.log(`\n${GUTTER}${t.bold("Client")} ${t.dim(`${label}/`)}`)
234
+ for (const [index, entry] of shown.entries()) {
235
+ const isLast = index === shown.length - 1 && entries.length === shown.length
236
+ console.log(
237
+ `${GUTTER}${t.dim(isLast ? glyph.last : glyph.branch)} ${entry.name.padEnd(width + 2)}` +
238
+ `${bytes(entry.size).padStart(9)}` +
239
+ `${t.dim(` ${bytes(entry.gzip).padStart(8)} gzip`)}`
240
+ )
241
+ }
242
+ if (entries.length > shown.length) {
243
+ console.log(`${GUTTER}${t.dim(glyph.last)} ${t.dim(`and ${entries.length - shown.length} more`)}`)
244
+ }
245
+ if (server.length) {
246
+ const total = server.reduce((sum, entry) => sum + entry.size, 0)
247
+ const files = `${server.length} file${server.length === 1 ? "" : "s"}`
248
+ console.log(
249
+ `\n${GUTTER}${t.bold("Server")} ${t.dim(`${label}/server/`)} ${t.dim(`${files}, ${bytes(total)}`)}`
250
+ )
251
+ }
252
+ }
253
+
47
254
  /**
48
255
  * @description - builds the application for production
49
256
  */
50
257
  async function build() {
258
+ const startedAt = Date.now()
51
259
  const commandLineArguments = process.argv.slice(2)
52
260
  const argumentsObject = arrayToObject(commandLineArguments)
53
261
  const outputMode = resolveOutputMode(process.argv)
@@ -57,21 +265,26 @@ async function build() {
57
265
  const packageJson = JSON.parse(readFileSync(path.join(process.env.PWD, "package.json"), "utf-8"))
58
266
  const { name } = packageJson
59
267
 
268
+ process.stdout.write(header("catalyst build", "production"))
269
+
60
270
  const buildOutputPath = path.join(process.env.PWD, configJSON.BUILD_OUTPUT_PATH || "build")
61
271
  if (existsSync(buildOutputPath)) {
62
- console.log("🧹 Clearing previous build output...")
272
+ const cleaning = Date.now()
63
273
  rmSync(buildOutputPath, { recursive: true, force: true })
274
+ console.log(step("Cleaned previous output", Date.now() - cleaning))
64
275
  }
65
276
 
66
- console.log("🏗️ Building application for production...")
67
-
68
277
  const baseEnv = {
69
278
  ...process.env,
279
+ // The children are piped now, so their stdout is not a TTY and the
280
+ // bundler would drop colour. Forward our own terminal's answer, so a
281
+ // redirect to a file still comes out plain.
282
+ ...(process.stdout.isTTY && !process.env.NO_COLOR ? { FORCE_COLOR: "1" } : {}),
70
283
  src_path: process.env.PWD,
71
284
  NODE_ENV: "production",
72
285
  VITE_BUILD_MODE: "true",
73
286
  APPLICATION: name || "catalyst_app",
74
- NODE_OPTIONS: `--loader ${loaderPath}`,
287
+ NODE_OPTIONS: `--import ${loaderImportArg(loaderPath)}`,
75
288
  CATALYST_OUTPUT_MODE: outputMode,
76
289
  ...argumentsObject,
77
290
  filterKeys: JSON.stringify([
@@ -85,41 +298,100 @@ async function build() {
85
298
 
86
299
  const serverBuildArgs = [viteBinPath, "build", "--config", "./dist/vite/vite.config.server.js", "--ssr"]
87
300
  const clientBuildArgs = [viteBinPath, "build", "--config", "./dist/vite/vite.config.client.js"]
88
- const spawnBase = {
89
- cwd: dirname,
90
- stdio: "inherit",
301
+ const spawnBase = { cwd: dirname }
302
+
303
+ // A live row while the bundles build: a large app takes tens of seconds
304
+ // here, and a screen that stops changing reads as a hang. Both children
305
+ // are piped, so nothing else writes to the terminal meanwhile.
306
+ const bundling = new Spinner().start("Building client and server bundles")
307
+
308
+ // Both bundles build in parallel and their output is captured, so failures
309
+ // are reported once, attributed to the bundle that produced them.
310
+ // allSettled rather than all: if both bundles are broken, Promise.all would
311
+ // report only whichever rejected first and hide the other.
312
+ const results = await Promise.allSettled([
313
+ runBuildStep(
314
+ serverBuildArgs,
315
+ { ...spawnBase, env: { ...baseEnv, CATALYST_VITE_CACHE_ID: "ssr" } },
316
+ "server"
317
+ ),
318
+ runBuildStep(
319
+ clientBuildArgs,
320
+ { ...spawnBase, env: { ...baseEnv, CATALYST_VITE_CACHE_ID: "client" } },
321
+ "client"
322
+ ),
323
+ ])
324
+
325
+ const failures = results.filter((result) => result.status === "rejected").map((result) => result.reason)
326
+
327
+ if (failures.length > 0) {
328
+ bundling.stop("fail", "Build failed")
329
+ process.stderr.write(reportBundleFailures(failures, process.env.PWD))
330
+ // --verbose/--debug add the registry code and the bundler's full
331
+ // output under the extracted errors.
332
+ if (outputMode !== "default") {
333
+ process.stderr.write(
334
+ verboseOutput({
335
+ code: ERROR_CODES.BUNDLE_UPSTREAM_ERROR,
336
+ docs: getDocUrl(ERROR_CODES.BUNDLE_UPSTREAM_ERROR),
337
+ output: failures.map((failure) => `[${failure.label}]\n${failure.output}`).join("\n"),
338
+ mode: outputMode,
339
+ error: failures[0],
340
+ env: outputMode === "debug" ? getDebugEnvInfo() : undefined,
341
+ })
342
+ )
343
+ }
344
+ process.exit(exitCodeFrom(failures[0]))
91
345
  }
92
346
 
93
- console.log("🔧📦 Building server and client bundles in parallel...")
347
+ // Warnings that survived the config filters were captured and then thrown
348
+ // away on a green build, so a real one (a circular dependency, an oversized
349
+ // chunk) vanished silently. Surface them under the step that produced them.
350
+ const warnings = results
351
+ .filter((result) => result.status === "fulfilled")
352
+ .flatMap((result) => bundleWarnings(result.value.output, result.value.label))
94
353
 
95
- try {
96
- await Promise.all([
97
- runBuildStep(serverBuildArgs, {
98
- ...spawnBase,
99
- env: { ...baseEnv, CATALYST_VITE_CACHE_ID: "ssr" },
100
- }),
101
- runBuildStep(clientBuildArgs, {
102
- ...spawnBase,
103
- env: { ...baseEnv, CATALYST_VITE_CACHE_ID: "client" },
104
- }),
105
- ])
106
- } catch (err) {
107
- console.error("❌ Build failed!")
108
- const debugEnv = outputMode === "debug" ? getDebugEnvInfo() : undefined
109
- console.error(formatError(wrapForeignError("BUNDLE", err), outputMode, debugEnv))
110
- process.exit(1)
354
+ bundling.stop("done", "Built client and server bundles")
355
+ // Warnings belong to the step that produced them, beneath it.
356
+ for (const warning of warnings) {
357
+ console.log(`${GUTTER}${t.dim(glyph.pipe)} ${t.warn(glyph.warn)} ${t.dim(warning)}`)
111
358
  }
112
359
 
113
- console.log("✅ Server and client builds completed!")
360
+ // Lets `serve` refuse a build that no longer matches config/config.json.
361
+ writeBuildInfo(process.env.PWD, configJSON, argumentsObject)
114
362
 
115
- await runBuildStep(["./dist/scripts/generateOfflineManifest.js"], {
116
- ...spawnBase,
117
- env: baseEnv,
118
- })
363
+ const manifest = new Spinner().start("Generating offline manifest")
364
+ try {
365
+ await runBuildStep(
366
+ ["./dist/scripts/generateOfflineManifest.js"],
367
+ { ...spawnBase, env: baseEnv },
368
+ "Generating the offline manifest"
369
+ )
370
+ } catch (error) {
371
+ manifest.stop("fail", "Generating offline manifest failed")
372
+ // Without this the manifest step fell through to the top-level handler,
373
+ // which prints a raw stack trace. The step's output is captured, so
374
+ // show its tail -- the exit code alone says nothing about why -- and
375
+ // all of it under --verbose/--debug.
376
+ process.stderr.write(
377
+ diagnostic({
378
+ message: `Generating the offline manifest failed: ${error.message}`,
379
+ scope: "offline manifest",
380
+ }) + (outputMode === "default" ? fencedTail(error.output) : "")
381
+ )
382
+ if (outputMode !== "default") {
383
+ process.stderr.write(verboseOutput({ output: error.output, mode: outputMode, error }))
384
+ }
385
+ process.exit(exitCodeFrom(error))
386
+ }
387
+ manifest.stop("done", "Generated offline manifest")
388
+
389
+ printAssetTable(buildOutputPath, configJSON.BUILD_OUTPUT_PATH || "build")
119
390
 
120
- console.log("🎉 Build completed successfully!")
121
- console.log("📁 Built files are located in the 'build' directory")
122
- console.log("🚀 Run 'npm run serve' to start the production server")
391
+ console.log(`\n${GUTTER}${t.ok(glyph.done)} ${t.bold(`Built in ${duration(Date.now() - startedAt)}`)}`)
392
+ console.log(
393
+ `${GUTTER} ${t.dim("Run ")}${t.accent("catalyst serve")}${t.dim(" to start the production server")}\n`
394
+ )
123
395
  }
124
396
 
125
397
  build().catch((err) => {
@@ -53,6 +53,14 @@ export function collectOfflineRoutes(routes, basePath = "", parentOffline = fals
53
53
  }
54
54
 
55
55
  async function loadAppRoutes(appRoot) {
56
+ // The shared dev config's `server` block wins over the inline options
57
+ // below, and its isProduction is captured at module load so NODE_ENV
58
+ // cannot be flipped here. This flag is the supported opt-out: without it
59
+ // the manifest step opens an HMR websocket and prints "Port 24678 is
60
+ // already in use" whenever a dev server happens to be running.
61
+ const previousNoHmr = process.env.CATALYST_NO_HMR
62
+ process.env.CATALYST_NO_HMR = "true"
63
+
56
64
  const vite = await createViteServer({
57
65
  configFile: path.resolve(__dirname, "../vite/vite.config.js"),
58
66
  root: appRoot,
@@ -73,6 +81,8 @@ async function loadAppRoutes(appRoot) {
73
81
  return typeof routesModule.getRoutes === "function" ? routesModule.getRoutes() : []
74
82
  } finally {
75
83
  await vite.close()
84
+ if (previousNoHmr === undefined) delete process.env.CATALYST_NO_HMR
85
+ else process.env.CATALYST_NO_HMR = previousNoHmr
76
86
  }
77
87
  }
78
88
 
@@ -106,7 +116,7 @@ export async function generateOfflineManifest() {
106
116
  copyIfExists(path.join(appRoot, "public/offline.html"), path.join(buildDir, "offline.html"))
107
117
  fs.copyFileSync(path.join(__dirname, "../offline/catalyst-sw.js"), path.join(buildDir, "catalyst-sw.js"))
108
118
 
109
- console.log(`Catalyst offline manifest generated: ${routes.length} route(s)`)
119
+ // The CLI reports this step; printing it here too duplicated the line.
110
120
  }
111
121
 
112
122
  if (process.argv[1] && path.resolve(process.argv[1]) === __filename) {
@@ -1,14 +1,19 @@
1
1
  import path from "path"
2
2
  import fs from "fs"
3
+ import { createRequire } from "module"
4
+
5
+ const { diagnostic } = createRequire(import.meta.url)("../cli/diagnostic.js")
3
6
 
4
7
  /**
5
8
  * @description stores all config.json key value into process.env before server starts.
6
9
  */
7
10
  const loadEnvironmentVariables = async () => {
11
+ // Declared outside the try so the catch can point the diagnostic at it.
12
+ let appConfigPath
8
13
  try {
9
14
  // Resolved at call time, not import time, so preServerInit.js can report a
10
15
  // missing src_path before any path is built from it.
11
- const appConfigPath = path.resolve(process.env.src_path, "config/config.json")
16
+ appConfigPath = path.resolve(process.env.src_path, "config/config.json")
12
17
  const filterKeys = JSON.parse(process.env.filterKeys)
13
18
  const configContent = fs.readFileSync(appConfigPath, "utf8")
14
19
  const appConfig = JSON.parse(configContent)
@@ -45,7 +50,24 @@ const loadEnvironmentVariables = async () => {
45
50
  // still keeping every pre-existing process.env entry via the spread.
46
51
  process.env = { ...process.env, ...newConfig }
47
52
  } catch (error) {
48
- console.error("Error loading environment variables:", error)
53
+ // This runs before the server starts, for both `start` and `serve`, so
54
+ // a missing or malformed config used to greet the user with a raw
55
+ // ENOENT/SyntaxError stack trace as the very first thing they saw.
56
+ process.stderr.write(
57
+ diagnostic({
58
+ message:
59
+ error.code === "ENOENT"
60
+ ? "Config file not found"
61
+ : error instanceof SyntaxError
62
+ ? `Config file is not valid JSON: ${error.message}`
63
+ : `Could not load configuration: ${error.message}`,
64
+ file: appConfigPath,
65
+ hint:
66
+ error.code === "ENOENT"
67
+ ? "Every Catalyst app needs a config/config.json at its root."
68
+ : undefined,
69
+ })
70
+ )
49
71
  throw error
50
72
  }
51
73
  }
@@ -3,16 +3,32 @@ import { createRequire } from "module"
3
3
  import loadEnvironmentVariables from "./loadEnvironmentVariables.js"
4
4
  import { validatePreInitServer, handleError, safeCallNamed } from "../server/utils/validator.js"
5
5
 
6
+ const { diagnostic, registryFailure } = createRequire(import.meta.url)("../cli/diagnostic.js")
7
+
8
+ // A startup check that fails: one diagnostic, then exit. A throw here printed
9
+ // Node's stack for a problem whose fix is one install command. The CLI that
10
+ // spawned this process sees the diagnostic and does not add its own.
11
+ function failStartup(message, hint) {
12
+ process.stderr.write(diagnostic({ message, scope: "server", hint }))
13
+ process.exit(1)
14
+ }
15
+
6
16
  // src_path is the application root, set by the CLI when it spawns this process.
7
17
  // Without it every path below would be resolved against undefined.
8
18
  const appRoot = process.env.src_path
9
19
  if (!appRoot) {
10
- throw new Error(
11
- "catalyst-core: process.env.src_path is not set, so the application root cannot be resolved. " +
12
- "Start the server with `catalyst serve` or `catalyst start` from the application root."
20
+ failStartup(
21
+ "process.env.src_path is not set, so the application root cannot be resolved",
22
+ "Start the server with `catalyst start` or `catalyst serve` from the application root."
13
23
  )
14
24
  }
15
25
 
26
+ // validator.ts renders registry errors through this. It is also bundled into
27
+ // the app's SSR build, which cannot reach the CLI by a relative path, so the
28
+ // server process hands the renderer over here, in the first module it loads.
29
+ // eslint-disable-next-line no-undef -- the scripts lint env predates globalThis
30
+ globalThis.__catalystRenderRegistryError = (error, scope) => registryFailure(error, { scope, cwd: appRoot })
31
+
16
32
  // react-router is a peer dependency: fail loudly at startup rather than on a
17
33
  // missing export deep inside a render. Resolve from the app root, not from
18
34
  // catalyst-core's own tree — the app's copy is the one vite and SSR load.
@@ -22,9 +38,9 @@ let reactRouterVersion
22
38
  try {
23
39
  reactRouterVersion = appRequire("react-router/package.json").version
24
40
  } catch {
25
- throw new Error(
26
- `catalyst-core requires react-router ${REQUIRED_REACT_ROUTER} as a peer dependency, but it is not installed. ` +
27
- `Install it: npm install react-router@${REQUIRED_REACT_ROUTER}`
41
+ failStartup(
42
+ `catalyst-core needs react-router ${REQUIRED_REACT_ROUTER}, and it is not installed`,
43
+ `Install it with \`npm install react-router@${REQUIRED_REACT_ROUTER}\`.`
28
44
  )
29
45
  }
30
46
  const [major, minor, patch] = reactRouterVersion.split("-")[0].split(".").map(Number)
@@ -33,9 +49,9 @@ const isValid =
33
49
  major === 7 &&
34
50
  (minor > 18 || (minor === 18 && patch >= 2))
35
51
  if (!isValid) {
36
- throw new Error(
37
- `catalyst-core requires react-router ${REQUIRED_REACT_ROUTER}, but found ${reactRouterVersion}. ` +
38
- `Install a compatible version: npm install react-router@${REQUIRED_REACT_ROUTER}`
52
+ failStartup(
53
+ `catalyst-core needs react-router ${REQUIRED_REACT_ROUTER}, but the app has ${reactRouterVersion}`,
54
+ `Upgrade it with \`npm install react-router@${REQUIRED_REACT_ROUTER}\`.`
39
55
  )
40
56
  }
41
57
 
@@ -46,7 +62,13 @@ try {
46
62
  } catch {
47
63
  // No hooks file — preServerInit remains undefined
48
64
  }
49
- await loadEnvironmentVariables()
65
+ try {
66
+ await loadEnvironmentVariables()
67
+ } catch {
68
+ // loadEnvironmentVariables already printed an actionable message. Letting
69
+ // this propagate would print the raw stack again as an unhandled rejection.
70
+ process.exit(1)
71
+ }
50
72
 
51
73
  // preServerInit is an OPTIONAL hook — an app with no server/index.js is
52
74
  // valid, so PREFLIGHT-010 (missing) is deliberately not surfaced here. But