@zhin.js/adapter-sandbox 5.0.6 → 6.0.0
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 +22 -0
- package/README.md +10 -10
- package/lib/endpoint.js +76 -13
- package/lib/protocol.d.ts +10 -4
- package/lib/protocol.js +34 -5
- package/package.json +20 -13
- package/pages/RichTextEditor.tsx +429 -0
- package/pages/SandboxChat.tsx +672 -0
- package/pages/sandbox.tsx +6 -84
- package/pages/sandboxTransport.ts +45 -0
- package/schema.json +27 -7
- package/src/endpoint.ts +85 -12
- package/src/protocol.ts +45 -5
package/pages/sandbox.tsx
CHANGED
|
@@ -1,95 +1,17 @@
|
|
|
1
1
|
import { definePage } from '@zhin.js/console-contract';
|
|
2
|
-
import
|
|
2
|
+
import SandboxChat from './SandboxChat';
|
|
3
3
|
|
|
4
4
|
export const meta = definePage({
|
|
5
|
-
title: '
|
|
5
|
+
title: '沙盒',
|
|
6
6
|
icon: 'Box',
|
|
7
7
|
order: 10,
|
|
8
8
|
});
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
|
-
* Convention page (ADR 0046).
|
|
12
|
-
*
|
|
11
|
+
* Convention page entry (ADR 0046).
|
|
12
|
+
* Restores the pre-runtime-migration Sandbox console UI (channels + rich text + faces).
|
|
13
|
+
* WebSocket targets Host `/sandbox` via zhin_api_base + token (see sandboxTransport.ts).
|
|
13
14
|
*/
|
|
14
15
|
export default function SandboxPage() {
|
|
15
|
-
|
|
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
|
-
);
|
|
16
|
+
return <SandboxChat />;
|
|
95
17
|
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/** Same storage keys as `@zhin.js/client` remoteApi. */
|
|
2
|
+
|
|
3
|
+
export function getSandboxApiBase(): string {
|
|
4
|
+
if (typeof localStorage === 'undefined') {
|
|
5
|
+
return typeof window !== 'undefined' ? window.location.origin : '';
|
|
6
|
+
}
|
|
7
|
+
const stored = localStorage.getItem('zhin_api_base')?.trim();
|
|
8
|
+
if (stored) return stored.replace(/\/+$/u, '');
|
|
9
|
+
if (typeof window !== 'undefined') return window.location.origin;
|
|
10
|
+
return '';
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function getSandboxBearerToken(): string {
|
|
14
|
+
if (typeof window !== 'undefined') {
|
|
15
|
+
const runtime = (window as unknown as { __ZHIN_API_TOKEN?: string }).__ZHIN_API_TOKEN?.trim();
|
|
16
|
+
if (runtime) return runtime;
|
|
17
|
+
}
|
|
18
|
+
if (typeof localStorage === 'undefined') return '';
|
|
19
|
+
return (
|
|
20
|
+
localStorage.getItem('zhin_api_token')?.trim()
|
|
21
|
+
|| localStorage.getItem('HTTP_TOKEN')?.trim()
|
|
22
|
+
|| localStorage.getItem('zhin_http_token')?.trim()
|
|
23
|
+
|| (typeof sessionStorage !== 'undefined' ? sessionStorage.getItem('zhin_api_token')?.trim() : '')
|
|
24
|
+
|| ''
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function getSandboxAuthHeaders(): Record<string, string> {
|
|
29
|
+
const token = getSandboxBearerToken();
|
|
30
|
+
return token ? { Authorization: `Bearer ${token}` } : {};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Browser WebSocket cannot set Authorization reliably; pass token in query when needed.
|
|
35
|
+
* Uses stored Console API base so Remote Console (different origin) still hits the bot Host.
|
|
36
|
+
*/
|
|
37
|
+
export function buildSandboxWebSocketUrl(base?: string): string {
|
|
38
|
+
const apiBase = (base ?? getSandboxApiBase()).replace(/\/+$/u, '');
|
|
39
|
+
const origin = apiBase || (typeof window !== 'undefined' ? window.location.origin : 'http://localhost');
|
|
40
|
+
const wsUrl = new URL('/sandbox', `${origin}/`);
|
|
41
|
+
wsUrl.protocol = wsUrl.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
42
|
+
const token = getSandboxBearerToken();
|
|
43
|
+
if (token) wsUrl.searchParams.set('token', token);
|
|
44
|
+
return wsUrl.href;
|
|
45
|
+
}
|
package/schema.json
CHANGED
|
@@ -5,16 +5,36 @@
|
|
|
5
5
|
"properties": {
|
|
6
6
|
"endpoints": {
|
|
7
7
|
"type": "array",
|
|
8
|
-
"
|
|
8
|
+
"description": "多账号:一个插件实例挂多个 endpoint。每项与顶层字段同构(name 必填,其余覆盖顶层)",
|
|
9
9
|
"items": {
|
|
10
10
|
"type": "object",
|
|
11
|
-
"additionalProperties":
|
|
11
|
+
"additionalProperties": true,
|
|
12
12
|
"properties": {
|
|
13
|
-
"
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
13
|
+
"name": {
|
|
14
|
+
"type": "string",
|
|
15
|
+
"description": "Sandbox bot name"
|
|
16
|
+
},
|
|
17
|
+
"context": {
|
|
18
|
+
"type": "string",
|
|
19
|
+
"description": "Sandbox context identifier"
|
|
20
|
+
},
|
|
21
|
+
"owner": {
|
|
22
|
+
"type": "string",
|
|
23
|
+
"description": "Sandbox owner user ID"
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
"required": [
|
|
27
|
+
"name"
|
|
28
|
+
]
|
|
17
29
|
}
|
|
30
|
+
},
|
|
31
|
+
"commandPrefix": {
|
|
32
|
+
"type": "string",
|
|
33
|
+
"default": "",
|
|
34
|
+
"description": "命令前缀(默认 '' 无前缀,任意文本按命令匹配;如 '/' 要求 / 开头)。endpoints[i] 可逐项覆盖"
|
|
18
35
|
}
|
|
19
|
-
}
|
|
36
|
+
},
|
|
37
|
+
"required": [
|
|
38
|
+
"endpoints"
|
|
39
|
+
]
|
|
20
40
|
}
|
package/src/endpoint.ts
CHANGED
|
@@ -18,6 +18,11 @@ import {
|
|
|
18
18
|
|
|
19
19
|
const logger = getLogger('sandbox');
|
|
20
20
|
|
|
21
|
+
interface SandboxChannel {
|
|
22
|
+
readonly type: string;
|
|
23
|
+
readonly id: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
21
26
|
interface SandboxConnection {
|
|
22
27
|
readonly target: string;
|
|
23
28
|
readonly owner: string;
|
|
@@ -25,6 +30,11 @@ interface SandboxConnection {
|
|
|
25
30
|
readonly release: () => void;
|
|
26
31
|
/** true = 占位连接(尚无真实 WS 客户端),send 命中时按 miss 处理。 */
|
|
27
32
|
readonly placeholder?: boolean;
|
|
33
|
+
/**
|
|
34
|
+
* 最近一条入站频道(Console UI 按 type+id 过滤气泡)。
|
|
35
|
+
* 出站必须带回,否则回复石沉大海。
|
|
36
|
+
*/
|
|
37
|
+
lastChannel?: SandboxChannel;
|
|
28
38
|
}
|
|
29
39
|
|
|
30
40
|
export interface SandboxEndpointOptions {
|
|
@@ -79,7 +89,16 @@ export class SandboxWsEndpoint implements EndpointInstance {
|
|
|
79
89
|
this.#open = false;
|
|
80
90
|
this.#wsHandleRelease?.();
|
|
81
91
|
this.#wsHandleRelease = undefined;
|
|
82
|
-
for (const connection of this.#connections.values())
|
|
92
|
+
for (const connection of this.#connections.values()) {
|
|
93
|
+
connection.release();
|
|
94
|
+
if (!connection.placeholder) {
|
|
95
|
+
try {
|
|
96
|
+
connection.socket.close(1001, 'sandbox endpoint stopped');
|
|
97
|
+
} catch {
|
|
98
|
+
/* already closed */
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
83
102
|
this.#connections.clear();
|
|
84
103
|
this.#started = false;
|
|
85
104
|
logger.debug(formatCompact({ op: 'sandbox_stopped' }));
|
|
@@ -87,7 +106,9 @@ export class SandboxWsEndpoint implements EndpointInstance {
|
|
|
87
106
|
|
|
88
107
|
send({ target, payload }: { readonly target: string; readonly payload: unknown }): unknown {
|
|
89
108
|
if (!this.#open) return undefined;
|
|
90
|
-
|
|
109
|
+
// Reply target is the connection key (bot name / sandbox-uuid), not private:channelId.
|
|
110
|
+
const connection = this.#connections.get(target)
|
|
111
|
+
?? this.#findLiveConnection();
|
|
91
112
|
if (!connection) {
|
|
92
113
|
logger.debug(formatCompact({ op: 'sandbox_send_miss', target }));
|
|
93
114
|
return undefined;
|
|
@@ -96,31 +117,75 @@ export class SandboxWsEndpoint implements EndpointInstance {
|
|
|
96
117
|
logger.debug(formatCompact({ op: 'sandbox_send_placeholder', target }));
|
|
97
118
|
return undefined;
|
|
98
119
|
}
|
|
99
|
-
|
|
100
|
-
|
|
120
|
+
// Console UI filters by type+id; stamp last inbound channel onto outbound wire.
|
|
121
|
+
const channel = connection.lastChannel ?? {
|
|
122
|
+
type: 'private',
|
|
123
|
+
id: connection.owner,
|
|
124
|
+
};
|
|
125
|
+
connection.socket.send(formatSandboxOutbound(payload, {
|
|
126
|
+
type: channel.type,
|
|
127
|
+
id: channel.id,
|
|
128
|
+
bot: this.#options.defaults.name,
|
|
129
|
+
endpoint: connection.target,
|
|
130
|
+
}));
|
|
131
|
+
logger.debug(formatCompact({
|
|
132
|
+
op: 'sandbox_send',
|
|
133
|
+
target: connection.target,
|
|
134
|
+
channelType: channel.type,
|
|
135
|
+
channelId: channel.id,
|
|
136
|
+
}));
|
|
101
137
|
return payload;
|
|
102
138
|
}
|
|
103
139
|
|
|
140
|
+
/** Prefer a real (non-placeholder) socket when reply target key is wrong/stale. */
|
|
141
|
+
#findLiveConnection(): SandboxConnection | undefined {
|
|
142
|
+
for (const connection of this.#connections.values()) {
|
|
143
|
+
if (!connection.placeholder) return connection;
|
|
144
|
+
}
|
|
145
|
+
return undefined;
|
|
146
|
+
}
|
|
147
|
+
|
|
104
148
|
#acceptConnection(connection: WsConnection): void {
|
|
105
149
|
const target = this.#options.defaults.randomNamePerConnection
|
|
106
150
|
? `sandbox-${randomUUID().slice(0, 8)}`
|
|
107
151
|
: this.#options.defaults.name;
|
|
108
152
|
const owner = this.#options.defaults.owner;
|
|
109
153
|
const socket = connection.socket as SandboxWsSocket;
|
|
110
|
-
|
|
111
|
-
|
|
154
|
+
// Fixed-name mode reuses `target`; dropping the prior entry without
|
|
155
|
+
// closing its socket leaves a zombie browser tab that still looks
|
|
156
|
+
// connected but never receives outbound traffic.
|
|
157
|
+
const previous = this.#connections.get(target);
|
|
158
|
+
if (previous) {
|
|
159
|
+
previous.release();
|
|
160
|
+
if (!previous.placeholder) {
|
|
161
|
+
try {
|
|
162
|
+
previous.socket.close(4000, 'replaced by new sandbox client');
|
|
163
|
+
} catch {
|
|
164
|
+
/* already closed */
|
|
165
|
+
}
|
|
166
|
+
}
|
|
112
167
|
this.#connections.delete(target);
|
|
113
168
|
}
|
|
114
169
|
const release = bindSandboxWsSocket(socket, {
|
|
115
170
|
onMessage: (raw) => {
|
|
116
|
-
if (!this.#open) return;
|
|
117
171
|
const parsed = parseSandboxWsPayload(raw);
|
|
172
|
+
const conn = this.#connections.get(target);
|
|
173
|
+
if (conn && !conn.placeholder) {
|
|
174
|
+
conn.lastChannel = {
|
|
175
|
+
type: parsed.type,
|
|
176
|
+
id: parsed.id || owner,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
118
179
|
logger.debug(formatCompact({
|
|
119
180
|
op: 'sandbox_recv',
|
|
120
181
|
target,
|
|
121
182
|
sender: parsed.id || owner,
|
|
183
|
+
channelType: parsed.type,
|
|
184
|
+
channelId: parsed.id || owner,
|
|
122
185
|
text: parsed.text.slice(0, 80),
|
|
123
186
|
}));
|
|
187
|
+
// Don't gate on #open — inbound must always reach the gateway so
|
|
188
|
+
// Command/AI dispatch and outbound replies work.
|
|
124
189
|
void this.#options.gateway.receive({
|
|
125
190
|
adapter: this.#options.id,
|
|
126
191
|
target,
|
|
@@ -128,6 +193,9 @@ export class SandboxWsEndpoint implements EndpointInstance {
|
|
|
128
193
|
sender: parsed.id || owner,
|
|
129
194
|
metadata: Object.freeze({
|
|
130
195
|
type: parsed.type,
|
|
196
|
+
channelType: parsed.type,
|
|
197
|
+
channelId: parsed.id || owner,
|
|
198
|
+
endpoint: target,
|
|
131
199
|
elements: parsed.content,
|
|
132
200
|
timestamp: parsed.timestamp,
|
|
133
201
|
...(parsed.action ? { action: parsed.action } : {}),
|
|
@@ -141,8 +209,13 @@ export class SandboxWsEndpoint implements EndpointInstance {
|
|
|
141
209
|
});
|
|
142
210
|
},
|
|
143
211
|
onClose: () => {
|
|
144
|
-
this
|
|
145
|
-
|
|
212
|
+
// Only drop the map entry if we still own this socket — a replace
|
|
213
|
+
// may have already swapped in a newer connection for the same target.
|
|
214
|
+
const current = this.#connections.get(target);
|
|
215
|
+
if (current && current.socket === socket) {
|
|
216
|
+
this.#connections.delete(target);
|
|
217
|
+
logger.debug(formatCompact({ op: 'sandbox_ws_closed', target }));
|
|
218
|
+
}
|
|
146
219
|
},
|
|
147
220
|
onError: (err) => {
|
|
148
221
|
logger.warn(formatCompact({
|
|
@@ -152,7 +225,7 @@ export class SandboxWsEndpoint implements EndpointInstance {
|
|
|
152
225
|
}));
|
|
153
226
|
},
|
|
154
227
|
});
|
|
155
|
-
this.#connections.set(target,
|
|
228
|
+
this.#connections.set(target, { target, owner, socket, release });
|
|
156
229
|
logger.debug(formatCompact({ op: 'sandbox_ws_connected', target, owner }));
|
|
157
230
|
if (!this.#options.defaults.randomNamePerConnection) {
|
|
158
231
|
const readyPayload = JSON.stringify({
|
|
@@ -177,12 +250,12 @@ export class SandboxWsEndpoint implements EndpointInstance {
|
|
|
177
250
|
|
|
178
251
|
#ensurePlaceholder(name: string, owner: string): void {
|
|
179
252
|
if (this.#connections.has(name)) return;
|
|
180
|
-
this.#connections.set(name,
|
|
253
|
+
this.#connections.set(name, {
|
|
181
254
|
target: name,
|
|
182
255
|
owner,
|
|
183
256
|
socket: { send: () => undefined, close: () => undefined },
|
|
184
257
|
release: () => undefined,
|
|
185
258
|
placeholder: true,
|
|
186
|
-
})
|
|
259
|
+
});
|
|
187
260
|
}
|
|
188
261
|
}
|
package/src/protocol.ts
CHANGED
|
@@ -161,26 +161,66 @@ export function parseSandboxWsPayload(raw: string): {
|
|
|
161
161
|
return { type, id, content, timestamp: payload.timestamp ?? Date.now(), text, action };
|
|
162
162
|
}
|
|
163
163
|
|
|
164
|
+
export type SandboxOutboundChannel = {
|
|
165
|
+
readonly type?: string;
|
|
166
|
+
readonly id?: string;
|
|
167
|
+
readonly bot?: string;
|
|
168
|
+
readonly endpoint?: string;
|
|
169
|
+
readonly messageId?: string;
|
|
170
|
+
};
|
|
171
|
+
|
|
164
172
|
/**
|
|
165
173
|
* Wire-encode an already-rendered outbound payload.
|
|
166
|
-
*
|
|
167
|
-
*
|
|
168
|
-
* owns that before `endpoint.send`.
|
|
174
|
+
* Stamps `channel` so Console SandboxChat can filter by type+id (otherwise
|
|
175
|
+
* replies look like they disappeared).
|
|
169
176
|
*/
|
|
170
|
-
export function formatSandboxOutbound(
|
|
177
|
+
export function formatSandboxOutbound(
|
|
178
|
+
payload: unknown,
|
|
179
|
+
channel: SandboxOutboundChannel = {},
|
|
180
|
+
): string {
|
|
181
|
+
const stamp: Record<string, unknown> = {};
|
|
182
|
+
if (channel.type) stamp.type = channel.type;
|
|
183
|
+
if (channel.id) stamp.id = channel.id;
|
|
184
|
+
if (channel.bot) stamp.bot = channel.bot;
|
|
185
|
+
if (channel.endpoint) stamp.endpoint = channel.endpoint;
|
|
186
|
+
if (channel.messageId) stamp.messageId = channel.messageId;
|
|
187
|
+
|
|
171
188
|
if (typeof payload === 'string') {
|
|
172
189
|
return JSON.stringify({
|
|
190
|
+
...stamp,
|
|
173
191
|
content: [{ type: 'text', data: { text: payload } }],
|
|
174
192
|
timestamp: Date.now(),
|
|
175
193
|
});
|
|
176
194
|
}
|
|
177
195
|
if (Array.isArray(payload)) {
|
|
178
196
|
return JSON.stringify({
|
|
197
|
+
...stamp,
|
|
179
198
|
content: payload,
|
|
180
199
|
timestamp: Date.now(),
|
|
181
200
|
});
|
|
182
201
|
}
|
|
183
|
-
|
|
202
|
+
// Already a wire envelope ({ content, type, … }) — pass through so the
|
|
203
|
+
// Console UI can read `content` / `type` without an extra nesting layer.
|
|
204
|
+
// Bare segment objects ({ type: 'text', data: … }) still need wrapping.
|
|
205
|
+
if (
|
|
206
|
+
payload
|
|
207
|
+
&& typeof payload === 'object'
|
|
208
|
+
&& !Array.isArray(payload)
|
|
209
|
+
&& (
|
|
210
|
+
'content' in (payload as object)
|
|
211
|
+
|| 'type' in (payload as object) && 'timestamp' in (payload as object)
|
|
212
|
+
)
|
|
213
|
+
) {
|
|
214
|
+
const envelope = payload as Record<string, unknown>;
|
|
215
|
+
return JSON.stringify({
|
|
216
|
+
...stamp,
|
|
217
|
+
...envelope,
|
|
218
|
+
type: envelope.type ?? stamp.type,
|
|
219
|
+
id: envelope.id ?? stamp.id,
|
|
220
|
+
timestamp: typeof envelope.timestamp === 'number' ? envelope.timestamp : Date.now(),
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
return JSON.stringify({ ...stamp, content: payload, timestamp: Date.now() });
|
|
184
224
|
}
|
|
185
225
|
|
|
186
226
|
/** WebSocket.OPEN 常量值;Node <22 无全局 WebSocket,不能用 WebSocket.OPEN。 */
|