@zhin.js/adapter-sandbox 5.0.5 → 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.
@@ -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';
@@ -0,0 +1,200 @@
1
+ /** Sandbox WebSocket wire protocol helpers (no legacy Adapter/Endpoint). */
2
+
3
+ export type MessageType = 'private' | 'group' | 'guild' | 'direct' | 'channel';
4
+
5
+ export interface MessageElement {
6
+ readonly type: string;
7
+ readonly data?: Record<string, unknown>;
8
+ }
9
+
10
+ export interface SandboxWsSocket {
11
+ send(data: string): void;
12
+ close(code?: number, reason?: string): void;
13
+ on?(event: 'message' | 'close' | 'error', listener: (...args: unknown[]) => void): void;
14
+ off?(
15
+ event: 'message' | 'close' | 'error',
16
+ listener: (...args: unknown[]) => void,
17
+ ): void;
18
+ addEventListener?(
19
+ type: 'message' | 'close' | 'error',
20
+ listener: (ev: Event | MessageEvent | CloseEvent) => void,
21
+ ): void;
22
+ removeEventListener?(
23
+ type: 'message' | 'close' | 'error',
24
+ listener: (ev: Event | MessageEvent | CloseEvent) => void,
25
+ ): void;
26
+ }
27
+
28
+ export type ResolvedSandboxBot = {
29
+ readonly context: 'sandbox';
30
+ readonly name: string;
31
+ readonly owner: string;
32
+ readonly randomNamePerConnection: boolean;
33
+ };
34
+
35
+ export interface SandboxAdapterConfig {
36
+ readonly endpoints?: ReadonlyArray<{
37
+ readonly context?: string;
38
+ readonly name?: string;
39
+ readonly owner?: string;
40
+ }>;
41
+ }
42
+
43
+ export function resolveSandboxEndpoint(
44
+ appConfig: SandboxAdapterConfig,
45
+ ): ResolvedSandboxBot {
46
+ const entry = appConfig.endpoints?.find((item) => item.context === 'sandbox');
47
+ const fixedName = typeof entry?.name === 'string' ? entry.name : undefined;
48
+ const name = fixedName || process.env.SANDBOX_BOT_NAME || 'sandbox-bot';
49
+ const owner = (typeof entry?.owner === 'string' && entry.owner)
50
+ || process.env.SANDBOX_BOT_OWNER
51
+ || 'sandbox-user';
52
+ return {
53
+ context: 'sandbox',
54
+ name,
55
+ owner,
56
+ randomNamePerConnection: !fixedName,
57
+ };
58
+ }
59
+
60
+ export function bindSandboxWsSocket(
61
+ ws: SandboxWsSocket,
62
+ handlers: {
63
+ onMessage: (raw: string) => void;
64
+ onClose: () => void;
65
+ onError?: (err: unknown) => void;
66
+ },
67
+ ): () => void {
68
+ if (typeof ws.on === 'function') {
69
+ const onMessage = (...args: unknown[]) => {
70
+ const data = args[0];
71
+ const raw = typeof data === 'string'
72
+ ? data
73
+ : data instanceof ArrayBuffer
74
+ ? new TextDecoder().decode(data)
75
+ : Buffer.isBuffer(data)
76
+ ? data.toString()
77
+ : String(data ?? '');
78
+ handlers.onMessage(raw);
79
+ };
80
+ ws.on('message', onMessage);
81
+ ws.on('close', handlers.onClose);
82
+ if (handlers.onError) ws.on('error', handlers.onError);
83
+ return () => {
84
+ ws.off?.('message', onMessage);
85
+ ws.off?.('close', handlers.onClose);
86
+ if (handlers.onError) ws.off?.('error', handlers.onError);
87
+ };
88
+ }
89
+ const onMessage = (ev: Event) => {
90
+ const data = (ev as MessageEvent).data;
91
+ handlers.onMessage(typeof data === 'string' ? data : '');
92
+ };
93
+ const onClose = () => handlers.onClose();
94
+ const onError = handlers.onError
95
+ ? () => handlers.onError?.(new Error('WebSocket error'))
96
+ : undefined;
97
+ ws.addEventListener!('message', onMessage);
98
+ ws.addEventListener!('close', onClose);
99
+ if (onError) ws.addEventListener!('error', onError);
100
+ return () => {
101
+ ws.removeEventListener!('message', onMessage);
102
+ ws.removeEventListener!('close', onClose);
103
+ if (onError) ws.removeEventListener!('error', onError);
104
+ };
105
+ }
106
+
107
+ export function parseSandboxWsPayload(raw: string): {
108
+ type: MessageType;
109
+ id: string;
110
+ content: MessageElement[];
111
+ timestamp: number;
112
+ text: string;
113
+ action?: { id: string; payload: string };
114
+ } {
115
+ let payload: {
116
+ type?: MessageType;
117
+ id?: string;
118
+ content?: MessageElement[] | string;
119
+ text?: string;
120
+ timestamp?: number;
121
+ };
122
+ try {
123
+ payload = JSON.parse(raw) as typeof payload;
124
+ } catch {
125
+ payload = { text: raw };
126
+ }
127
+ const type = payload.type ?? 'private';
128
+ const id = payload.id ?? 'sandbox-user';
129
+ const content: MessageElement[] = typeof payload.content === 'string'
130
+ ? [{ type: 'text', data: { text: payload.content } }]
131
+ : Array.isArray(payload.content)
132
+ ? payload.content
133
+ : [{ type: 'text', data: { text: payload.text ?? raw } }];
134
+
135
+ const actionSegment = content.find((segment) => segment.type === 'action');
136
+ let action: { id: string; payload: string } | undefined;
137
+ if (actionSegment?.data) {
138
+ const actionPayload = typeof actionSegment.data.payload === 'string'
139
+ ? actionSegment.data.payload
140
+ : typeof actionSegment.data.id === 'string'
141
+ ? actionSegment.data.id
142
+ : '';
143
+ const actionId = typeof actionSegment.data.id === 'string'
144
+ ? actionSegment.data.id
145
+ : actionPayload;
146
+ if (actionId || actionPayload) {
147
+ action = { id: actionId || actionPayload, payload: actionPayload || actionId };
148
+ }
149
+ }
150
+
151
+ let text = content
152
+ .flatMap((segment) => (segment.type === 'text' && typeof segment.data?.text === 'string'
153
+ ? [segment.data.text]
154
+ : []))
155
+ .join('\n');
156
+ if (!text.trim()) {
157
+ text = (typeof payload.text === 'string' && payload.text.trim())
158
+ ? payload.text
159
+ : action?.payload ?? raw;
160
+ }
161
+ return { type, id, content, timestamp: payload.timestamp ?? Date.now(), text, action };
162
+ }
163
+
164
+ /**
165
+ * Wire-encode an already-rendered outbound payload.
166
+ * Canonical segment mapping (old `segment-mapper` re-export of `to/fromCanonicalSegments`
167
+ * from legacy `zhin.js`) is intentionally not done here — the gateway/core render path
168
+ * owns that before `endpoint.send`.
169
+ */
170
+ export function formatSandboxOutbound(payload: unknown): string {
171
+ if (typeof payload === 'string') {
172
+ return JSON.stringify({
173
+ content: [{ type: 'text', data: { text: payload } }],
174
+ timestamp: Date.now(),
175
+ });
176
+ }
177
+ if (Array.isArray(payload)) {
178
+ return JSON.stringify({
179
+ content: payload,
180
+ timestamp: Date.now(),
181
+ });
182
+ }
183
+ return JSON.stringify({ content: payload, timestamp: Date.now() });
184
+ }
185
+
186
+ /** WebSocket.OPEN 常量值;Node <22 无全局 WebSocket,不能用 WebSocket.OPEN。 */
187
+ const WS_OPEN = 1;
188
+
189
+ export function whenWsOpen(ws: SandboxWsSocket, fn: () => void): void {
190
+ const std = ws as WebSocket;
191
+ if (typeof std.readyState === 'number') {
192
+ if (std.readyState === WS_OPEN) {
193
+ fn();
194
+ return;
195
+ }
196
+ std.addEventListener('open', fn, { once: true });
197
+ return;
198
+ }
199
+ fn();
200
+ }