@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/CHANGELOG.md +16 -0
- package/README.md +16 -2
- package/client/Sandbox.tsx +111 -31
- package/client/sandboxTransport.ts +61 -0
- package/dist/index.js +7 -7
- package/lib/fetch-sse.d.ts +11 -0
- package/lib/fetch-sse.d.ts.map +1 -0
- package/lib/fetch-sse.js +76 -0
- package/lib/fetch-sse.js.map +1 -0
- package/lib/fetch-ws.d.ts +11 -0
- package/lib/fetch-ws.d.ts.map +1 -0
- package/lib/fetch-ws.js +13 -0
- package/lib/fetch-ws.js.map +1 -0
- package/lib/index.d.ts +16 -48
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +25 -132
- package/lib/index.js.map +1 -1
- package/lib/sandbox-sse-hub.d.ts +10 -0
- package/lib/sandbox-sse-hub.d.ts.map +1 -0
- package/lib/sandbox-sse-hub.js +101 -0
- package/lib/sandbox-sse-hub.js.map +1 -0
- package/lib/sandbox-ws.d.ts +91 -0
- package/lib/sandbox-ws.d.ts.map +1 -0
- package/lib/sandbox-ws.js +337 -0
- package/lib/sandbox-ws.js.map +1 -0
- package/package.json +14 -9
- package/src/fetch-sse.ts +87 -0
- package/src/fetch-ws.ts +23 -0
- package/src/index.ts +57 -181
- package/src/sandbox-sse-hub.ts +118 -0
- package/src/sandbox-ws.ts +462 -0
package/src/index.ts
CHANGED
|
@@ -1,37 +1,18 @@
|
|
|
1
|
-
import
|
|
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 {
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
|
128
|
-
|
|
129
|
-
this.
|
|
130
|
-
|
|
131
|
-
|
|
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
|
|
162
|
-
|
|
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", (
|
|
172
|
-
|
|
173
|
-
const
|
|
174
|
-
logger.debug(
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
const
|
|
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: ${
|
|
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 ${
|
|
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
|
-
|
|
224
|
-
|
|
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
|
+
}
|