catalyst-core 1.0.2-canary.1 → 1.0.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 (90) hide show
  1. package/bin/catalyst.js +105 -1
  2. package/changelog.md +34 -0
  3. package/dist/cli/README.md +119 -0
  4. package/dist/cli/appConfig.js +51 -0
  5. package/dist/cli/buildErrors.js +170 -0
  6. package/dist/cli/buildWarnings.js +28 -0
  7. package/dist/cli/devOutput.js +524 -0
  8. package/dist/cli/diagnostic.js +388 -0
  9. package/dist/cli/hints.js +69 -0
  10. package/dist/cli/package.json +3 -0
  11. package/dist/cli/spinner.js +141 -0
  12. package/dist/cli/theme.js +155 -0
  13. package/dist/errors/index.js +7 -3
  14. package/dist/logger.js +61 -6
  15. package/dist/native/androidProject/app/build.gradle.kts +2 -1
  16. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/CustomWebview.kt +2 -0
  17. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/MainActivity.kt +9 -0
  18. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/SplashActivity.kt +65 -34
  19. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/SplashOverlay.kt +109 -0
  20. package/dist/native/androidSetup.js +46 -57
  21. package/dist/native/buildAndroid/assets.js +22 -13
  22. package/dist/native/buildAndroid/build.js +98 -26
  23. package/dist/native/buildAndroid/config.js +43 -12
  24. package/dist/native/buildAndroid/index.js +49 -191
  25. package/dist/native/buildAppAndroid.js +19 -8
  26. package/dist/native/buildAppIos.js +35 -16
  27. package/dist/native/buildIos/assets.js +1 -6
  28. package/dist/native/buildIos/build.js +66 -95
  29. package/dist/native/buildIos/config.js +26 -8
  30. package/dist/native/buildIos/index.js +143 -64
  31. package/dist/native/buildIos/plugins.js +12 -15
  32. package/dist/native/internalPluginUtils.js +0 -1
  33. package/dist/native/iosnativeWebView/CoreLogic/Sources/CatalystCoreLogic/Constants/ConfigConstants.swift +2 -0
  34. package/dist/native/iosnativeWebView/Sources/Core/WebView/SplashHTMLView.swift +31 -0
  35. package/dist/native/iosnativeWebView/Sources/Core/WebView/SplashView.swift +17 -1
  36. package/dist/native/iosnativeWebView/Sources/Core/WebView/SplashViewModel.swift +41 -1
  37. package/dist/native/iosnativeWebView/Sources/Core/WebView/WebViewModel.swift +2 -0
  38. package/dist/native/iosnativeWebView/Sources/Core/WebView/WebViewNavigationDelegate.swift +3 -0
  39. package/dist/native/pluginComposerAndroid.js +2 -3
  40. package/dist/native/renameAndroidProject.js +42 -20
  41. package/dist/native/setupEmulatorIos.js +28 -49
  42. package/dist/native/setupServer.js +12 -15
  43. package/dist/native/terminalProgress.js +186 -147
  44. package/dist/native/utils.js +340 -54
  45. package/dist/otel.js +15 -9
  46. package/dist/scripts/build.js +313 -42
  47. package/dist/scripts/generateOfflineManifest.js +11 -1
  48. package/dist/scripts/loadEnvironmentVariables.js +24 -2
  49. package/dist/scripts/preServerInit.js +32 -10
  50. package/dist/scripts/preflight.js +35 -11
  51. package/dist/scripts/scriptUtils.js +34 -0
  52. package/dist/scripts/serve.js +64 -15
  53. package/dist/scripts/start.js +58 -21
  54. package/dist/server/expressServer.js +167 -32
  55. package/dist/server/renderer/handler.jsx +87 -11
  56. package/dist/server/utils/validator.js +14 -11
  57. package/dist/vite/manifest-categorization-plugin.js +7 -5
  58. package/dist/vite/resolveDevServerConfig.js +9 -2
  59. package/dist/vite/vite.config.client.js +18 -0
  60. package/dist/vite/vite.config.js +73 -13
  61. package/dist/vite/vite.config.server.js +21 -0
  62. package/dist/web-router/components/MetaTag.jsx +15 -2
  63. package/dist/web-router/components/MetaTag.test.jsx +71 -2
  64. package/dist/webmcp/WebMcpProvider.jsx +3 -2
  65. package/dist/webmcp/useTool.js +4 -1
  66. package/mcp_v2/conversion-tasks.json +6 -5
  67. package/mcp_v2/knowledge-base.json +124 -7
  68. package/mcp_v2/test/config.test.ts +59 -0
  69. package/mcp_v2/tools/build.js +6 -6
  70. package/mcp_v2/tools/config.js +35 -18
  71. package/mcp_v2/tools/conversion.js +7 -13
  72. package/package.json +5 -5
  73. package/test/cli/buildErrors.test.ts +173 -0
  74. package/test/cli/buildWarnings.test.cjs +62 -0
  75. package/test/cli/devOutput.test.ts +549 -0
  76. package/test/cli/diagnostic.test.ts +434 -0
  77. package/test/cli/output.test.cjs +90 -0
  78. package/test/cli/spinner.test.ts +177 -0
  79. package/test/cli/theme.test.ts +163 -0
  80. package/test/native/redactSecrets.test.ts +72 -0
  81. package/test/native/utils.test.ts +532 -0
  82. package/test/scripts/build.test.ts +275 -0
  83. package/test/scripts/serve.test.ts +102 -0
  84. package/test/server/fixtures/template/src/js/routes/index.jsx +8 -0
  85. package/test/server/handler.test.ts +17 -0
  86. package/test/server/logger.test.ts +185 -0
  87. package/test/server/otel.test.ts +556 -0
  88. package/test/server/validator.test.ts +19 -8
  89. package/dist/scripts/checkVersion.js +0 -3
  90. package/dist/scripts/cleanCache.js +0 -30
