Ctrl K
ComunidadGuías…

vite-plugin-lean-worker-bundle.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
import { fileURLToPath } from "node:url";
import type { Plugin } from "vite";

// Literal `new URL(..., import.meta.url)` per stub (rather than a path
// helper) so knip sees the stub files as used.
const JUST_BASH_STUB = fileURLToPath(
  new URL("./src/server/lib/just-bash-stub.ts", import.meta.url),
);
const WORKERS_AI_PROVIDER_STUB = fileURLToPath(
  new URL("./src/server/lib/workers-ai-provider-stub.ts", import.meta.url),
);
/**
 * Dependencies that must never be reachable from the workers' eager startup
 * module graphs. The 128 MB isolate limit is shared by everything evaluated at
 * startup (production OOM bursts trace back to baseline heap, not leaks), so
 * each of these is either loaded lazily behind a dynamic import or stubbed
 * out. `generateBundle` below fails the build if one sneaks back in via a
 * static import chain.
 */
const EAGER_DENYLIST: Array<{
  pattern: RegExp;
  expected: string;
  // Enforced by code splitting rather than by a stub/alias, so it cannot
  // hold in the single-module standalone build (see vite.config.ts).
  requiresCodeSplitting?: boolean;
}> = [
  {
    requiresCodeSplitting: true,
    pattern: /node_modules\/autumn-js\//,
    expected: "lazy-loaded behind the facade in src/server/billing/autumn.ts",
  },
  {
    pattern:
      /node_modules\/(workers-ai-provider|@ai-sdk\/(openai|anthropic))\//,
    expected:
      "aliased to workers-ai-provider-stub.ts (@cloudflare/think's default " +
      "provider path is dead code — our agents construct OpenRouter models)",
  },
  {
    pattern: /node_modules\/just-bash\//,
    expected:
      "aliased to just-bash-stub.ts (Think's workspace bash tool is disabled)",
  },
  {
    // The barrel (index.*) is allowed — the load hook rewrites its content to
    // re-export English only — as is en.* itself. Every other locale module
    // must stay out; if the load-hook swap ever regresses, the real barrel
    // pulls the individual locale files back in and they match here.
    pattern: /node_modules\/zod\/v4\/locales\/(?!en\.|index\.)/,
    expected:
      "replaced with an en-only barrel via the load hook below (we never " +
      "localize zod errors)",
  },
  {
    requiresCodeSplitting: true,
    // Startup CPU, not memory: every tool's zod input/output schemas and the
    // SDK's protocol schemas are built at module scope, which pushed the main
    // worker toward the 400 ms startup CPU limit.
    pattern:
      /node_modules\/@modelcontextprotocol\/server\/|\/src\/server\/mcp\/(server|transport|tools\/)/,
    expected:
      "lazy-loaded behind the dynamic imports of src/server/mcp/transport.ts " +
      "(server.ts, oauth-provider.ts, api-key-auth.ts) and samChatTools.ts " +
      "(SamChatAgent.beforeTurn)",
  },
  {
    requiresCodeSplitting: true,
    // Pre-existing boundary from the site-audit engine, guarded here too.
    pattern: /node_modules\/cheerio\//,
    expected:
      "lazy-loaded behind the page-analyzer dynamic import " +
      "(site-audit-workflow-helpers.ts)",
  },
];

/**
 * Dependencies that must stay out of the browser's entry chunk. The whole app
 * renders inside <ClientOnly>, so every page waits for that chunk to load.
 */
const CLIENT_ENTRY_DENYLIST: Array<{ pattern: RegExp; expected: string }> = [
  {
    pattern: /node_modules\/recharts\//,
    expected:
      "imported only from route components, which are code-split. Route " +
      "options like loader and validateSearch are not, so they must import " +
      "from chart-free modules",
  },
];

/**
 * Keeps the worker's eager server bundle lean, in three parts:
 *
 * 1. `resolve.alias` stubs for packages that are pure dead weight (dead code
 *    paths in @cloudflare/think).
 * 2. A `load`-hook swap of zod v4's all-languages locales barrel for an
 *    en-only barrel (the barrel is imported via relative specifiers inside
 *    zod itself, and pre-enforced resolvers in this plugin stack win the
 *    resolveId race, so matching the resolved file path in `load` is the
 *    reliable seam).
 * 3. A `generateBundle` assertion that walks the static-import closure of the
 *    worker entry chunk and fails the build if any EAGER_DENYLIST module is
 *    reachable — turning "we verified the chunk by grepping a sourcemap once"
 *    into a permanent regression test. The client build gets the same check
 *    against CLIENT_ENTRY_DENYLIST.
 * 4. An ASCII-only guarantee for the worker chunks. V8 keeps each module's
 *    source text alive for the life of the isolate, and one character outside
 *    Latin-1 makes it store the whole file at two bytes per character (the
 *    7.8 MB main chunk occupied 15.5 MB of the 128 MB heap). Rolldown escapes
 *    code (`output.charset` in vite.config.ts) but keeps JSDoc comments
 *    verbatim, so `generateBundle` escapes what is left in them.
 */
