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.
- package/bin/catalyst.js +105 -1
- package/changelog.md +34 -0
- package/dist/cli/README.md +119 -0
- package/dist/cli/appConfig.js +51 -0
- package/dist/cli/buildErrors.js +170 -0
- package/dist/cli/buildWarnings.js +28 -0
- package/dist/cli/devOutput.js +524 -0
- package/dist/cli/diagnostic.js +388 -0
- package/dist/cli/hints.js +69 -0
- package/dist/cli/package.json +3 -0
- package/dist/cli/spinner.js +141 -0
- package/dist/cli/theme.js +155 -0
- package/dist/errors/index.js +7 -3
- package/dist/logger.js +61 -6
- package/dist/native/androidProject/app/build.gradle.kts +2 -1
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/CustomWebview.kt +2 -0
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/MainActivity.kt +9 -0
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/SplashActivity.kt +65 -34
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/SplashOverlay.kt +109 -0
- package/dist/native/androidSetup.js +46 -57
- package/dist/native/buildAndroid/assets.js +22 -13
- package/dist/native/buildAndroid/build.js +98 -26
- package/dist/native/buildAndroid/config.js +43 -12
- package/dist/native/buildAndroid/index.js +49 -191
- package/dist/native/buildAppAndroid.js +19 -8
- package/dist/native/buildAppIos.js +35 -16
- package/dist/native/buildIos/assets.js +1 -6
- package/dist/native/buildIos/build.js +66 -95
- package/dist/native/buildIos/config.js +26 -8
- package/dist/native/buildIos/index.js +143 -64
- package/dist/native/buildIos/plugins.js +12 -15
- package/dist/native/internalPluginUtils.js +0 -1
- package/dist/native/iosnativeWebView/CoreLogic/Sources/CatalystCoreLogic/Constants/ConfigConstants.swift +2 -0
- package/dist/native/iosnativeWebView/Sources/Core/WebView/SplashHTMLView.swift +31 -0
- package/dist/native/iosnativeWebView/Sources/Core/WebView/SplashView.swift +17 -1
- package/dist/native/iosnativeWebView/Sources/Core/WebView/SplashViewModel.swift +41 -1
- package/dist/native/iosnativeWebView/Sources/Core/WebView/WebViewModel.swift +2 -0
- package/dist/native/iosnativeWebView/Sources/Core/WebView/WebViewNavigationDelegate.swift +3 -0
- package/dist/native/pluginComposerAndroid.js +2 -3
- package/dist/native/renameAndroidProject.js +42 -20
- package/dist/native/setupEmulatorIos.js +28 -49
- package/dist/native/setupServer.js +12 -15
- package/dist/native/terminalProgress.js +186 -147
- package/dist/native/utils.js +340 -54
- package/dist/otel.js +15 -9
- package/dist/scripts/build.js +313 -42
- package/dist/scripts/generateOfflineManifest.js +11 -1
- package/dist/scripts/loadEnvironmentVariables.js +24 -2
- package/dist/scripts/preServerInit.js +32 -10
- package/dist/scripts/preflight.js +35 -11
- package/dist/scripts/scriptUtils.js +34 -0
- package/dist/scripts/serve.js +64 -15
- package/dist/scripts/start.js +58 -21
- package/dist/server/expressServer.js +167 -32
- package/dist/server/renderer/handler.jsx +87 -11
- package/dist/server/utils/validator.js +14 -11
- package/dist/vite/manifest-categorization-plugin.js +7 -5
- package/dist/vite/resolveDevServerConfig.js +9 -2
- package/dist/vite/vite.config.client.js +18 -0
- package/dist/vite/vite.config.js +73 -13
- package/dist/vite/vite.config.server.js +21 -0
- package/dist/web-router/components/MetaTag.jsx +15 -2
- package/dist/web-router/components/MetaTag.test.jsx +71 -2
- package/dist/webmcp/WebMcpProvider.jsx +3 -2
- package/dist/webmcp/useTool.js +4 -1
- package/mcp_v2/conversion-tasks.json +6 -5
- package/mcp_v2/knowledge-base.json +124 -7
- package/mcp_v2/test/config.test.ts +59 -0
- package/mcp_v2/tools/build.js +6 -6
- package/mcp_v2/tools/config.js +35 -18
- package/mcp_v2/tools/conversion.js +7 -13
- package/package.json +5 -5
- package/test/cli/buildErrors.test.ts +173 -0
- package/test/cli/buildWarnings.test.cjs +62 -0
- package/test/cli/devOutput.test.ts +549 -0
- package/test/cli/diagnostic.test.ts +434 -0
- package/test/cli/output.test.cjs +90 -0
- package/test/cli/spinner.test.ts +177 -0
- package/test/cli/theme.test.ts +163 -0
- package/test/native/redactSecrets.test.ts +72 -0
- package/test/native/utils.test.ts +532 -0
- package/test/scripts/build.test.ts +275 -0
- package/test/scripts/serve.test.ts +102 -0
- package/test/server/fixtures/template/src/js/routes/index.jsx +8 -0
- package/test/server/handler.test.ts +17 -0
- package/test/server/logger.test.ts +185 -0
- package/test/server/otel.test.ts +556 -0
- package/test/server/validator.test.ts +19 -8
- package/dist/scripts/checkVersion.js +0 -3
- 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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
439
|
+
// Same reasoning as above: a consequence, not the cause.
|
|
438
440
|
return
|
|
439
441
|
}
|
|
440
442
|
|
|
441
443
|
if (!processedManifest) {
|
|
442
|
-
console.
|
|
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
|
-
//
|
|
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:
|
|
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
|
},
|
package/dist/vite/vite.config.js
CHANGED
|
@@ -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
|
-
//
|
|
139
|
-
|
|
140
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
"prop-types",
|
|
371
|
-
|
|
372
|
-
|
|
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:
|
|
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
|
-
|
|
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 {
|
|
3
|
-
import {
|
|
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(
|
|
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
|
-
|
|
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
|
}
|
package/dist/webmcp/useTool.js
CHANGED
|
@@ -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(
|
|
92
|
+
console.warn('[webmcp] useTool("%s") failed to register:', s.name, res.error)
|
|
90
93
|
}
|
|
91
94
|
|
|
92
95
|
return () => {
|