@@ -29,15 +29,84 @@ import { resolveOutputMode, getDebugEnvInfo } from "../../scripts/scriptUtils.js
29
29
  // this process never sees the parent's CLI flags, so process.argv here would
30
30
  // never contain --debug/--verbose — only CATALYST_OUTPUT_MODE is real input.
31
31
  const outputMode = resolveOutputMode([], process.env);
32
- // Debug mode's boxed output already embeds the full stack trace (see
33
- // errors/index.js#formatDebug), so only default/verbose need the original
34
- // error printed separately to avoid losing the stack.
35
- const logSSRError = (stage, error) => {
32
+ const STAGE_LABEL = {
33
+ RENDER: "render",
34
+ FETCHER: "serverFetcher",
35
+ SERVER_SIDE_FUNCTION: "serverSideFunction",
36
+ REQUEST_HANDLING: "request"
37
+ };
38
+
39
+ // One failure is reported once. A render error used to surface at every catch
40
+ // site it bubbled through -- render, fetcher, serverSideFunction, request --
41
+ // each re-rendering the same broken tree, so one throw printed four coded
42
+ // reports and four stacks per request.
43
+ const reported = new WeakSet();
44
+
45
+ // Marks an error that came out of rendering. The fetcher and
46
+ // serverSideFunction fallbacks re-render the page; re-rendering after the
47
+ // render itself failed only throws the same error again.
48
+ const RENDER_FAILED = Symbol.for("catalyst.renderFailed");
49
+ const asError = error => error && typeof error === "object" ? error : new Error(String(error));
50
+ const markRenderFailure = error => {
51
+ const err = asError(error);
52
+ err[RENDER_FAILED] = true;
53
+ // Already reported by the render path; a thrown string arrives here as a
54
+ // fresh Error the outer catch would otherwise report a second time.
55
+ reported.add(err);
56
+ return err;
57
+ };
58
+ const isRenderFailure = error => Boolean(error && typeof error === "object" && error[RENDER_FAILED]);
59
+ const logSSRError = (stage, error, req) => {
60
+ if (error && typeof error === "object") {
61
+ if (reported.has(error)) return;
62
+ reported.add(error);
63
+ }
36
64
  const wrapped = wrapSSRError(stage, error);
65
+
66
+ // This module is bundled into the app's build, so it cannot reach
67
+ // catalyst-core's terminal renderer by path. The server that loads it
68
+ // provides one (see expressServer.ts); a custom server without it gets the
69
+ // registry's plain formatting.
70
+ const report = globalThis.__catalystReportRuntimeError;
71
+ if (typeof report === "function") {
72
+ const where = req ? `${req.method} ${req.originalUrl}` : null;
73
+ report({
74
+ error,
75
+ code: wrapped.code,
76
+ docs: wrapped.docUrl,
77
+ scope: [where, STAGE_LABEL[stage]].filter(Boolean).join(" · ")
78
+ });
79
+ return;
80
+ }
81
+ // Debug mode's boxed output already embeds the full stack trace (see
82
+ // errors/index.js#formatDebug), so only default/verbose need the original
83
+ // error printed separately to avoid losing the stack.
37
84
  const debugEnv = outputMode === "debug" ? getDebugEnvInfo() : undefined;
38
85
  console.error(formatError(wrapped, outputMode, debugEnv));
39
86
  if (outputMode !== "debug") console.error(error);
40
87
  };
88
+
89
+ /**
90
+ * Finish a request whose handling failed. Without this a render error left the
91
+ * response open: the browser spun until it timed out and the terminal was the
92
+ * only place the failure showed.
93
+ */
94
+ const sendFailure = (res, error) => {
95
+ // Plain Node response calls, and never a throw: failing while reporting a
96
+ // failure would turn one error into an unhandled rejection.
97
+ try {
98
+ if (res.headersSent) {
99
+ if (!res.writableEnded) res.end();
100
+ return;
101
+ }
102
+ const detail = process.env.NODE_ENV !== "production" && error ? `\n\n${error?.message || String(error)}\n\nThe full error is in the terminal running catalyst start.` : "";
103
+ res.statusCode = 500;
104
+ res.setHeader?.("content-type", "text/plain; charset=utf-8");
105
+ res.end(`Internal Server Error${detail}`);
106
+ } catch {
107
+ // The connection is already gone; nothing left to tell it.
108
+ }
109
+ };
41
110
  const DEFAULT_SAFE_AREA_INSETS = {
42
111
  top: 0,
43
112
  right: 0,
@@ -286,6 +355,10 @@ const _renderMarkUp = async (errorCode, req, res, metaTags, fetcherData, store,
286
355
  cb();
287
356
  }
288
357
  });
