bundle-openclaw.mjs 8.8 KB
/**
 * bundle-openclaw.mjs — 从 openclaw 源码中 bundle 出核心插件模块
 *
 * 用法:node scripts/bundle-openclaw.mjs
 *
 * 产出:openclaw-dist/core-bundle.mjs(自包含,无外部依赖)
 *
 * 原理:
 *   openclaw 的 dist/ 是 TS→JS 的 1:1 编译产物,模块间保留了原始 import 关系,
 *   依赖 openclaw/node_modules 里的大量第三方包。
 *   本脚本用 esbuild 把 bootstrap.js 需要的函数从 dist/ 中提取并打成
 *   一个自包含的 ESM bundle,所有内部 import 和第三方依赖全部内联。
 *
 *   含 native addon 的包(koffi, @napi-rs/canvas 等)标记为 external,
 *   它们不是核心加载流程所需的,运行时缺失只会导致特定功能降级。
 */

import esbuild from 'esbuild';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(__dirname, '..');
const OPENCLAW_ROOT = path.resolve(ROOT, 'openclaw');
const OUTPUT_DIR = path.resolve(ROOT, 'openclaw-dist');
const OUTPUT_FILE = path.join(OUTPUT_DIR, 'core-bundle.mjs');

// bootstrap.js 需要的所有导出
const BUNDLE_ENTRY = `
export {
  normalizePluginsConfig,
  applyTestPluginDefaults,
  resolveEnableState,
  resolveMemorySlotDecision,
} from './dist/plugins/config-state.js';
export { discoverOpenClawPlugins } from './dist/plugins/discovery.js';
export { initializeGlobalHookRunner } from './dist/plugins/hook-runner-global.js';
export { loadPluginManifestRegistry } from './dist/plugins/manifest-registry.js';
export { createPluginRegistry } from './dist/plugins/registry.js';
export { setActivePluginRegistry } from './dist/plugins/runtime.js';
export { createPluginRuntime } from './dist/plugins/runtime/index.js';
export { validateJsonSchemaValue } from './dist/plugins/schema-validator.js';
export { clearPluginCommands } from './dist/plugins/commands.js';
export { resolveUserPath } from './dist/utils.js';
`;

// 含 native addon 或平台专属的包 → 替换为空模块 stub
// 不能用 external(ESM import 在 link 阶段找不到包会直接崩)
// 改用 esbuild plugin 把这些 import 指向空模块
const NATIVE_STUB_PACKAGES = [
  'koffi',
  '@napi-rs/canvas',
  '@reflink/reflink',
  '@lydell/node-pty',
  '@node-llama-cpp',
  'node-llama-cpp',
  'playwright-core',
  '@homebridge/ciao',
  'libsignal-client',
  'better-sqlite3',
];

