@zhin.js/adapter-sandbox 3.0.2 → 3.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts CHANGED
@@ -1,37 +1,18 @@
1
- import { EventEmitter } from "events";
2
- import {
3
- Bot,
4
- Adapter,
5
- usePlugin,
6
- Message,
7
- SendOptions,
8
- segment,
9
- SendContent,
10
- MessageType,
11
- MessageElement,
12
- Plugin,
13
- } from "zhin.js";
1
+ import path from "node:path";
2
+ import { usePlugin, type Plugin } from "zhin.js";
14
3
  import type { WebSocket } from "ws";
15
- import { Router } from "@zhin.js/http";
4
+ import {
5
+ SandboxWsHostAdapter,
6
+ resolveSandboxBot,
7
+ type SandboxWsSocket,
8
+ } from "./sandbox-ws.js";
16
9
  import { PageManager } from "@zhin.js/console";
17
- import path from "path";
18
10
 
19
- export interface SandboxConfig {
20
- context: "sandbox";
21
- ws: WebSocket;
22
- name: string;
23
- /** 发言者即为 owner(沙箱模式) */
24
- owner?: string;
25
- }
11
+ type SandboxRouter = {
12
+ ws: (path: string) => NonNullable<SandboxAdapter["wss"]>;
13
+ };
26
14
 