358
+ // destroy(error) below emits "error" on this stream. Unhandled, that
359
+ // became an uncaughtException per render on top of the report
360
+ // already printed; the failure is handled through reject().
361
+ tail.on("error", () => {});
289
362
  tail.pipe(res);
290
363
  const {
291
364
  pipe
@@ -299,7 +372,7 @@ const _renderMarkUp = async (errorCode, req, res, metaTags, fetcherData, store,
299
372
  resolve();
300
373
  },
301
374
  onError(error) {
302
- logSSRError("RENDER", error);
375
+ logSSRError("RENDER", error, req);
303
376
  safeCall(onRenderError, {
304
377
  req,
305
378
  res,
@@ -308,20 +381,20 @@ const _renderMarkUp = async (errorCode, req, res, metaTags, fetcherData, store,
308
381
  });
309
382
  cleanupSafeArea();
310
383
  tail.destroy(error);
311
- reject(error);
384
+ reject(markRenderFailure(error));
312
385
  }
313
386
  });
314
387
  });
315
388
  } catch (error) {
316
389
  cleanupSafeArea();
317
- logSSRError("RENDER", error);
390
+ logSSRError("RENDER", error, req);
318
391
  safeCall(onRenderError, {
319
392
  req,
320
393
  res,
321
394
  store,
322
395
  error
323
396
  });
324
- return Promise.reject(error);
397
+ return Promise.reject(markRenderFailure(error));
325
398
  }
326
399
  };
327
400
  const tracedRenderMarkUp = withObservability(SSR_SERVICE, _renderMarkUp, "renderMarkUp");
@@ -412,7 +485,8 @@ async function _handler(req, res) {
412
485
  await tracedRenderMarkUp(null, req, res, allTags, fetcherData, store, allMatches, context, chunkExtractor);
413
486
  }
