diff --git a/packages/cli/script/build.ts b/packages/cli/script/build.ts index e81731d7a..d72433ea2 100644 --- a/packages/cli/script/build.ts +++ b/packages/cli/script/build.ts @@ -37,6 +37,7 @@ import { gzip } from "node:zlib"; import { build as esbuild } from "esbuild"; import { uploadSourcemaps } from "../src/lib/api/sourcemaps.js"; import { injectDebugId, PLACEHOLDER_DEBUG_ID } from "./debug-id.js"; +import { zodEnglishLocalesPlugin } from "./lib/zod-locales-plugin.js"; import { textImportPlugin } from "./text-import-plugin.js"; const gzipAsync = promisify(gzip); @@ -160,6 +161,7 @@ async function bundleJs(): Promise { }, plugins: [ textImportPlugin, + zodEnglishLocalesPlugin, // Transform _require() → require() so esbuild resolves lazy relative // requires at bundle time. In tsx dev mode, _require is a file-local // createRequire(import.meta.url) that resolves relative to the file. diff --git a/packages/cli/script/bundle.ts b/packages/cli/script/bundle.ts index d6624cfca..fff32d363 100644 --- a/packages/cli/script/bundle.ts +++ b/packages/cli/script/bundle.ts @@ -6,6 +6,7 @@ import { build, type Plugin } from "esbuild"; import pkg from "../package.json"; import { uploadSourcemaps } from "../src/lib/api/sourcemaps.js"; import { injectDebugId, PLACEHOLDER_DEBUG_ID } from "./debug-id.js"; +import { zodEnglishLocalesPlugin } from "./lib/zod-locales-plugin.js"; import { buildCoreDeclarations, SDK_TYPES_PATH } from "./sdk-declarations.js"; import { textImportPlugin } from "./text-import-plugin.js"; @@ -253,6 +254,7 @@ const zlibNamespaceShimPlugin: Plugin = { const plugins: Plugin[] = [ sentrySourcemapPlugin, + zodEnglishLocalesPlugin, textImportPlugin, requireAliasPlugin, ]; diff --git a/packages/cli/script/lib/zod-locales-plugin.ts b/packages/cli/script/lib/zod-locales-plugin.ts new file mode 100644 index 000000000..21ee6ba15 --- /dev/null +++ b/packages/cli/script/lib/zod-locales-plugin.ts @@ -0,0 +1,56 @@ +/** + * esbuild plugin that bundles only Zod's English error messages. + * + * Zod 4's `core`, `classic`, and `mini` entry points all re-export + * `locales/index.js`, which statically imports every locale (about 260 KB + * minified). Zod only uses English unless code calls + * `z.config(z.locales.xx())`, and nothing we bundle does. This plugin swaps + * that index for one that exports only `en`, so `z.locales.en` still works + * and error messages are unchanged. `classic/schemas.js` imports `en.js` + * directly to set the default locale, so that path is not touched. + * + * Shared by the CLI bundles (`script/bundle.ts`, `script/build.ts`), which + * inline `@sentry/mcp-core` for `sentry mcp`. + */ + +import { existsSync } from "node:fs"; +import { dirname, join } from "node:path"; +import type { Plugin } from "esbuild"; + +const ZOD_LOCALES_NS = "zod-en-locales"; +const LOCALES_INDEX_FILTER = /[\\/]locales[\\/]index\.c?js$/; +const ANY_FILTER = /.*/; +/** Matches a file inside an installed `zod` package (npm or pnpm layout). */ +const ZOD_PACKAGE_RE = /[\\/]node_modules[\\/]zod[\\/]/; + +export const zodEnglishLocalesPlugin: Plugin = { + name: "zod-english-locales", + setup(build) { + build.onResolve({ filter: LOCALES_INDEX_FILTER }, (args) => { + if (!ZOD_PACKAGE_RE.test(`${args.resolveDir}/`)) { + return; + } + // `zod/locales/index.js` re-exports `../v4/locales/index.js`, so both + // resolve here. Keep the importer's module format: `.cjs` for require. + const indexPath = join(args.resolveDir, args.path); + const enPath = join( + dirname(indexPath), + indexPath.endsWith(".cjs") ? "en.cjs" : "en.js", + ); + if (!existsSync(enPath)) { + // Fail loudly if Zod changes its layout, rather than shipping a + // locales namespace without English. + return { + errors: [{ text: `Zod English locale not found at ${enPath}` }], + }; + } + return { path: enPath, namespace: ZOD_LOCALES_NS }; + }); + + build.onLoad({ filter: ANY_FILTER, namespace: ZOD_LOCALES_NS }, (args) => ({ + contents: `export { default as en } from ${JSON.stringify(args.path)};`, + resolveDir: dirname(args.path), + loader: "js", + })); + }, +}; diff --git a/packages/cli/test/script/zod-locales-plugin.test.ts b/packages/cli/test/script/zod-locales-plugin.test.ts new file mode 100644 index 000000000..ab61757f2 --- /dev/null +++ b/packages/cli/test/script/zod-locales-plugin.test.ts @@ -0,0 +1,119 @@ +/** + * Tests for the esbuild plugin that bundles only Zod's English locale. + * + * Builds a small entry against the real `zod` package (resolved from + * `@sentry/mcp-core`, which depends on it) with and without the plugin, then + * runs both bundles and compares their error messages. + */ + +import { createRequire } from "node:module"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { runInNewContext } from "node:vm"; +import { build } from "esbuild"; +import { describe, expect, test } from "vitest"; +import { zodEnglishLocalesPlugin } from "../../script/lib/zod-locales-plugin.js"; + +const MCP_CORE_DIR = join( + dirname(fileURLToPath(import.meta.url)), + "../../../mcp-core", +); + +/** `import` resolves Zod's ESM build and `require` its CJS build. */ +const IMPORTS = { + esm: `import { z } from "zod";`, + cjs: `const { z } = require("zod");`, +}; + +const BODY = ` +const issues = [ + z.string().safeParse(1), + z.object({ n: z.number().min(5) }).safeParse({ n: 1 }), + z.enum(["a", "b"]).safeParse("c"), +].flatMap((r) => r.error.issues.map((i) => i.message)); +module.exports = { issues, locales: Object.keys(z.locales) }; +`; + +type BundleResult = { + bytes: number; + issues: string[]; + locales: string[]; +}; + +async function bundleZod( + format: "cjs" | "esm", + withPlugin: boolean, +): Promise { + const result = await build({ + stdin: { + contents: IMPORTS[format] + BODY, + resolveDir: MCP_CORE_DIR, + loader: "js", + }, + bundle: true, + write: false, + platform: "node", + format: "cjs", + logLevel: "silent", + plugins: withPlugin ? [zodEnglishLocalesPlugin] : [], + }); + const code = result.outputFiles[0]?.text ?? ""; + const sandbox = { + module: { exports: {} }, + require: createRequire(import.meta.url), + }; + runInNewContext(code, sandbox); + const { issues, locales } = sandbox.module.exports as Omit< + BundleResult, + "bytes" + >; + return { bytes: code.length, issues, locales }; +} + +describe("zodEnglishLocalesPlugin", () => { + test.each(["esm", "cjs"] as const)( + "keeps English messages and drops other locales (%s)", + async (format) => { + const full = await bundleZod(format, false); + const english = await bundleZod(format, true); + + expect(english.issues).toEqual(full.issues); + expect(english.issues[0]).toBe( + "Invalid input: expected string, received number", + ); + expect(full.locales).toContain("fr"); + expect(english.locales).toEqual(["en"]); + // All other locales together are well over 100 KB unminified. + expect(full.bytes - english.bytes).toBeGreaterThan(100_000); + }, + ); + + test("leaves non-Zod locales/index.js imports alone", async () => { + const result = await build({ + stdin: { + contents: `import { en } from "./locales/index.js"; module.exports = en;`, + resolveDir: "/nonexistent", + loader: "js", + }, + bundle: true, + write: false, + logLevel: "silent", + plugins: [ + zodEnglishLocalesPlugin, + { + name: "fake-locales", + setup(b) { + b.onResolve({ filter: /locales/ }, () => ({ + path: "fake", + namespace: "fake", + })); + b.onLoad({ filter: /.*/, namespace: "fake" }, () => ({ + contents: `export const en = "fake";`, + })); + }, + }, + ], + }); + expect(result.outputFiles[0]?.text).toContain('"fake"'); + }); +});