export function leanWorkerBundle(): Plugin {
  let standalone = false;
  return {
    configResolved(config) {
      standalone = config.mode === "standalone";
    },
    name: "lean-worker-bundle",
    config() {
      return {
        resolve: {
          alias: {
            // Rationale for each stub lives in its docblock. TODO: remove the
            // just-bash workaround once @cloudflare/think stops eagerly
            // importing it (https://github.com/cloudflare/agents/issues/1673).
            "just-bash": JUST_BASH_STUB,
            // Subpaths must precede the bare specifier, which would otherwise
            // prefix-match them.
            "workers-ai-provider/anthropic": WORKERS_AI_PROVIDER_STUB,
            "workers-ai-provider/openai": WORKERS_AI_PROVIDER_STUB,
            "workers-ai-provider": WORKERS_AI_PROVIDER_STUB,
          },
        },
      };
    },
    load(id) {
      // zod v4's core/classic entrypoints re-export the full locales barrel
      // (`export * as locales from "../locales/index.js"`, every language,
      // ~208 kB) into the eager bundle. Zod's default English error map
      // imports `../locales/en.js` directly and bypasses the barrel, so only
      // `z.locales.<lang>` consumers need it — and we never localize zod
      // errors. Serve an en-only barrel in its place so `z.locales.en` keeps
      // working and the other ~40 languages tree-shake away. (`./en.js`
      // resolves relative to the real barrel path, so no stub file needed.)
      const barrel = id.match(
        /node_modules\/zod\/v4\/locales\/index\.(js|cjs)$/,
      );
      if (barrel) {
        return `export { default as en } from "./en.${barrel[1]}";`;
      }
    },
    generateBundle(_options, bundle) {
      // EAGER_DENYLIST only matters for the worker builds' isolate memory; the
      // client bundle never contains these packages (and the zod swap applies
      // everywhere). "ssr" is the main worker; "open_seo_audit" is the
      // site-audit aux worker, which must stay lean for the same reason it
      // exists.
      const environment = this.environment.name;
      let denylist: typeof EAGER_DENYLIST;
      if (environment === "client") {
        denylist = CLIENT_ENTRY_DENYLIST;
      } else if (["ssr", "open_seo_audit"].includes(environment)) {
        denylist = EAGER_DENYLIST.filter(
          (rule) => !standalone || !rule.requiresCodeSplitting,
        );
      } else {
        return;
      }

      // Bundle keys are chunk fileNames already.
      const chunkAt = (fileName: string) => {
        const output = bundle[fileName];
        return output?.type === "chunk" ? output : undefined;
      };

      if (environment !== "client") {
        for (const output of Object.values(bundle)) {
          if (output.type !== "chunk") continue;
          // Code is already escaped by Rolldown; only comment text remains, and
          // a \uXXXX sequence reads the same there (and stays valid in a
          // string should one slip through). Per UTF-16 unit on purpose, so an
          // astral character becomes its surrogate pair.
          output.code = output.code.replace(
            /[\u0080-\uffff]/g,
            (unit) => `\\u${unit.charCodeAt(0).toString(16).padStart(4, "0")}`,
          );
        }
      }

      // Static-import closure from the entry chunks: everything here is
      // evaluated at isolate startup. Dynamic imports are excluded — landing
      // there is the point of the lazy boundaries.
      const eager = new Set<string>();
      const queue = Object.values(bundle)
        .filter((output) => output.type === "chunk" && output.isEntry)
        .map((chunk) => chunk.fileName);
      while (queue.length > 0) {
        const fileName = queue.pop();
        if (fileName === undefined || eager.has(fileName)) continue;
        eager.add(fileName);
        queue.push(...(chunkAt(fileName)?.imports ?? []));
      }

      const violations: string[] = [];
      for (const rule of denylist) {
        for (const fileName of eager) {
          const hits =
            chunkAt(fileName)?.moduleIds.filter((id) =>
              rule.pattern.test(id),
            ) ?? [];
          if (hits.length > 0) {
            violations.push(
              `${hits[0]}${hits.length > 1 ? ` (+${hits.length - 1} more modules)` : ""}\n` +
                `  reached the eager startup graph via chunk ${fileName}\n` +
                `  expected: ${rule.expected}`,
            );
          }
        }
      }

      if (violations.length > 0) {
        this.error(
          `[lean-worker-bundle] denylisted module(s) in the ${environment} ` +
            `build's eager startup graph:\n\n${violations.join("\n\n")}\n\n` +
            `Restore the lazy/stub boundary, or update the denylist in ` +
            `vite-plugin-lean-worker-bundle.ts if this is intentional.`,
        );
      }
    },
  };
}