414
487
  } catch (error) {
415
- logSSRError("FETCHER", error);
488
+ if (isRenderFailure(error)) throw error;
489
+ logSSRError("FETCHER", error, req);
416
490
  safeCall(onFetcherError, {
417
491
  req,
418
492
  res,
@@ -424,7 +498,8 @@ async function _handler(req, res) {
424
498
  await tracedRenderMarkUp(404, req, res, allTags, fetcherData, store, allMatches, context, chunkExtractor);
425
499
  }
426
500
  } catch (error) {
427
- logSSRError("SERVER_SIDE_FUNCTION", error);
501
+ if (isRenderFailure(error)) throw error;
502
+ logSSRError("SERVER_SIDE_FUNCTION", error, req);
428
503
  safeCall(onAppServerSideError, {
429
504
  req,
430
505
  res,
@@ -436,12 +511,13 @@ async function _handler(req, res) {
436
511
  await tracedRenderMarkUp(error.status_code, req, res, allTags, fetcherData, store, allMatches, context, chunkExtractor);
437
512
  }
438
513
  } catch (error) {
439
- logSSRError("REQUEST_HANDLING", error);
514
+ logSSRError("REQUEST_HANDLING", error, req);
440
515
  safeCall(onRequestError, {
441
516
  req,
442
517
  res,
443
518
  error
444
519
  });
520
+ sendFailure(res, error);
445
521
  }
446
522
  }
447
523
  const handler = withObservability(SSR_SERVICE, _handler, "handler");
@@ -1,4 +1,3 @@
1
- import pc from "ansis";
2
1
  import { createError, formatError, ERROR_CODES } from "../../errors/index.js";
3
2
  import { resolveOutputMode, getDebugEnvInfo } from "../../scripts/scriptUtils.js";
4
3
 
@@ -17,13 +16,19 @@ const outputMode = resolveOutputMode([], process.env);
17
16
  // single error and fall back, keeping the request alive.
18
17
  // `handleError` below is the shared "log it, in whatever output mode" helper
19
18
  // those in-server sites use.
20
- const handleError = e => {
19
+ // The default view is the CLI's diagnostic block. This module is also bundled
20
+ // into the app's SSR build, where the CLI is not reachable by a relative path,
21
+ // so the server process installs the renderer as a global (preServerInit.js,
22
+ // the first module it loads). Without it, or under --verbose/--debug, the
23
+ // registry's own format is used.
24
+ const renderError = e => {
25
+ const render = globalThis.__catalystRenderRegistryError;
26
+ if (outputMode === "default" && typeof render === "function") return render(e, "server");
21
27
  const debugEnv = outputMode === "debug" ? getDebugEnvInfo() : undefined;
22
- if (outputMode === "default") {
23
- console.log(pc.red("Failed to start server: "), formatError(e, outputMode));
24
- } else {
25
- console.log(formatError(e, outputMode, debugEnv));
26
- }
28
+ return formatError(e, outputMode, debugEnv);
29
+ };
30
+ const handleError = e => {
31
+ console.log(renderError(e));
27
32
  };
28
33
  const validatePreInitServer = fn => {
29
34
  if (!fn) return createError(ERROR_CODES.PREFLIGHT_PRE_SERVER_INIT_MISSING);
@@ -112,8 +117,7 @@ const safeCall = async (fn, ...args) => {
112
117
  const wrapped = createError(ERROR_CODES.PROCESS_USER_HOOK_FAILED, {
113
118
  cause: e
114
119
  });
115
- const debugEnv = outputMode === "debug" ? getDebugEnvInfo() : undefined;
116
- console.error(formatError(wrapped, outputMode, debugEnv));
120
+ console.error(renderError(wrapped));
117
121
  }
118
122
  };
119
123
 
@@ -132,8 +136,7 @@ const safeCallNamed = async (hookName, fn, ...args) => {
132
136
  details: `The "${hookName}" hook threw. See the cause below.`,
133
137
  cause: e
134
138
  });
135
- const debugEnv = outputMode === "debug" ? getDebugEnvInfo() : undefined;
136
- console.error(formatError(wrapped, outputMode, debugEnv));
139
+ console.error(renderError(wrapped));
137
140
  }
138
141
  };
139
142
  export { handleError, validateConfigFile, validateConfigureStore, validateCustomDocument, validateGetRoutes, validatePackageJson, validateReducerFunction, validateModuleAlias, validatePreInitServer, validateMiddleware, safeCall, safeCallNamed };
@@ -199,7 +199,7 @@ export function manifestCategorizationPlugin(options = {}) {
199
199
  if (matchedViteEntry && matchedViteKey) {
200
200
  newCategorizedChunks[category][matchedViteKey] = { ...matchedViteEntry }
201
201
  } else {
202
- console.log(`❌ No manifest match found for ${chunkName}, keeping original structure`)
202
+ console.debug(`No manifest match for ${chunkName}; keeping original structure`)
203
203
  newCategorizedChunks[category][chunkName] = chunkData
204
204
  }
205
205
  }
@@ -405,7 +405,7 @@ export function manifestCategorizationPlugin(options = {}) {
405
405
  }
406
406
  } catch (err) {
407
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.
408
- console.log(`❌ Error resolving ${importPath}:`, err.message)
408
+ console.debug(`Could not resolve ${importPath}: ${err.message}`)
409
409
  }
410
410
  })
411
411
  )
@@ -430,16 +430,18 @@ export function manifestCategorizationPlugin(options = {}) {
430
430
  const manifestContent = fs.readFileSync(manifestPath, "utf8")
431
431
  viteManifest = JSON.parse(manifestContent)
432
432
  } else {
433
- console.log("❌ Could not find manifest.json at expected path:", manifestPath)
433
+ // Silent: this fires as a *consequence* of the bundle
434
+ // failing, so shouting about a missing manifest adds a
435
+ // second, misleading error on top of the real one.
434
436
  return
435
437
  }
436
438
  } catch (e) {
437
- console.log("❌ Could not read manifest from file system:", e.message)
439
+ // Same reasoning as above: a consequence, not the cause.
438
440
  return
439
441
  }
440
442
 
441
443
  if (!processedManifest) {
442
- console.log("❌ No processed manifest available")
444
+ console.debug("No processed manifest available")
443
445
  return
444
446
  }
445
447
 
