@zhin.js/adapter-sandbox 5.0.4 → 5.0.6

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@zhin.js/adapter-sandbox",
3
- "version": "5.0.4",
4
- "description": "Zhin.js adapter for local testing and development",
3
+ "version": "5.0.6",
4
+ "description": "Zhin.js Sandbox adapter for Plugin Runtime (WebSocket /sandbox)",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",
7
7
  "types": "./lib/index.d.ts",
@@ -13,11 +13,14 @@
13
13
  }
14
14
  },
15
15
  "files": [
16
+ "adapters",
17
+ "pages",
18
+ "plugin.ts",
19
+ "schema.json",
16
20
  "src",
17
21
  "lib",
18
- "client",
19
22
  "dist",
20
- "skills",
23
+ "agent",
21
24
  "README.md",
22
25
  "CHANGELOG.md"
23
26
  ],
@@ -43,35 +46,34 @@
43
46
  "directory": "plugins/adapters/sandbox"
44
47
  },
45
48
  "dependencies": {
46
- "lucide-react": "^1.22.0"
49
+ "react": "^19.1.0",
50
+ "@zhin.js/console-contract": "1.0.0",
51
+ "@zhin.js/adapter": "1.0.1",
52
+ "@zhin.js/core": "1.3.5",
53
+ "@zhin.js/host-http": "1.0.1",
54
+ "@zhin.js/logger": "1.0.75",
55
+ "@zhin.js/page": "1.0.1",
56
+ "@zhin.js/plugin-runtime": "1.0.1"
47
57
  },
48
58
  "devDependencies": {
49
- "@types/react": "^19.2.17",
50
- "@types/react-dom": "^19.2.3",
59
+ "@types/ws": "^8.18.1",
51
60
  "typescript": "^6.0.3",
52
- "@zhin.js/cli": "1.0.92",
53
- "@zhin.js/contract": "1.0.2",
54
- "@zhin.js/client": "2.0.4",
55
- "@zhin.js/core": "1.3.3",
56
- "@zhin.js/host-api": "2.0.4",
57
- "@zhin.js/host-router": "2.0.2",
58
- "zhin.js": "4.1.1"
61
+ "vitest": "^4.1.10",
62
+ "ws": "^8.21.0",
63
+ "@zhin.js/pagemanager": "2.0.4",
64
+ "@zhin.js/runtime": "1.0.1"
59
65
  },
60
66
  "peerDependencies": {
61
- "@zhin.js/core": "1.3.3",
62
- "@zhin.js/client": "2.0.4",
63
- "@zhin.js/host-router": "2.0.2",
64
- "@zhin.js/host-api": "2.0.4",
65
- "@zhin.js/contract": "1.0.2"
67
+ "react": "^19.0.0",
68
+ "@zhin.js/adapter": "1.0.1",
69
+ "@zhin.js/console-contract": "1.0.0",
70
+ "@zhin.js/core": "1.3.5",
71
+ "@zhin.js/host-http": "1.0.1",
72
+ "@zhin.js/page": "1.0.1",
73
+ "@zhin.js/plugin-runtime": "1.0.1"
66
74
  },
67
75
  "peerDependenciesMeta": {
68
- "@zhin.js/host-router": {
69
- "optional": true
70
- },
71
- "@zhin.js/host-api": {
72
- "optional": true
73
- },
74
- "@zhin.js/client": {
76
+ "react": {
75
77
  "optional": true
76
78
  }
77
79
  },
@@ -82,8 +84,27 @@
82
84
  "engines": {
83
85
  "node": "^20.19.0 || >=22.12.0"
84
86
  },
87
+ "zhin": {
88
+ "protocol": 1,
89
+ "type": "plugin",
90
+ "entry": "./plugin.ts",
91
+ "engine": "^1.0.0",
92
+ "runtime": "trusted",
93
+ "features": [
94
+ {
95
+ "package": "@zhin.js/adapter",
96
+ "api": "^1.0.0"
97
+ },
98
+ {
99
+ "package": "@zhin.js/page",
100
+ "api": "^1.0.0"
101
+ }
102
+ ],
103
+ "plugins": []
104
+ },
85
105
  "scripts": {
86
- "build": "zhin build",
87
- "clean": "rimraf lib"
106
+ "build": "tsc",
107
+ "clean": "rimraf lib",
108
+ "test": "NODE_OPTIONS=--experimental-strip-types vitest run --root ../../.. plugins/adapters/sandbox/tests"
88
109
  }
89
110
  }
