## 为什么
原设计让 Rust 壳自己实现一份 client(crates/nodeagent-client),代价是**两套协议实现静默漂移**
(改一处漏一处时构建/单测都不报错,只在真机暴露)。sidecar 让 Rust 壳退化为
「进程管理 + 消息转发」,协议实现只剩 TS 一份,从根上消除漂移。
## 本次交付
1. `apps/console/sidecar/main.mjs`(190 行):把 `@nodeagent/client` 暴露成
**JSON-RPC 2.0 over stdio**(行分隔)。方法 connect/disconnect/invoke/state;
onStateChange/onLog/onEvent 三个回调 → `event` 通知(kind=state/log/agent_event)
2. `apps/console/sidecar/build.mjs`:esbuild **JS API** 打成单文件(避开 pnpm `.bin` 的
ENOENT 坑),并注入 `createRequire` shim —— 否则 `ws` 的动态 require 会报
"Dynamic require of events is not supported"
3. `apps/console/package.json`:加 `@nodeagent/client`/`protocol` 依赖 + sidecar:dev/build 脚本
4. `docs/console-plan.md`:补「实施决策」节(决策理由、协议定义、进度表、打包方向)
## 两条协议纪律(写进注释与文档)
- **stdout 只写协议消息**,日志一律 stderr(混入非 JSON 行会让 Rust 侧解析失败)
- 任何异常都转成响应,**绝不因单个请求崩进程**
## 错误形态归一(实测中修正)
被控端对未知能力/ACL 拒绝/超时是回 JSON-RPC error → `client.invoke()` 抛异常。
若原样上抛前端要处理两种形态,故归一为 `{status:'failed',error:{name,message,data?}}`;
而「未连接/缺参数」仍走协议错误(语义不同)。
## 验证(真实链路,非 mock)
本地 agent → sidecar **8/8 全绿**:connect 回传 51 项能力与 agent_version ✓ ·
invoke system.info ✓ · state 反映已连接 ✓ · 事件通知已推送 ✓ ·
非法能力返回 failed 且不崩 ✓ · disconnect ✓ · **stdout 无日志污染** ✓
打包产物拷到仓库外**独立运行通过**(227KB 单文件自足)。
## 待办(下一批)
Rust 壳改造(command 签名不变 → 前端零改动)· tauri.conf.json 的 resource 配置 +
Node 运行时打包 · 退役 crates/nodeagent-client
53 lines
2.2 KiB
JavaScript
53 lines
2.2 KiB
JavaScript
#!/usr/bin/env node
|
||
/**
|
||
* 把 sidecar 打成**单文件**(供 Tauri 打包进应用)。
|
||
*
|
||
* 用法:node apps/console/sidecar/build.mjs [--out <dir>]
|
||
*
|
||
* 为什么用 esbuild 的 **JS API** 而不是 spawn `node_modules/.bin/esbuild`:
|
||
* pnpm 的 `.bin` 布局下 spawn 该路径会 ENOENT(scripts/pack.mjs 真机踩过)。
|
||
* 用 API 则从模块解析走,与包管理器布局无关。
|
||
*
|
||
* 依赖处理:`@nodeagent/client` 与 `@nodeagent/protocol` 是 workspace 包,
|
||
* **一并 bundle 进去**(sidecar 要作为独立文件被 Tauri 拉起,不能依赖仓库 node_modules)。
|
||
* `ws` 亦被打入(它是纯 JS)。Node 内置模块(readline/process)保持 external。
|
||
*/
|
||
|
||
import { build } from 'esbuild';
|
||
import { mkdirSync } from 'node:fs';
|
||
import { dirname, join } from 'node:path';
|
||
import { fileURLToPath } from 'node:url';
|
||
|
||
const here = dirname(fileURLToPath(import.meta.url));
|
||
const argv = process.argv.slice(2);
|
||
const outIdx = argv.indexOf('--out');
|
||
const outFile = join(outIdx >= 0 ? argv[outIdx + 1] : join(here, 'dist'), 'sidecar.mjs');
|
||
|
||
mkdirSync(dirname(outFile), { recursive: true });
|
||
|
||
const result = await build({
|
||
entryPoints: [join(here, 'main.mjs')],
|
||
outfile: outFile,
|
||
bundle: true,
|
||
platform: 'node',
|
||
format: 'esm',
|
||
target: 'node20',
|
||
// Node 内置模块保持外部引用;其余(含 ws / workspace 包)全部内联
|
||
external: ['node:*'],
|
||
// ⚠️ CJS 依赖(如 ws)里有**动态 require**(`require('events')`),esbuild 打成 ESM 后
|
||
// 无法转换 → 运行时报 "Dynamic require of X is not supported"。注入 createRequire shim
|
||
// 是标准解法:让 bundle 内的 `require(...)` 走真实的 CJS 解析。
|
||
banner: {
|
||
js:
|
||
'// nodeagent console sidecar(由 apps/console/sidecar/build.mjs 生成,请勿手工编辑)\n' +
|
||
"import { createRequire as __naCreateRequire } from 'node:module';\n" +
|
||
'const require = __naCreateRequire(import.meta.url);',
|
||
},
|
||
legalComments: 'none',
|
||
minify: false, // 保持可读:出问题时能直接看堆栈
|
||
metafile: true,
|
||
});
|
||
|
||
const bytes = Object.values(result.metafile.outputs)[0]?.bytes ?? 0;
|
||
console.log(`✓ sidecar 已打包: ${outFile} ${(bytes / 1024).toFixed(0)} KB`);
|