async function main() {
  console.log('=== Bundle OpenClaw 核心模块 ===\n');

  if (!fs.existsSync(path.join(OPENCLAW_ROOT, 'dist'))) {
    console.error(`❌ openclaw/dist/ 不存在,请先在 openclaw 目录执行构建`);
    process.exit(1);
  }

  fs.mkdirSync(OUTPUT_DIR, { recursive: true });
  console.log(`源目录: ${OPENCLAW_ROOT}`);
  console.log(`输出文件: ${OUTPUT_FILE}\n`);

  const t0 = Date.now();

  // esbuild plugin:把 native addon 包和 .node 文件替换为空模块
  const stubNativePlugin = {
    name: 'stub-native',
    setup(build) {
      // .node 文件 → 空模块
      build.onResolve({ filter: /\.node$/ }, () => ({
        path: 'stub',
        namespace: 'native-stub',
      }));

      // native 包 → 空模块
      const packageFilter = new RegExp(
        `^(${NATIVE_STUB_PACKAGES.map(p => p.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|')})(/.*)?$`
      );
      build.onResolve({ filter: packageFilter }, (args) => {
        return { path: args.path, namespace: 'native-stub' };
      });

      // 空模块的内容 — 用 CJS 格式 + Proxy 兜底所有 named import
      build.onLoad({ filter: /.*/, namespace: 'native-stub' }, () => ({
        contents: `
          const handler = { get: (_, prop) => prop === '__esModule' ? true : (() => null) };
          const stub = new Proxy({}, handler);
          module.exports = stub;
          module.exports.default = stub;
        `,
        loader: 'js',
      }));
    },
  };

  const result = await esbuild.build({
    stdin: {
      contents: BUNDLE_ENTRY,
      resolveDir: OPENCLAW_ROOT,
      loader: 'js',
    },
    bundle: true,
    format: 'esm',
    platform: 'node',
    target: 'node18',
    outfile: OUTPUT_FILE,
    // 注入 createRequire,让 CJS 包(如 debug)在 ESM bundle 中也能 require Node 内置模块
    banner: {
      js: `import { createRequire as __bundleCreateRequire } from 'node:module';
import { fileURLToPath as __bundleFileURLToPath } from 'node:url';
import { dirname as __bundleDirname } from 'node:path';
const require = __bundleCreateRequire(import.meta.url);
const __filename = __bundleFileURLToPath(import.meta.url);
const __dirname = __bundleDirname(__filename);`,
    },
    external: [
      // 所有 Node 内置模块(动态获取,避免遗漏)
      'node:*',
      ...(await import('node:module')).default.builtinModules,
    ],
    plugins: [stubNativePlugin],
    treeShaking: true,
    sourcemap: false,
    logLevel: 'warning',
    logOverride: {
      'indirect-require': 'silent',
    },
  });

  if (result.errors.length > 0) {
    console.error('❌ 构建错误:');
    result.errors.forEach(e => console.error(e));
    process.exit(1);
  }

  if (result.warnings.length > 0) {
    console.warn(`⚠️  ${result.warnings.length} 个警告(忽略)`);
  }

  const stat = fs.statSync(OUTPUT_FILE);
  const sizeMB = (stat.size / 1024 / 1024).toFixed(2);
  const elapsed = ((Date.now() - t0) / 1000).toFixed(2);

  console.log(`✅ Bundle 完成: ${OUTPUT_FILE}`);
  console.log(`   大小: ${sizeMB} MB`);
  console.log(`   耗时: ${elapsed}s`);

  // --- 同步 extensions ---
  console.log('\n--- 同步 extensions ---');
  const feishuSrc = path.join(OPENCLAW_ROOT, 'extensions/feishu');
  const feishuDst = path.join(OUTPUT_DIR, 'extensions/feishu');

  if (fs.existsSync(feishuSrc)) {
    const { execSync } = await import('node:child_process');
    fs.mkdirSync(path.dirname(feishuDst), { recursive: true });
    execSync(
      `rsync -a --delete --exclude='.esbuild-cache' --exclude='*.test.*' --exclude='node_modules' "${feishuSrc}/" "${feishuDst}/"`,
      { stdio: 'inherit' },
    );
    console.log('✅ feishu extension 已同步');
  }

  // --- Bundle plugin-sdk(给 feishu 编译时 alias 用)---
  console.log('\n--- Bundle plugin-sdk ---');
  const pluginSdkEntry = (() => {
    // src 版本比 dist 更完整(dist 可能缺少部分 re-export)
    const src = path.join(OPENCLAW_ROOT, 'src/plugin-sdk/index.ts');
    const dist = path.join(OPENCLAW_ROOT, 'dist/plugin-sdk/index.js');
    return fs.existsSync(src) ? src : dist;
  })();
  const pluginSdkAccountIdEntry = (() => {
    const src = path.join(OPENCLAW_ROOT, 'src/plugin-sdk/account-id.ts');
    const dist = path.join(OPENCLAW_ROOT, 'dist/plugin-sdk/account-id.js');
    return fs.existsSync(src) ? src : dist;
  })();
  const pluginSdkOutDir = path.join(OUTPUT_DIR, 'plugin-sdk');
  fs.mkdirSync(pluginSdkOutDir, { recursive: true });

  if (fs.existsSync(pluginSdkEntry)) {
    await esbuild.build({
      entryPoints: [pluginSdkEntry],
      bundle: true,
      format: 'esm',
      platform: 'node',
      target: 'node18',
      outfile: path.join(pluginSdkOutDir, 'index.mjs'),
      banner: {
        js: `import { createRequire as __bundleCreateRequire } from 'node:module';
const require = __bundleCreateRequire(import.meta.url);`,
      },
      external: ['node:*', ...(await import('node:module')).default.builtinModules],
      plugins: [stubNativePlugin],
      treeShaking: true,
      sourcemap: false,
      logLevel: 'warning',
      logOverride: { 'indirect-require': 'silent' },
    });
    console.log(`✅ plugin-sdk/index.mjs bundle 完成`);
  }

  if (fs.existsSync(pluginSdkAccountIdEntry)) {
    await esbuild.build({
      entryPoints: [pluginSdkAccountIdEntry],
      bundle: true,
      format: 'esm',
      platform: 'node',
      target: 'node18',
      outfile: path.join(pluginSdkOutDir, 'account-id.mjs'),
      banner: {
        js: `import { createRequire as __bundleCreateRequire } from 'node:module';
const require = __bundleCreateRequire(import.meta.url);`,
      },
      external: ['node:*', ...(await import('node:module')).default.builtinModules],
      plugins: [stubNativePlugin],
      treeShaking: true,
      sourcemap: false,
      logLevel: 'warning',
      logOverride: { 'indirect-require': 'silent' },
    });
    console.log(`✅ plugin-sdk/account-id.mjs bundle 完成`);
  }

  // --- 写 package.json ---
  const pkgJson = {
    name: 'openclaw-dist',
    private: true,
    type: 'module',
    description: 'Bundled OpenClaw core modules for multi-agent-system',
  };
  fs.writeFileSync(
    path.join(OUTPUT_DIR, 'package.json'),
    JSON.stringify(pkgJson, null, 2) + '\n',
  );
  console.log('✅ package.json 已写入');

  console.log('\n=== 完成 ===');
  console.log(`产出目录: ${OUTPUT_DIR}`);
}

main().catch(err => {
  console.error('❌', err.message || err);
  process.exit(1);
});