@@ -0,0 +1,95 @@
1
+ import { definePage } from '@zhin.js/console-contract';
2
+ import { useEffect, useRef, useState, type FormEvent } from 'react';
3
+
4
+ export const meta = definePage({
5
+ title: 'Sandbox',
6
+ icon: 'Box',
7
+ order: 10,
8
+ });
9
+
10
+ /**
11
+ * Convention page (ADR 0046). Plugin Runtime Host also serves a vanilla WS shell
12
+ * for this localName so local smoke does not depend on a CDN React graph.
13
+ */
14
+ export default function SandboxPage() {
15
+ const [connected, setConnected] = useState(false);
16
+ const [input, setInput] = useState('');
17
+ const [lines, setLines] = useState<readonly { readonly kind: 'in' | 'out'; readonly text: string }[]>([]);
18
+ const wsRef = useRef<WebSocket | null>(null);
19
+
20
+ useEffect(() => {
21
+ const url = new URL('/sandbox', window.location.href);
22
+ url.protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
23
+ const ws = new WebSocket(url);
24
+ wsRef.current = ws;
25
+ ws.addEventListener('open', () => setConnected(true));
26
+ ws.addEventListener('close', () => setConnected(false));
27
+ ws.addEventListener('message', (event) => {
28
+ let text = String(event.data);
29
+ try {
30
+ const data = JSON.parse(text) as {
31
+ content?: Array<{ data?: { text?: string } }> | string;
32
+ };
33
+ text = Array.isArray(data.content)
34
+ ? data.content.map((segment) => segment?.data?.text ?? '').filter(Boolean).join('\n')
35
+ : typeof data.content === 'string'
36
+ ? data.content
37
+ : text;
38
+ } catch {
39
+ /* keep raw */
40
+ }
41
+ setLines((previous) => [...previous, { kind: 'in', text }]);
42
+ });
43
+ return () => {
44
+ ws.close();
45
+ wsRef.current = null;
46
+ };
47
+ }, []);
48
+
49
+ const onSubmit = (event: FormEvent) => {
50
+ event.preventDefault();
51
+ const text = input.trim();
52
+ const ws = wsRef.current;
53
+ if (!text || !ws || ws.readyState !== WebSocket.OPEN) return;
54
+ ws.send(JSON.stringify({ text, timestamp: Date.now() }));
55
+ setLines((previous) => [...previous, { kind: 'out', text }]);
56
+ setInput('');
57
+ };
58
+
59
+ return (
60
+ <div style={{ display: 'flex', flexDirection: 'column', height: 'calc(100vh - 3rem)', maxWidth: 720, margin: '0 auto' }}>
61
+ <p style={{ padding: '0.75rem 1rem', margin: 0, color: '#71717a' }}>
62
+ {connected ? 'WebSocket /sandbox connected' : 'connecting…'}
63
+ </p>
64
+ <div style={{ flex: 1, overflow: 'auto', padding: '1rem', display: 'flex', flexDirection: 'column', gap: 8 }}>
65
+ {lines.map((line, index) => (
66
+ <div
67
+ key={`${line.kind}-${index}-${line.text.slice(0, 12)}`}
68
+ style={{
69
+ alignSelf: line.kind === 'out' ? 'flex-end' : 'flex-start',
70
+ background: line.kind === 'out' ? '#ccfbf1' : '#fff',
71
+ border: '1px solid #e4e4e7',
72
+ borderRadius: 10,
73
+ padding: '0.6rem 0.8rem',
74
+ maxWidth: '85%',
75
+ whiteSpace: 'pre-wrap',
76
+ }}
77
+ >
78
+ {line.text}
79
+ </div>
80
+ ))}
81
+ </div>
82
+ <form onSubmit={onSubmit} style={{ display: 'flex', gap: 8, padding: 12, borderTop: '1px solid #e4e4e7' }}>
83
+ <input
84
+ value={input}
85
+ onChange={(event) => setInput(event.target.value)}
86
+ placeholder="发送到 /sandbox …"
87
+ style={{ flex: 1, padding: '0.55rem 0.75rem', borderRadius: 8, border: '1px solid #d4d4d8' }}
88
+ />
89
+ <button type="submit" style={{ padding: '0.55rem 0.9rem', border: 0, borderRadius: 8, background: '#0f766e', color: '#fff' }}>
90
+ 发送
91
+ </button>
92
+ </form>
93
+ </div>
94
+ );
95
+ }
package/plugin.ts ADDED
@@ -0,0 +1,8 @@
1
+ import { definePlugin } from '@zhin.js/plugin-runtime';
2
+
3
+ export default definePlugin({
4
+ name: 'sandbox',
5
+ metadata: {
6
+ displayName: 'Sandbox Adapter',
7
+ },
8
+ });
package/schema.json ADDED
@@ -0,0 +1,20 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "type": "object",
4
+ "additionalProperties": false,
5
+ "properties": {
6
+ "endpoints": {
7
+ "type": "array",
8
+ "default": [],
9
+ "items": {
10
+ "type": "object",
11
+ "additionalProperties": false,
12
+ "properties": {
13
+ "context": { "type": "string" },
14
+ "name": { "type": "string" },
15
+ "owner": { "type": "string" }
16
+ }
17
+ }
18
+ }
19
+ }
20
+ }
@@ -0,0 +1,188 @@
1
+ /**
2
+ * SandboxWsEndpoint — WebSocket lifecycle and MessageGateway bridge for /sandbox.
3
+ */
4
+ import { randomUUID } from 'node:crypto';
5
+ import type { EndpointInstance } from '@zhin.js/adapter';
6
+ import type { MessageGateway } from '@zhin.js/core/runtime';
7
+ import type { HttpHost, WsConnection } from '@zhin.js/host-http';
8
+ import { formatCompact, getLogger } from '@zhin.js/logger';
9
+ import type { CapabilityId } from '@zhin.js/plugin-runtime';
10
+ import {
11
+ bindSandboxWsSocket,
12
+ formatSandboxOutbound,
13
+ parseSandboxWsPayload,
14
+ whenWsOpen,
15
+ type ResolvedSandboxBot,
16
+ type SandboxWsSocket,
17
+ } from './protocol.js';
18
+
19
+ const logger = getLogger('sandbox');
20
+
21
+ interface SandboxConnection {
22
+ readonly target: string;
23
+ readonly owner: string;
24
+ readonly socket: SandboxWsSocket;
25
+ readonly release: () => void;
26
+ /** true = 占位连接(尚无真实 WS 客户端),send 命中时按 miss 处理。 */
27
+ readonly placeholder?: boolean;
28
+ }
29
+
30
+ export interface SandboxEndpointOptions {
31
+ readonly id: CapabilityId;
32
+ readonly gateway: MessageGateway;
33
+ readonly http: HttpHost;
34
+ readonly defaults: ResolvedSandboxBot;
35
+ }
36
+
37
+ export class SandboxWsEndpoint implements EndpointInstance {
38
+ readonly #options: SandboxEndpointOptions;
39
+ readonly #connections = new Map<string, SandboxConnection>();
40
+ #wsHandleRelease?: () => void;
41
+ #open = false;
42
+ #started = false;
43
+
44
+ constructor(options: SandboxEndpointOptions) {
45
+ this.#options = options;
46
+ }
47
+
48
+ /** Live endpoint name (config `name`) — Console endpoint.list/resolve uses it. */
49
+ get name(): string {
50
+ return this.#options.defaults.name;
51
+ }
52
+
53
+ start(): void {
54
+ if (this.#started) return;
55
+ this.#started = true;
56
+ const handle = this.#options.http.ws('/sandbox');
57
+ this.#wsHandleRelease = handle.onConnection((connection) => {
58
+ this.#acceptConnection(connection);
59
+ });
60
+ if (!this.#options.defaults.randomNamePerConnection) {
61
+ this.#ensurePlaceholder(this.#options.defaults.name, this.#options.defaults.owner);
62
+ }
63
+ logger.info(formatCompact({
64
+ op: 'sandbox_ws_mounted',
65
+ path: '/sandbox',
66
+ endpoint: this.#options.defaults.name,
67
+ }));
68
+ }
69
+
70
+ open(): void {
71
+ this.#open = true;
72
+ }
73
+
74
+ close(): void {
75
+ this.#open = false;
76
+ }
77
+
78
+ stop(): void {
79
+ this.#open = false;
80
+ this.#wsHandleRelease?.();
81
+ this.#wsHandleRelease = undefined;
82
+ for (const connection of this.#connections.values()) connection.release();
83
+ this.#connections.clear();
84
+ this.#started = false;
85
+ logger.debug(formatCompact({ op: 'sandbox_stopped' }));
86
+ }
87
+
88
+ send({ target, payload }: { readonly target: string; readonly payload: unknown }): unknown {
89
+ if (!this.#open) return undefined;
90
+ const connection = this.#connections.get(target);
91
+ if (!connection) {
92
+ logger.debug(formatCompact({ op: 'sandbox_send_miss', target }));
93
+ return undefined;
94
+ }
95
+ if (connection.placeholder) {
96
+ logger.debug(formatCompact({ op: 'sandbox_send_placeholder', target }));
97
+ return undefined;
98
+ }
99
+ connection.socket.send(formatSandboxOutbound(payload));
100
+ logger.debug(formatCompact({ op: 'sandbox_send', target }));
101
+ return payload;
102
+ }
103
+
104
+ #acceptConnection(connection: WsConnection): void {
105
+ const target = this.#options.defaults.randomNamePerConnection
106
+ ? `sandbox-${randomUUID().slice(0, 8)}`
107
+ : this.#options.defaults.name;
108
+ const owner = this.#options.defaults.owner;
109
+ const socket = connection.socket as SandboxWsSocket;
110
+ if (this.#connections.has(target)) {
111
+ this.#connections.get(target)?.release();
112
+ this.#connections.delete(target);
113
+ }
114
+ const release = bindSandboxWsSocket(socket, {
115
+ onMessage: (raw) => {
116
+ if (!this.#open) return;
117
+ const parsed = parseSandboxWsPayload(raw);
118
+ logger.debug(formatCompact({
119
+ op: 'sandbox_recv',
120
+ target,
121
+ sender: parsed.id || owner,
122
+ text: parsed.text.slice(0, 80),
123
+ }));
124
+ void this.#options.gateway.receive({
125
+ adapter: this.#options.id,
126
+ target,
127
+ content: parsed.text,
128
+ sender: parsed.id || owner,
129
+ metadata: Object.freeze({
130
+ type: parsed.type,
131
+ elements: parsed.content,
132
+ timestamp: parsed.timestamp,
133
+ ...(parsed.action ? { action: parsed.action } : {}),
134
+ }),
135
+ }).catch((err) => {
136
+ logger.warn(formatCompact({
137
+ op: 'sandbox_gateway_receive_failed',
138
+ target,
139
+ error: err instanceof Error ? err.message : String(err),
140
+ }));
141
+ });
142
+ },
143
+ onClose: () => {
144
+ this.#connections.delete(target);
145
+ logger.debug(formatCompact({ op: 'sandbox_ws_closed', target }));
146
+ },
147
+ onError: (err) => {
148
+ logger.warn(formatCompact({
149
+ op: 'sandbox_ws_error',
150
+ target,
151
+ error: err instanceof Error ? err.message : String(err),
152
+ }));
153
+ },
154
+ });
155
+ this.#connections.set(target, Object.freeze({ target, owner, socket, release }));
156
+ logger.debug(formatCompact({ op: 'sandbox_ws_connected', target, owner }));
157
+ if (!this.#options.defaults.randomNamePerConnection) {
158
+ const readyPayload = JSON.stringify({
159
+ type: 'ready',
160
+ id: owner,
161
+ endpoint: target,
162
+ content: [{
163
+ type: 'text',
164
+ data: {
165
+ text: [
166
+ `已连接 Sandbox「${target}」`,
167
+ '与 Node Host 控制台沙盒协议一致(/sandbox)',
168
+ '命令: help · ping · zt · status',
169
+ ].join('\n'),
170
+ },
171
+ }],
172
+ timestamp: Date.now(),
173
+ });
174
+ whenWsOpen(socket, () => socket.send(readyPayload));
175
+ }
176
+ }
177
+
178
+ #ensurePlaceholder(name: string, owner: string): void {
179
+ if (this.#connections.has(name)) return;
180
+ this.#connections.set(name, Object.freeze({
181
+ target: name,
182
+ owner,
183
+ socket: { send: () => undefined, close: () => undefined },
184
+ release: () => undefined,
185
+ placeholder: true,
186
+ }));
187
+ }
188
+ }
package/src/index.ts CHANGED
@@ -1,106 +1,17 @@
1
- import path from "node:path";
2
- import { usePlugin, type Plugin } from "zhin.js";
3
- import type { WebSocket } from "ws";
4
- import {
5
- SandboxWsHostAdapter,
6
- resolveSandboxEndpoint,
7
- type SandboxWsSocket,
8
- } from "./sandbox-ws.js";
9
- import { PageManager } from "@zhin.js/host-api";
10
-
11
- type SandboxRouter = {
12
- ws: (path: string) => NonNullable<SandboxAdapter["wss"]>;
13
- };
14
-
15
- declare module "zhin.js" {
16
- interface Adapters {
17
- sandbox: SandboxAdapter;
18
- }
19
- }
20
-
21
- const plugin = usePlugin();
22
- const logger = plugin.logger;
23
-
24
- /** Node:`Router.ws` */
25
- export class SandboxAdapter extends SandboxWsHostAdapter {
26
- wss?: { on: (ev: string, fn: (...args: unknown[]) => void) => void; close: () => void };
27
-
28
- constructor(plugin: ReturnType<typeof usePlugin>) {
29
- const appConfig = (plugin.inject("config")?.getPrimary() ?? {}) as Record<string, unknown>;
30
- super(plugin, resolveSandboxEndpoint(appConfig));
31
- }
32
-
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)`,
38
- );
39
- }
40
-
41
- async setupWebSocket(router: SandboxRouter): Promise<void> {
42
- if (this.wss) return;
43
- this.wss = router.ws("/sandbox");
44
-
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 endpoint = this.acceptWebSocket(ws as SandboxWsSocket);
52
- ws.on("close", () => {
53
- logger.debug(`Sandbox connection closed: ${endpoint.$config.name}`);
54
- this.endpoints.delete(endpoint.$id);
55
- });
56
- ws.on("error", (error) => {
57
- logger.error(`Sandbox WebSocket error for ${endpoint.$config.name}:`, error);
58
- });
59
- });
60
-
61
- logger.debug("Sandbox WebSocket server started at /sandbox (Node Router.ws)");
62
- }
63
- }
64
-
65
- const { provide } = usePlugin();
66
-
67
- provide({
68
- name: "sandbox",
69
- description: "Sandbox Adapter — Node Router.ws",
70
- mounted: async (p: Plugin) => {
71
- const adapter = new SandboxAdapter(p);
72
- await adapter.start();
73
- return adapter;
74
- },
75
- dispose: async (adapter: SandboxAdapter) => {
76
- for (const endpoint of adapter.endpoints.values()) {
77
- await endpoint.$disconnect();
78
- }
79
- adapter.wss?.close();
80
- await adapter.stop();
81
- },
82
- } as never);
83
-
84
- plugin.useContext("router",'sandbox', async (router: SandboxRouter,adapter: SandboxAdapter) => {
85
- await adapter.setupWebSocket(router);
86
- });
87
-
88
- plugin.useContext("web", (pageManager) => {
89
- pageManager.addEntry({
90
- id: "sandbox",
91
- development: path.resolve(import.meta.dirname, "../client/index.tsx"),
92
- production: path.resolve(import.meta.dirname, "../dist/index.js"),
93
- meta: { name: "Sandbox" },
94
- });
95
- });
96
-
97
1
  export {
98
- SandboxWsEndpoint,
99
- SandboxWsHostAdapter,
100
- resolveSandboxEndpoint,
101
2
  bindSandboxWsSocket,
3
+ formatSandboxOutbound,
102
4
  parseSandboxWsPayload,
5
+ resolveSandboxEndpoint,
6
+ whenWsOpen,
7
+ type MessageElement,
8
+ type MessageType,
103
9
  type ResolvedSandboxBot,
104
- type SandboxWsConfig,
10
+ type SandboxAdapterConfig,
105
11
  type SandboxWsSocket,
106
- } from "./sandbox-ws.js";
12
+ } from './protocol.js';
13
+
14
+ export {
15
+ SandboxWsEndpoint,
16
+ type SandboxEndpointOptions,
17
+ } from './endpoint.js';