@@ -38,11 +38,18 @@ export function resolveDevFsAllow(devFs, frameworkPaths) {
38
38
  * first so the framework-owned `hmr` and `fs` always win.
39
39
  */
40
40
  export function buildDevServer(devServer = {}, { frameworkPaths, isProduction = false } = {}) {
41
- // eslint-disable-next-line no-unused-vars -- base is destructured only to omit it from serverOverrides
41
+ // `base` is pulled out to keep it out of serverOverrides -- it is applied
42
+ // at the config root, not under `server` -- so it is unused by design.
43
+ // eslint-disable-next-line no-unused-vars
42
44
  const { base, hmr, fs, ...serverOverrides } = devServer
45
+ // CATALYST_NO_HMR lets a one-shot consumer of this config (the offline
46
+ // manifest step, which spins up a throwaway server purely to load a
47
+ // module) opt out. isProduction is captured at module load, so it cannot
48
+ // be flipped by setting NODE_ENV just before createServer().
49
+ const hmrDisabled = isProduction || process.env.CATALYST_NO_HMR === "true"
43
50
  return {
44
51
  ...serverOverrides,
45
- hmr: isProduction ? false : (hmr ?? true),
52
+ hmr: hmrDisabled ? false : (hmr ?? true),
46
53
  fs: { allow: resolveDevFsAllow(fs, frameworkPaths) },
47
54
  }
48
55
  }
@@ -31,6 +31,10 @@ const createClientConfig = async () => {
31
31
  sourcemap: false,
32
32
  manifest: true,
33
33
  ssrManifest: true,
34
+ // Parallel client and server builds share this directory -- emptying it
35
+ // would delete the other bundle. Declared explicitly so Vite does not
36
+ // warn about an outDir outside the project root.
37
+ emptyOutDir: false,
34
38
  outDir: path.join(process.env.src_path, process.env.BUILD_OUTPUT_PATH || "build"),
35
39
 
36
40
  // cssCodeSplit: true (default) — each chunk gets its own CSS file.
@@ -38,6 +42,20 @@ const createClientConfig = async () => {
38
42
  // per-chunk CSS is fine — more files but each is small and cacheable.
39
43
 
40
44
  rollupOptions: {
45
+ // Advisory warnings about third-party code the app author
46
+ // cannot act on: a dependency's misplaced /*#__PURE__*/ comment
47
+ // and unused re-exports from our own entry. Genuine problems
48
+ // (unresolved imports, circular deps, missing exports) still
49
+ // surface.
50
+ onwarn(warning, defaultHandler) {
51
+ const ignored = new Set([
52
+ "INVALID_ANNOTATION",
53
+ "UNUSED_EXTERNAL_IMPORT",
54
+ "MODULE_LEVEL_DIRECTIVE",
55
+ ])
56
+ if (ignored.has(warning.code)) return
57
+ defaultHandler(warning)
58
+ },
41
59
  input: {
42
60
  main: path.join(process.env.src_path, "client/index.js"),
43
61
  },
@@ -135,14 +135,20 @@ import { imageUrl, fontUrl } from "./scssParams.js"
135
135
  // options — so anything else is left alone and Vite falls back to its normal
136
136
  // auto-loaded postcss.config.js resolution.
137
137
  const resolvePostcssPlugins = () => {
138
- // 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.
139
- const configPath = path.join(process.env.src_path, "postcss.config.js")
140
- if (!fs.existsSync(configPath)) return null
138
+ // .cjs first: an app with "type": "module" (the scaffold default) must
139
+ // name a CommonJS config .cjs, and a .js one is ESM there.
140
+ const configPath = ["postcss.config.cjs", "postcss.config.js"]
141
+ // 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.
142
+ .map((name) => path.join(process.env.src_path, name))
143
+ .find((candidate) => fs.existsSync(candidate))
144
+ if (!configPath) return null
141
145
 
142
146
  const appRequire = createRequire(configPath)
143
147
 
144
148
  try {
145
- const userConfig = appRequire(configPath)
149
+ const loaded = appRequire(configPath)
150
+ // require() of an ESM config returns its namespace.
151
+ const userConfig = loaded?.__esModule || loaded?.default ? loaded.default : loaded
146
152
  const pluginsConfig = userConfig?.plugins
147
153
 
148
154
  if (!pluginsConfig || Array.isArray(pluginsConfig) || typeof pluginsConfig !== "object") {
@@ -268,7 +274,28 @@ const getScssResourcesImport = () => {
268
274
 
269
275
  // Pre-bundling upfront avoids Vite discovering deps at runtime which causes
270
276
  // cascading full-page reloads and "stuck in loading" on dev server.
271
- const browserOptimizeDeps = [
277
+ /**
278
+ * Keep only the packages this app can actually resolve.
279
+ *
280
+ * The list below is a superset covering every dependency a Catalyst app might
281
+ * use. Vite logs "Failed to resolve dependency: X, present in
282
+ * 'optimizeDeps.include'" for each entry it cannot find, so an app that uses
283
+ * half of them opened with 23 warning lines it could do nothing about.
284
+ */
285
+ function resolvableFrom(candidates, appRoot) {
286
+ // nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal - appRoot is the local project root resolved by the CLI.
287
+ const resolver = createRequire(path.join(appRoot, "package.json"))
288
+ return candidates.filter((name) => {
289
+ try {
290
+ resolver.resolve(name)
291
+ return true
292
+ } catch (error) {
293
+ return false
294
+ }
295
+ })
296
+ }
297
+
298
+ const candidateBrowserDeps = [
272
299
  "react",
273
300
  "react-dom",
274
301
  "react-dom/client",
@@ -364,13 +391,12 @@ export const sharedViteConfig = {
364
391
  // the bundler never tries to resolve the optional (opt-in) OTEL packages.
365
392
  external: nodeOnlyExternalDeps,
366
393
  optimizeDeps: {
367
- include: [
368
- "invariant",
369
- "shallowequal",
370
- "prop-types",
371
- "redux-thunk",
372
- "redux-logger",
373
- ],
394
+ // Same treatment as the browser list: only include what this app
395
+ // can resolve, or Vite warns about every entry it cannot find.
396
+ include: resolvableFrom(
397
+ ["invariant", "shallowequal", "prop-types", "redux-thunk", "redux-logger"],
398
+ process.env.src_path
399
+ ),
374
400
  // Prevent pre-bundling React ecosystem packages for SSR to avoid duplicate
375
401
  // React instances. Pre-bundled copies in .vite/deps_ssr/ create a separate
376
402
  // React instance from node_modules/react used by react-dom/server, causing
@@ -437,7 +463,7 @@ export const sharedViteConfig = {
437
463
  path.join(process.env.src_path, "client/index.js"),
438
464
  path.join(process.env.src_path, "src/**/*.{js,jsx,ts,tsx}"),
439
465
  ],
440
- include: browserOptimizeDeps,
466
+ include: resolvableFrom(candidateBrowserDeps, process.env.src_path),
441
467
  // catalyst-core/webmcp (and its shim subpath) must share the SAME
442
468
  // pre-bundled module instance as catalyst-core's main entry:
443
469
  // WebMcpProvider reads router state through a React context
@@ -500,6 +526,16 @@ export const sharedViteConfig = {
500
526
  },
501
527
  }
502
528
 
529
+ // Every catalyst-core subpath export except the main entry and webmcp, as bare
530
+ // specifiers ("catalyst-core/hooks"). Read from package.json so a new subpath
531
+ // stays externalized in dev SSR without touching this list.
532
+ function catalystCoreExternalSubpaths() {
533
+ const { exports = {} } = JSON.parse(fs.readFileSync(path.join(__dirname, "../../package.json"), "utf8"))
534
+ return Object.keys(exports)
535
+ .filter((key) => key.startsWith("./") && key !== "./package.json" && !key.startsWith("./webmcp"))
536
+ .map((key) => `catalyst-core/${key.slice(2)}`)
537
+ }
538
+
503
539
  // ─── Dev server config ────────────────────────────────────────────────────────
504
540
  // Async factory so it can read the app's buildConfig.devServer for `base`, HMR
505
541
  // and other Vite server options. `base` comes solely from devServer.base; the
@@ -512,6 +548,30 @@ export default defineConfig(async () => {
512
548
  return {
513
549
  ...sharedViteConfig,
514
550
  base: resolveDevBase(devServer),
551
+ // Dev SSR only: transform catalyst-core/webmcp through Vite instead of
552
+ // externalizing it. Node cannot load its .jsx files, and its provider
553
+ // must share one module record (one createContext()) with the main
554
+ // entry, which Vite always transforms (.jsx is never externalized).
555
+ // ssr.noExternal only matches package names, so the package goes in
556
+ // noExternal and every other subpath is pinned external: CJS subpaths
557
+ // (hooks, WebBridge, sentry) would fail to inline ("exports is not
558
+ // defined"), and apps that do not import webmcp resolve exactly as
559
+ // before. Keyed on NODE_ENV, not Vite's `command`: `catalyst build`
560
+ // also spins up this config for the offline manifest, and built
561
+ // output runs under plain Node ESM, one module record per file.
562
+ ssr: {
563
+ ...sharedViteConfig.ssr,
564
+ noExternal: [
565
+ ...new Set([
566
+ ...(sharedViteConfig.ssr?.noExternal || []),
567
+ ...(isProduction ? [] : ["catalyst-core"]),
568
+ ]),
569
+ ],
570
+ external: [
571
+ ...(sharedViteConfig.ssr?.external || []),
572
+ ...(isProduction ? [] : catalystCoreExternalSubpaths()),
573
+ ],
574
+ },
515
575
  server: buildDevServer(devServer, {
516
576
  frameworkPaths: [process.env.src_path, __dirname],
517
577
  isProduction,
@@ -28,8 +28,15 @@ export default defineConfig(async () => {
28
28
  ...(customViteConfig?.ssrPlugins || []),
29
29
  ],
30
30
 
31
+ // The CLI prints its own build summary and asset table, so Vite's
32
+ // duplicate table and rollup's advisory warnings are pure noise.
33
+
31
34
  build: {
32
35
  ...baseConfig.build,
36
+ // Parallel client and server builds share this directory -- emptying it
37
+ // would delete the other bundle. Declared explicitly so Vite does not
38
+ // warn about an outDir outside the project root.
39
+ emptyOutDir: false,
33
40
  outDir: path.join(process.env.src_path, process.env.BUILD_OUTPUT_PATH || "build"),
34
41
  target: "es2022",
35
42
  minify: "esbuild",
@@ -38,6 +45,20 @@ export default defineConfig(async () => {
38
45
  ssrManifest: false,
39
46
 
40
47
  rollupOptions: {
48
+ // Advisory warnings about third-party code the app author
49
+ // cannot act on: a dependency's misplaced /*#__PURE__*/ comment
50
+ // and unused re-exports from our own entry. Genuine problems
51
+ // (unresolved imports, circular deps, missing exports) still
52
+ // surface.
53
+ onwarn(warning, defaultHandler) {
54
+ const ignored = new Set([
55
+ "INVALID_ANNOTATION",
56
+ "UNUSED_EXTERNAL_IMPORT",
57
+ "MODULE_LEVEL_DIRECTIVE",
58
+ ])
59
+ if (ignored.has(warning.code)) return
60
+ defaultHandler(warning)
61
+ },
41
62
  // Belt-and-suspenders with ssr.external: ensures the opt-in OTEL /
42
63
  // node-only packages (and their transitive @opentelemetry/* deps) are
43
64
  // never resolved/bundled, even though they may not be installed.
@@ -1,4 +1,4 @@
1
- import React, { useContext, useState, useEffect } from "react";
1
+ import React, { useContext, useState, useEffect, useRef } from "react";
2
2
  import { OneMgRouterContext } from "../context.jsx";
3
3
  import { useRouterData } from "./RouterDataProvider.jsx";
4
4
  import { getMetaData } from "../utils/metaDataUtils.jsx";
@@ -13,12 +13,25 @@ export const MetaTag = () => {
13
13
  } = useContext(OneMgRouterContext);
14
14
  const routeData = useRouterData();
15
15
  const location = useLocation();
16
- const [metaTags, setMetaTags] = useState([]);
16
+ // null until the first client update, so server and hydration renders match (both empty).
17
+ const [metaTags, setMetaTags] = useState(null);
18
+ // The server renders these tags into <head> (Head.tsx). React 19 does not
19
+ // adopt them when MetaTag renders its own copies, and renders non-metadata
20
+ // tags such as <script type="application/ld+json"> in place, so the server
21
+ // copies would outlive the page they describe. Captured on mount, before
22
+ // any client copy exists, and removed once the client copies are in.
23
+ const serverTags = useRef(null);
17
24
  useEffect(() => {
25
+ serverTags.current ??= Array.from(document.head.querySelectorAll("[data-catalyst]"));
18
26
  const mergedMetaTags = getMetaData(matchedRoutes, routeData);
19
27
  setMetaTags(Array.isArray(mergedMetaTags) ? mergedMetaTags : []);
20
28
  // eslint-disable-next-line react-hooks/exhaustive-deps
21
29
  }, [location]);
30
+ useEffect(() => {
31
+ if (!metaTags || !serverTags.current?.length) return;
32
+ serverTags.current.forEach(el => el.remove());
33
+ serverTags.current = [];
34
+ }, [metaTags]);
22
35
 
23
36
  // React 19 hoists document metadata rendered by components into <head>.
24
37
  return <>{metaTags}</>;
@@ -1,6 +1,7 @@
1
1
  import React from "react"
2
- import { describe, expect, it, vi } from "vitest"
3
- import { render, waitFor, act } from "@testing-library/react"
2
+ import { renderToString } from "react-dom/server"
3
+ import { afterEach, describe, expect, it, vi } from "vitest"
4
+ import { render, waitFor, act, cleanup } from "@testing-library/react"
4
5
  import { MemoryRouter, Routes, Route, useLocation, useNavigate } from "react-router"
5
6
  import { MetaTag } from "./MetaTag.jsx"
6
7
  import { OneMgRouterContext } from "../context.jsx"
@@ -104,4 +105,72 @@ describe("MetaTag", () => {
104
105
  expect(document.head.querySelector('meta[name="description"]')).toBeNull()
105
106
  })
106
107
  })
108
+
109
+ describe("server-rendered head tags", () => {
110
+ afterEach(() => {
111
+ cleanup()
112
+ document.head.innerHTML = ""
113
+ })
114
+
115
+ const pageMeta = (page) => [
116
+ <title key="t">{`Page ${page}`}</title>,
117
+ <meta key="d" name="description" content={`about ${page}`} />,
118
+ <script key="ld" type="application/ld+json">{`{"page":"${page}"}`}</script>,
119
+ ]
120
+ const routesFor = (pathname) => [
121
+ { route: { component: { setMetaData: () => pageMeta(pathname === "/a" ? "a" : "b") } } },
122
+ ]
123
+
124
+ function App() {
125
+ const location = useLocation()
126
+ const navigate = useNavigate()
127
+ return (
128
+ <OneMgRouterContext.Provider value={{ matchedRoutes: routesFor(location.pathname) }}>
129
+ <RouterContext.Provider value={{}}>
130
+ <button type="button" onClick={() => navigate("/b")}>
131
+ go
132
+ </button>
133
+ <MetaTag />
134
+ </RouterContext.Provider>
135
+ </OneMgRouterContext.Provider>
136
+ )
137
+ }
138
+ const tree = (
139
+ <MemoryRouter initialEntries={["/a"]}>
140
+ <App />
141
+ </MemoryRouter>
142
+ )
143
+
144
+ it("hydrates without a mismatch and replaces the server's JSON-LD, title and meta on navigation", async () => {
145
+ // What Head.tsx streams for /a: getMetaData output stamped data-catalyst.
146
+ document.head.innerHTML = renderToString(<>{pageMeta("a")}</>).replace(
147
+ /<(title|meta|script)/g,
148
+ '<$1 data-catalyst="true"'
149
+ )
150
+ const container = document.createElement("div")
151
+ container.innerHTML = renderToString(tree)
152
+ document.body.appendChild(container)
153
+ const onRecoverableError = vi.fn()
154
+
155
+ await act(async () => {
156
+ render(tree, { container, hydrate: true, onRecoverableError })
157
+ })
158
+ expect(onRecoverableError).not.toHaveBeenCalled()
159
+
160
+ await act(async () => {
161
+ container.querySelector("button").click()
162
+ })
163
+
164
+ await waitFor(() => {
165
+ expect(document.title).toBe("Page b")
166
+ })
167
+ const jsonLd = document.querySelectorAll('script[type="application/ld+json"]')
168
+ expect(Array.from(jsonLd, (el) => el.textContent)).toEqual(['{"page":"b"}'])
169
+ expect(document.head.querySelector('script[type="application/ld+json"]')).toBeNull()
170
+ expect(document.querySelectorAll("title")).toHaveLength(1)
171
+ expect(
172
+ Array.from(document.querySelectorAll('meta[name="description"]'), (el) => el.content)
173
+ ).toEqual(["about b"])
174
+ })
175
+ })
107
176
  })
@@ -127,7 +127,7 @@ export function WebMcpProvider({ children, routes, filterNavigable }) {
127
127
  if (res.ok) owners.push(key)
128
128
  else
129
129
  // eslint-disable-next-line no-console
130
- console.warn(`[webmcp] declarative tool "${spec.name}" failed to register:`, res.error)
130
+ console.warn('[webmcp] declarative tool "%s" failed to register:', spec.name, res.error)
131
131
  }
132
132
 
133
133
  // Framework: navigate + get_current_route + get_page_info.
@@ -146,7 +146,8 @@ export function WebMcpProvider({ children, routes, filterNavigable }) {
146
146
  else
147
147
  // eslint-disable-next-line no-console
148
148
  console.warn(
149
- `[webmcp] framework tool "${spec.name}" failed to register — a route tool may already use that name:`,
149
+ '[webmcp] framework tool "%s" failed to register — a route tool may already use that name:',
150
+ spec.name,
150
151
  res.error
151
152
  )
152
153
  }
@@ -1,3 +1,6 @@
1
+ // The optional deps array is the caller's, passed through on purpose, which
2
+ // the compiler cannot verify -- same exemption as the native bridge hooks.
3
+ /* eslint-disable react-compiler/react-compiler */
1
4
  import { useContext, useEffect, useRef, useState } from "react"
2
5
  import { RouteContext } from "./routeContext.js"
3
6
  import { registerTool, unregisterTool, updateToolRoute, updateToolSpec, newOwnerKey } from "./registry.js"
@@ -86,7 +89,7 @@ export function useTool(spec, deps) {
86
89
  } else {
87
90
  setState({ status: "error", receipt: null, error: res.error })
88
91
  // eslint-disable-next-line no-console
89
- console.warn(`[webmcp] useTool("${s.name}") failed to register:`, res.error)
92
+ console.warn('[webmcp] useTool("%s") failed to register:', s.name, res.error)
90
93
  }
91
94
 
92
95
  return () => {