Move/rename the transport/bridge code into a new @snowluma/channel layout and extract hook implementations into channel-specific packages. Many bridge sources were deleted and equivalent channel files added (adapter/ctx/interface/channel/hook/socket pieces moved/renamed), along with tests and configs relocated under packages/channel. RoadMap.md replaced with a detailed v3 architecture plan describing Channel/ChannelCtx/Core/CoreCtx/Hub boundaries and migration steps. Also apply related core package updates (account/core reshaping and package.json adjustments) to match the new architecture.
20 KiB
SnowLuma v3 — 目标架构
v2 已完成:UID/UIN Service(SQLite 持久化)、tsx+map Debug、包拆分、proto-defs 剥离、消息 hot-path 优化(17.5μs → 4.7μs)、NapCat 风格 Api 层、Bridge/Account 分层(v2 发布在
@snowluma/bridge,v3 重命名为@snowluma/channel)。
0. 数据流总览(一图概览所有权)
┌─ @snowluma/channel-hook ─────────┐ ┌─ @snowluma/channel-socket ──────┐
│ HookManager + HookChannel │ │ SocketChannel (future) │
│ — 仅 send/recv + pid/hook state — │ │ — 仅 send/recv + socket state — │
└────────────┬─────────────────────┘ └─────────────┬───────────────────┘
│ Channel 实例 │
└───────────┬──────────────────────────┘
│ 通过 ChannelAdapter 注册
▼
┌─ @snowluma/core ──── Hub(主入口 / 持有一切)──────────────┐
│ │
│ 内部:Map<uin, Core> │
│ │
│ channel available ──► ctx = capability(channel) │
│ ──► core = new Core(ctx) │
│ ──► emit 'core-online' { uin, apis, events, ... } │
│ │
│ channel gone ──► core.dispose() + emit 'core-offline' { uin } │
└─────────────────────────────────────┬─────────────────────────────────┘
│ 顶层 lifecycle 事件(仅 2 个)
▼
┌─ @snowluma/onebot ──────────────────────────────────────────────────────┐
│ hub.events.on('core-online', ctx => { │
│ this.byUin.set(ctx.uin, ctx); │
│ ctx.events.on('group_message', e => …); // 每账号 bus │
│ }); │
│ // 处理 OneBot action: │
│ this.byUin.get(uin)?.apis.message.sendGroup(...) │
└─────────────────────────────────────────────────────────────────────────┘
核心约定:
Channel/ adapter 包 只做 send/recv + 自己的内部状态(pid set / hook session / socket fd)。完全无业务。Hub是主入口,独占持有 Channels + Cores。Core实例由它构造,外部拿不到 Core 类本身。Core通过 ctx(capability,一个 POJO)拿到 send/recv,从不直接持有 Channel。- 外部消费者(OneBot / WebUI)只拿
CoreCtx(Core 的对外投影:{ uin, apis, events, identity }),不知道 Core 类的存在。 - 每个账号一个
events(在 Core 上),不做全局 bus;Hub 只 emit 两个顶层生命周期事件。
1. 目标包结构
SnowLuma/
├── packages/
│ │
│ │ ── Infrastructure(无业务) ──
│ ├── common/ logger / packet-sender / protocol-types / async-context
│ ├── proto-defs/ protobuf TS 接口定义
│ ├── proton/ protobuf codec 编译时生成器
│ ├── runtime/ native .node 产物清单
│ ├── ui/ Tailwind v4 + shadcn 组件(前端)
│ │
│ │ ── Protocol(解析层,纯函数) ──
│ ├── protocol/ events / identity-service / msg-push / oidb / highway /
│ │ element-builder / packet-pipeline / event-bus
│ │
│ │ ── Channel & adapters(transport 端口与实现) ──
│ ├── channel/ ⭐ Channel 接口 + 抽象类 + ChannelAdapter 端口
│ │ —— 只定义接口和最小抽象,**不**含 Hub
│ ├── channel-hook/ ⭐ HookManager + HookChannel + HookAdapter
│ │ —— 现 @snowluma/channel 大部分内容搬来
│ ├── channel-socket/ ⭐ 未来:SocketChannel + SocketAdapter
│ │
│ │ ── Core(主入口 + 业务封装) ──
│ ├── core/ ⭐ Core + Hub + CoreCtx + ApiHub + 协议封装
│ │ —— Hub 在这里(避免 channel↔core 循环)
│ │
│ │ ── Inbound(外部协议消费 CoreCtx) ──
│ ├── onebot/ OneBot v11 (HTTP + WS)
│ ├── websocket/ 底层 WS server(被 onebot / webui-backend 共用)
│ ├── webui-backend/ ⭐ Hono server + REST/WS(拆自 webui)
│ ├── webui-frontend/ ⭐ Vite SPA(拆自 webui)
│ └── sdk/ TS SDK
│
├── package.json
└── pnpm-workspace.yaml
⭐ 标记的是 v3 改动。
1.1 关键变动
| 当前 | 目标 | 备注 |
|---|---|---|
@snowluma/bridge(hook + transport + adapter + manager) |
@snowluma/channel(仅接口 + abstract Channel)+ @snowluma/channel-hook(hook 实现) |
包名 v2 叫 bridge,v3 重命名;hook 全套迁出独立包 |
@snowluma/core(含 Account + AccountManager) |
core:Core + Hub |
Account → Core;AccountManager 折进 Hub |
| —— | channel-socket |
新建 stub |
@snowluma/webui |
webui-backend + webui-frontend |
前后端拆分 |
1.2 依赖 DAG(无环)
common ◄── proton, proto-defs, protocol, runtime, ui, channel
proto-defs ◄── protocol
proton ◄── protocol
protocol ◄── core
channel ◄── channel-hook, channel-socket, core
channel-hook ◄── core
channel-socket ◄── core
core ◄── onebot, webui-backend
websocket ◄── onebot, webui-backend
ui ◄── webui-frontend
核心要点:core 是唯一一处同时知道 channel 端口 + 业务实现的地方。Channel 包不反向依赖 core,因此 Hub 住在 core,而不是 channel —— 名字保留 Hub 是因为它语义上仍是「channel 集群的指挥官」。
2. 三个关键类型 / 接口(设计核心)
整个系统由 3 个类型 串起来。每个职责单一、边界清晰。
2.1 Channel(在 @snowluma/channel)—— 纯 transport
// packages/channel/src/channel.ts
export type ChannelKind = 'inject' | 'socket';
export interface Channel {
readonly kind: ChannelKind;
readonly uin: string;
readonly id: string; // adapter:uin 形式
sendRawPacket(cmd: string, body: Uint8Array): Promise<SendPacketResult>;
setPacketHandler(h: ((pkt: PacketInfo) => void) | null): void;
deliverPacket(pkt: PacketInfo): void; // adapter 内部用
dispose(): void;
}
Adapter 包(channel-hook / channel-socket)的具体类(HookChannel / SocketChannel)实现 Channel。它们的内部状态(PID set、hook session、socket fd)对外不可见。
2.2 ChannelCtx(在 @snowluma/channel)—— channel → core 的能力 POJO
// packages/channel/src/channel-ctx.ts
export interface ChannelCtx {
readonly uin: string;
readonly kind: ChannelKind;
readonly sendRawPacket: (cmd: string, body: Uint8Array) => Promise<SendPacketResult>;
readonly onPacket: (h: (pkt: PacketInfo) => void) => void;
readonly dispose: () => void;
}
// Hub 用 makeCtx(channel) 把 Channel 投影成 ChannelCtx:
export function makeChannelCtx(b: Channel): ChannelCtx {
return {
uin: b.uin, kind: b.kind,
sendRawPacket: (c, x) => b.sendRawPacket(c, x),
onPacket: (h) => b.setPacketHandler(h),
dispose: () => b.dispose(),
};
}
关键设计:Core 只接收 ChannelCtx(capability POJO),不持有 Channel 类引用。这样 core 包不依赖任何 adapter 实现细节,连 Channel 抽象类都不用 import。
2.3 CoreCtx(在 @snowluma/core)—— core → onebot 的能力 POJO
// packages/core/src/core-ctx.ts
export interface CoreCtx {
readonly uin: string;
readonly kind: ChannelKind;
readonly apis: ApiHub; // 所有 typed api(message/contacts/...)
readonly events: EventBus; // 该账号的事件(per-uin)
readonly identity: IdentityService; // UID/UIN/group roster
}
关键设计:OneBot 拿到的就是这个 POJO 投影,不持有 Core 类引用。Hub 在 core-online 事件里把 CoreCtx 透出去;OneBot 按 uin 存起来用。
3. Hub —— 主入口完整实现
所有所有权、生命周期、装配都在这里。外部 main.ts 只跟它打交道。
// packages/core/src/hub.ts
import { Channel, ChannelAdapter, ChannelAdapterHost, makeChannelCtx } from '@snowluma/channel';
import { Core } from './core';
import type { CoreCtx } from './core-ctx';
import { TypedEmitter } from '@snowluma/common/typed-emitter';
type Lifecycle = {
'core-online': CoreCtx;
'core-offline': { uin: string };
};
export class Hub {
private readonly cores = new Map<string, Core>();
private readonly adapters: ChannelAdapter[] = [];
readonly events = new TypedEmitter<Lifecycle>();
registerAdapter(a: ChannelAdapter): void {
this.adapters.push(a);
}
async start(): Promise<void> {
const host: ChannelAdapterHost = {
onChannelReady: (b) => this.onChannelReady(b),
onChannelGone: (uin) => this.onChannelGone(uin),
};
await Promise.all(this.adapters.map((a) => a.start(host)));
}
async stop(): Promise<void> {
await Promise.all(this.adapters.map((a) => a.stop()));
for (const c of this.cores.values()) c.dispose();
this.cores.clear();
}
// ─── private ───────────────────────────────
private onChannelReady(channel: Channel): void {
const stale = this.cores.get(channel.uin);
if (stale) {
stale.dispose();
this.events.emit('core-offline', { uin: channel.uin });
}
const core = new Core(makeChannelCtx(channel));
this.cores.set(channel.uin, core);
this.events.emit('core-online', core.publicCtx);
}
private onChannelGone(uin: string): void {
const core = this.cores.get(uin);
if (!core) return;
core.dispose();
this.cores.delete(uin);
this.events.emit('core-offline', { uin });
}
}
4. Core 实现要点
// packages/core/src/core.ts
import type { ChannelCtx } from '@snowluma/channel';
import { EventBus } from '@snowluma/protocol/event-bus';
import { IdentityService } from '@snowluma/protocol/identity-service';
import { IncomingPacketPipeline } from '@snowluma/protocol/packet-pipeline';
import { buildApiHub, type ApiHub } from './apis';
import type { CoreCtx } from './core-ctx';
export class Core {
readonly events = new EventBus(); // ← 每账号一个 bus
readonly identity: IdentityService;
readonly apis: ApiHub;
private readonly pipeline: IncomingPacketPipeline;
constructor(private readonly channelCtx: ChannelCtx) {
this.identity = IdentityService.openForUin(channelCtx.uin);
this.pipeline = new IncomingPacketPipeline(this.events, this.identity);
this.apis = buildApiHub(this); // apis 内部持 Core 引用,调 sendRawPacket
channelCtx.onPacket((pkt) => this.pipeline.process(pkt));
}
// 给外部消费者的投影(只读 POJO,不暴露 Core 实例)
get publicCtx(): CoreCtx {
return {
uin: this.channelCtx.uin,
kind: this.channelCtx.kind,
apis: this.apis,
events: this.events,
identity: this.identity,
};
}
// apis 内部通过 core 调发包
sendRawPacket(cmd: string, body: Uint8Array) {
return this.channelCtx.sendRawPacket(cmd, body);
}
dispose(): void {
this.channelCtx.dispose();
this.identity.close();
}
}
5. OneBot 消费 CoreCtx
// packages/onebot/src/manager.ts
import type { Hub, CoreCtx } from '@snowluma/core';
export class OneBotManager {
private readonly byUin = new Map<string, CoreCtx>();
bind(hub: Hub): void {
hub.events.on('core-online', (ctx) => this.attach(ctx));
hub.events.on('core-offline', ({ uin }) => this.detach(uin));
}
private attach(ctx: CoreCtx): void {
this.byUin.set(ctx.uin, ctx);
ctx.events.on('group_message', (e) => this.dispatchGroupMessage(ctx, e));
ctx.events.on('private_message',(e) => this.dispatchPrivateMessage(ctx, e));
// ... 其它 kind
}
private detach(uin: string): void {
this.byUin.delete(uin);
// ctx.events 随 Core dispose 一起释放
}
// 处理 OneBot HTTP/WS action:用 uin 查到 ctx,直接调 apis
async handleSendGroupMsg(uin: string, groupId: number, msg: OneBotMsg) {
const ctx = this.byUin.get(uin);
if (!ctx) throw new Error(`unknown uin: ${uin}`);
return ctx.apis.message.sendGroup(groupId, msg); // ← 直接调封装好的 api
}
}
6. 主入口(packages/core/src/index.ts)
import { HookAdapter } from '@snowluma/channel-hook';
import { OneBotManager } from '@snowluma/onebot';
import { Hub } from './hub';
const hub = new Hub();
hub.registerAdapter(new HookAdapter({ autoLoadOnDiscovery: true }));
// hub.registerAdapter(new SocketAdapter()); // 未来
const onebot = new OneBotManager();
onebot.bind(hub);
await hub.start();
await initWebUI(port, onebot, /* hookManager from adapter */);
9 行装配,每行都是有意义的所有权声明。
7. 为什么这样设计优雅
7.1 单向数据流
adapter → Channel → ChannelCtx → Core → CoreCtx → OneBot
transport capability capability
没有反向引用:Core 不知道 Channel 类,OneBot 不知道 Core 类。每层只看到「下一层的能力 POJO」。
7.2 单一所有者
Hub.cores: Map<uin, Core> 是 唯一 持有 Core 的地方。OneBot 只持 CoreCtx(投影,不算所有权)。Core 销毁 = 从这个 Map 里删 = 所有 ctx 同时失效。无需引用计数、无需 GC 协调。
7.3 每账号独立 EventBus
Core 自带 events: EventBus,互不干扰。OneBot 通过 core-online 拿到 ctx 后立即 subscribe,core-offline 时 Core 已 dispose,bus 自然失效,不会泄漏。没有全局 bus,没有「哪个账号的事件」问题。
7.4 完全可测
- 测 Channel:mock
setPacketHandler回调(已在account-private-routing.test.ts用FakeChannel验证) - 测 Core:传
ChannelCtxPOJO 即可,无需真 Channel - 测 OneBot:emit 一个
core-online+ 假CoreCtx - 测 Hub:用 fake
ChannelAdapter
每层都能独立测试。
7.5 内部还要避免 ctx 漫天的两个小招
(a) Api 类捕获 deps(已做)
class MessageApi {
constructor(private readonly core: Core) {}
// 方法签名只有业务参数:
async sendGroup(groupId: number, msg: Msg) { this.core.sendRawPacket(...) }
}
(b) AsyncLocalStorage 处理日志 / trace(建议引入)
// packages/common/src/async-context.ts
import { AsyncLocalStorage } from 'node:async_hooks';
const als = new AsyncLocalStorage<{ uin?: string; trace?: string }>();
export const runInContext = <T>(c: { uin?: string; trace?: string }, fn: () => T) =>
als.run(c, fn);
export const ctx = () => als.getStore() ?? {};
// logger 自动 prefix [uin],业务代码不用接 log 参数
在 Core.constructor 给 onPacket 包一层:
channelCtx.onPacket((pkt) => {
runInContext({ uin: channelCtx.uin, trace: `pkt:${pkt.seqId}` }, () => {
this.pipeline.process(pkt);
});
});
所有下游 log.info() 自动带 [10001] 前缀,不污染业务签名。开销 ~50ns/call。
8. 不要的设计(保持简单)
| 方案 | 不做的原因 |
|---|---|
CoreFactory 抽象 |
Core 只一种,多此一举。直接 new Core(ctx) |
| 全局 EventBus | 每账号自己的 bus 更清晰,OneBot 按 uin 订阅,不混杂 |
AccountManager 独立层 |
多余的中间层,折进 Hub |
InboundAdapter 抽象 |
OneBot / WebUI 数量有限且固定,直接 bm.events.on(...) 即可,不抽插件层 |
| DI 容器(tsyringe 等) | 9 行手动装配已经清晰,DI 引入反射/装饰器元数据反而难调 |
| Effect monad / Reader monad | TS 缺 do-notation,类型噪音 > 收益 |
| CQRS / Event-sourced state | 当前状态量级(10 账号 × 100 群)远未到 |
9. 迁移路线(每步独立可 ship)
typecheck + 全测试通过才进下一步。
Step 1 —— ⭐ 重命名 + 拆现有的 @snowluma/bridge
packages/bridge/重命名为packages/channel/;package.json name 改为@snowluma/channel- 新建
packages/channel-hook/,从@snowluma/channel搬:hook-manager.ts/hook-session.ts/hook-packet-client.ts/injector.ts/pipe-watcher.ts/qq-hook-client.ts/qq-port-probe.ts/hook-channel.ts(原 inject-bridge.ts)/hook-adapter.ts(原 inject-adapter.ts) @snowluma/channel留:channel.ts(abstract,原 bridge.ts)/channel-interface.ts/channel-ctx.ts(新)/adapter.ts(端口)+makeChannelCtx工具- 删除:
manager.ts(原 BridgeManager)(搬到 core/hub.ts)/socket-channel.ts+socket-adapter.ts(搬到channel-socketstub)
Step 2 —— ⭐ 重塑 @snowluma/core
- 删除:
account/account.ts/account-interface.ts/account-context.ts/account-manager.ts - 新建:
core.ts(吸收 Account 业务,构造接ChannelCtx)/core-ctx.ts/hub.ts apis/内部AccountContext→Core(直接拿 Core 类型)index.ts9 行装配(见 §6)
Step 3 —— OneBot 适配 CoreCtx
- 全局
AccountInterface→CoreCtx OneBotManager.bind(hub)订阅'core-online'/'core-offline'- 内部
byUin.get(uin).apis.<area>.method()调 api
Step 4 —— 引入 AsyncLocalStorage
packages/common/src/async-context.tslogger.ts自动读 ALS,加[uin]前缀Core.constructor在onPacket回调外包runInContext
Step 5 —— channel-socket stub 包(占位)
- 把
socket-channel.ts/socket-adapter.ts搬过来 - 实现 placeholder,让
ChannelAdapter端口齐
Step 6 —— ⭐ 拆 webui 为 webui-backend + webui-frontend
- Hono server + REST/WS →
webui-backend,依赖@snowluma/core(消费CoreCtx) - Vite SPA + UI →
webui-frontend,依赖@snowluma/ui
10. 设计原则速查
- 单向数据流:
adapter → Channel → ChannelCtx → Core → CoreCtx → OneBot,无反向引用 - 能力 POJO 跨层传递:层间用
{ uin, sendRawPacket, ... }/{ uin, apis, events, ... }这种纯数据,不传类实例 - 单一所有者:
Hub.cores是 Core 唯一容器,消费者只持投影 - 每账号自带 EventBus:避免全局 bus 混杂,订阅生命周期与 Core 绑定
- Class 封装 deps:构造时捕获,方法签名只剩业务参数
- AsyncLocalStorage 跨切面:日志 / trace 不进显式参数
- 不引入过度抽象:no factory / no DI / no Effect monad / no global bus
- 可测:每层接 POJO,fake 一下就能测
11. v2.0.0 已完成
- ✅ UID/UIN Service + SQLite 持久化
- ✅ tsx 免打包 Debug
- ✅ Common / OneBot / Core / Bridge / Protocol / proto-defs 拆包
- ✅ 消息 hot-path 优化(17.5μs → 4.7μs,better-sqlite3 + prepared-statement cache)
- ✅ NapCat 风格 Api 层(13 Api 类)
- ✅ Bridge / Account 严格分层(v2 中间形态;v3 重命名为 Channel + Core,并用 ctx 替代直接引用)
- ✅
@snowluma/bridge完整 transport 包(v3 重命名为@snowluma/channel)