27
15
  declare module "zhin.js" {
28
- namespace Plugin {
29
- interface Contexts {
30
- router: Router;
31
- web: PageManager;
32
- }
33
- }
34
-
35
16
  interface Adapters {
36
17
  sandbox: SandboxAdapter;
37
18
  }
@@ -40,162 +21,44 @@ declare module "zhin.js" {
40
21
  const plugin = usePlugin();
41
22
  const logger = plugin.logger;
42
23
 
43
- interface WebSocketMessage {
44
- type: MessageType;
45
- id: string;
46
- content: MessageElement[] | string;
47
- timestamp: number;
48
- }
49
-
50
- export class SandboxBot extends EventEmitter implements Bot<SandboxConfig, { content: MessageElement[]; ts: number }> {
51
- $connected: boolean = false;
52
-
53
- get $id() {
54
- return this.$config.name;
55
- }
56
-
57
- private logger = logger;
58
-
59
- constructor(public adapter: SandboxAdapter, public $config: SandboxConfig) {
60
- super();
61
- this.$config.ws.on("message", (data) => {
62
- const message = JSON.parse(data.toString()) as WebSocketMessage;
63
- // 确保 content 是 MessageElement[] 格式
64
- const content: MessageElement[] = typeof message.content === 'string'
65
- ? [{ type: 'text', data: { text: message.content } }]
66
- : message.content;
67
- this.logger.debug(`${this.$config.name} recv ${message.type}(${message.id}):${segment.raw(content)}`);
68
- const formattedMessage = this.$formatMessage({ content: content, type: message.type, id: message.id, ts: message.timestamp });
69
- this.adapter.emit("message.receive", formattedMessage);
70
- });
71
-
72
- this.$config.ws.on("close", () => {
73
- this.logger.debug(`Sandbox bot ${this.$config.name} disconnected`);
74
- this.$connected = false;
75
- // 从 adapter 中移除 bot
76
- this.adapter.bots.delete(this.$id);
77
- });
78
- }
79
-
80
- async $connect(): Promise<void> {
81
- this.$connected = true;
82
- }
83
-
84
- async $disconnect(): Promise<void> {
85
- this.$config.ws.close();
86
- this.$connected = false;
87
- }
24
+ /** Node:`Router.ws`;Deno/Edge:`registerSandboxWebSocketRoutes`(http-host) */
25
+ export class SandboxAdapter extends SandboxWsHostAdapter {
26
+ wss?: { on: (ev: string, fn: (...args: unknown[]) => void) => void; close: () => void };
88
27
 
89
- $formatMessage({ content, type, id, ts }: { content: MessageElement[]; id: string; type: MessageType; ts: number }) {
90
- // 沙箱模式:发言者即为 owner
91
- if (!this.$config.owner) this.$config.owner = id;
92
- const message = Message.from(
93
- { content, ts },
94
- {
95
- $id: `${ts}`,
96
- $adapter: "sandbox" as const,
97
- $bot: `${this.$config.name}`,
98
- $sender: {
99
- id: `${id}`,
100
- name: `mock`,
101
- },
102
- $channel: {
103
- id: `${id}`,
104
- type: type,
105
- },
106
- $content: content,
107
- $raw: segment.raw(content),
108
- $timestamp: ts,
109
- $recall: async () => {
110
- await this.$recallMessage(message.$id);
111
- },
112
- $reply: async (content: SendContent, quote?: boolean | string): Promise<string> => {
113
- if (!Array.isArray(content)) content = [content];
114
- if (quote) content.unshift({ type: "reply", data: { id: typeof quote === "boolean" ? message.$id : quote } });
115
- return await this.adapter.sendMessage({
116
- ...message.$channel,
117
- context: "sandbox",
118
- bot: `${this.$config.name}`,
119
- content,
120
- });
121
- },
122
- }
123
- );
124
- return message;
28
+ constructor(plugin: ReturnType<typeof usePlugin>) {
29
+ const appConfig = (plugin.inject("config")?.getPrimary() ?? {}) as Record<string, unknown>;
30
+ super(plugin, resolveSandboxBot(appConfig));
125
31
  }
126
32
 
127
- async $sendMessage(options: SendOptions): Promise<string> {
128
- if (!this.$connected) return "";
129
- this.logger.debug(`${this.$config.name} send ${options.type}(${options.id}):${segment.raw(options.content)}`);
130
- options.bot = this.$config.name;
131
- options.context = "sandbox";
132
- this.$config.ws.send(
133
- JSON.stringify({
134
- ...options,
135
- content: options.content, // 发送消息段数组
136
- timestamp: Date.now(),
137
- })
33
+ override async start(): Promise<void> {
34
+ await super.start();
35
+ this.registerConfiguredPlaceholder();
36
+ logger.debug(
37
+ `Sandbox placeholder: ${this.defaults.name} (offline until /sandbox WS)`,
138
38
  );
139
- return "";
140
- }
141
-
142
- async $recallMessage(id: string): Promise<void> {
143
- // 沙盒不支持撤回消息
144
- }
145
- }
146
-
147
- class SandboxAdapter extends Adapter<SandboxBot> {
148
- wss?: ReturnType<Router["ws"]>;
149
-
150
- constructor(plugin: Plugin) {
151
- super(plugin, "sandbox", []);
152
- }
153
-
154
- createBot(config: SandboxConfig): SandboxBot {
155
- const bot = new SandboxBot(this, config);
156
- // 将 bot 添加到 bots Map 中
157
- this.bots.set(bot.$id, bot);
158
- return bot;
159
39
  }
160
40
 
161
- async start(): Promise<void> {
162
- // start 方法会在 mounted 时被调用
163
- // WebSocket server 的创建在 useContext("router") 中处理
164
- }
165
-
166
- async setupWebSocket(router: Router): Promise<void> {
167
- if (this.wss) return; // 已经设置过了
168
- // 创建 WebSocket server
41
+ async setupWebSocket(router: SandboxRouter): Promise<void> {
42
+ if (this.wss) return;
169
43
  this.wss = router.ws("/sandbox");
170
44
 
171
- this.wss.on("connection", (ws: WebSocket, req) => {
172
- // 为每个连接创建一个唯一的 bot 名称
173
- const botName = `sandbox-${Math.random().toString(36).slice(2, 9)}`;
174
- logger.debug(`New sandbox connection: ${botName} from ${req.socket.remoteAddress}`);
175
-
176
- // 创建 bot 配置
177
- const config: SandboxConfig = {
178
- context: "sandbox",
179
- ws,
180
- name: botName,
181
- };
182
-
183
- // 创建并连接 bot
184
- const bot = this.createBot(config);
185
- bot.$connect();
186
-
187
- // WebSocket 关闭时清理
45
+ this.wss.on("connection", (...args: unknown[]) => {
46
+ const ws = args[0] as WebSocket;
47
+ const req = args[1] as { socket?: { remoteAddress?: string } };
48
+ logger.debug(
49
+ `New sandbox connection from ${req.socket?.remoteAddress ?? "unknown"}`,
50
+ );
51
+ const bot = this.acceptWebSocket(ws as SandboxWsSocket);
188
52
  ws.on("close", () => {
189
- logger.debug(`Sandbox connection closed: ${botName}`);
53
+ logger.debug(`Sandbox connection closed: ${bot.$config.name}`);
190
54
  this.bots.delete(bot.$id);
191
55
  });
192
-
193
56
  ws.on("error", (error) => {
194
- logger.error(`Sandbox WebSocket error for ${botName}:`, error);
57
+ logger.error(`Sandbox WebSocket error for ${bot.$config.name}:`, error);
195
58
  });
196
59
  });
197
60
 
198
- logger.debug("Sandbox WebSocket server started at /sandbox");
61
+ logger.debug("Sandbox WebSocket server started at /sandbox (Node Router.ws)");
199
62
  }
200
63
  }
201
64
 
@@ -203,32 +66,25 @@ const { provide } = usePlugin();
203
66
 
204
67
  provide({
205
68
  name: "sandbox",
206
- description: "Sandbox Adapter",
69
+ description: "Sandbox Adapter — Node Router.ws + Deno http-host WebSocket",
207
70
  mounted: async (p: Plugin) => {
208
71
  const adapter = new SandboxAdapter(p);
209
72
  await adapter.start();
210
73
  return adapter;
211
74
  },
212
75
  dispose: async (adapter: SandboxAdapter) => {
213
- // 关闭所有 bot 连接
214
76
  for (const bot of adapter.bots.values()) {
215
77
  await bot.$disconnect();
216
78
  }
217
- // 关闭 WebSocket server
218
79
  adapter.wss?.close();
219
80
  await adapter.stop();
220
81
  },
221
- });
82
+ } as never);
222
83
 
223
- // 使用 router 上下文创建 WebSocket server
224
- plugin.useContext("router", async (router: Router) => {
225
- // 等待 sandbox adapter 就绪
226
- plugin.useContext("sandbox", async (adapter: SandboxAdapter) => {
227
- await adapter.setupWebSocket(router);
228
- });
84
+ plugin.useContext("router",'sandbox', async (router: SandboxRouter,adapter: SandboxAdapter) => {
85
+ await adapter.setupWebSocket(router);
229
86
  });
230
87
 
231
- // 使用 web 上下文注册客户端入口
232
88
  plugin.useContext("web", (pageManager) => {
233
89
  pageManager.addEntry({
234
90
  id: "sandbox",
@@ -237,3 +93,23 @@ plugin.useContext("web", (pageManager) => {
237
93
  meta: { name: "Sandbox" },
238
94
  });
239
95
  });
96
+
97
+ export {
98
+ registerSandboxWebSocketRoutes,
99
+ type RegisterSandboxWsOptions,
100
+ } from "./fetch-ws.js";
101
+ export {
102
+ registerSandboxSseRoutes,
103
+ type RegisterSandboxSseOptions,
104
+ } from "./fetch-sse.js";
105
+ export {
106
+ SandboxWsBot,
107
+ SandboxWsHostAdapter,
108
+ resolveSandboxBot,
109
+ bindSandboxWsSocket,
110
+ parseSandboxWsPayload,
111
+ type ResolvedSandboxBot,
112
+ type SandboxTransport,
113
+ type SandboxWsConfig,
114
+ type SandboxWsSocket,
115
+ } from "./sandbox-ws.js";
@@ -0,0 +1,118 @@
1
+ /**
2
+ * 按 session 隔离的 Sandbox SSE 推送(Edge / Vercel 等无 WebSocket 入站时使用)。
3
+ * SSE `data` 字段为与 WebSocket 相同的 JSON 字符串。
4
+ */
5
+
6
+ type Subscriber = {
7
+ id: string;
8
+ enqueue: (chunk: string) => void;
9
+ close: () => void;
10
+ };
11
+
12
+ type StoredEvent = { id: string; data: string };
13
+
14
+ type SessionState = {
15
+ subscribers: Map<string, Subscriber>;
16
+ history: StoredEvent[];
17
+ nextSubId: number;
18
+ nextEventId: number;
19
+ };
20
+
21
+ const MAX_REPLAY = 100;
22
+ const sessions = new Map<string, SessionState>();
23
+
24
+ function getSession(sessionId: string): SessionState {
25
+ let s = sessions.get(sessionId);
26
+ if (!s) {
27
+ s = { subscribers: new Map(), history: [], nextSubId: 0, nextEventId: 0 };
28
+ sessions.set(sessionId, s);
29
+ }
30
+ return s;
31
+ }
32
+
33
+ function formatSse(data: string, id?: string): string {
34
+ const lines: string[] = [];
35
+ if (id) lines.push(`id: ${id}`);
36
+ lines.push(`data: ${data}`);
37
+ lines.push("");
38
+ return lines.join("\n") + "\n";
39
+ }
40
+
41
+ export function broadcastSandboxSse(sessionId: string, jsonPayload: string): void {
42
+ const session = getSession(sessionId);
43
+ const stored: StoredEvent = {
44
+ id: String(++session.nextEventId),
45
+ data: jsonPayload,
46
+ };
47
+ session.history.push(stored);
48
+ if (session.history.length > MAX_REPLAY) session.history.shift();
49
+ const chunk = formatSse(stored.data, stored.id);
50
+ for (const sub of session.subscribers.values()) {
51
+ sub.enqueue(chunk);
52
+ }
53
+ }
54
+
55
+ export function subscribeSandboxSse(
56
+ sessionId: string,
57
+ lastEventId?: string,
58
+ ): ReadableStream<Uint8Array> {
59
+ const session = getSession(sessionId);
60
+ const encoder = new TextEncoder();
61
+ const replayFrom = lastEventId ? Number.parseInt(lastEventId, 10) : 0;
62
+ const subId = `sse-${++session.nextSubId}`;
63
+
64
+ return new ReadableStream<Uint8Array>({
65
+ start(controller) {
66
+ for (const ev of session.history) {
67
+ if (Number(ev.id) > replayFrom) {
68
+ controller.enqueue(encoder.encode(formatSse(ev.data, ev.id)));
69
+ }
70
+ }
71
+ const interval = setInterval(() => {
72
+ try {
73
+ controller.enqueue(encoder.encode(": heartbeat\n\n"));
74
+ } catch {
75
+ clearInterval(interval);
76
+ }
77
+ }, 15000);
78
+ session.subscribers.set(subId, {
79
+ id: subId,
80
+ enqueue: (chunk) => {
81
+ try {
82
+ controller.enqueue(encoder.encode(chunk));
83
+ } catch {
84
+ /* closed */
85
+ }
86
+ },
87
+ close: () => {
88
+ clearInterval(interval);
89
+ try {
90
+ controller.close();
91
+ } catch {
92
+ /* */
93
+ }
94
+ },
95
+ });
96
+ },
97
+ cancel() {
98
+ const s = sessions.get(sessionId);
99
+ const sub = s?.subscribers.get(subId);
100
+ sub?.close();
101
+ s?.subscribers.delete(subId);
102
+ },
103
+ });
104
+ }
105
+
106
+ export function closeSandboxSseSession(sessionId: string): void {
107
+ const session = sessions.get(sessionId);
108
+ if (!session) return;
109
+ for (const sub of session.subscribers.values()) sub.close();
110
+ session.subscribers.clear();
111
+ sessions.delete(sessionId);
112
+ }
113
+
114
+ /** @internal */
115
+ export function resetSandboxSseHubForTests(): void {
116
+ for (const id of [...sessions.keys()]) closeSandboxSseSession(id);
117
+ sessions.clear();
118
+ }