@zhin.js/adapter-sandbox 5.0.5 → 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.
@@ -0,0 +1,261 @@
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 SandboxChannel {
22
+ readonly type: string;
23
+ readonly id: string;
24
+ }
25
+
26
+ interface SandboxConnection {
27
+ readonly target: string;
28
+ readonly owner: string;
29
+ readonly socket: SandboxWsSocket;
30
+ readonly release: () => void;
31
+ /** true = 占位连接(尚无真实 WS 客户端),send 命中时按 miss 处理。 */
32
+ readonly placeholder?: boolean;
33
+ /**
34
+ * 最近一条入站频道(Console UI 按 type+id 过滤气泡)。
35
+ * 出站必须带回,否则回复石沉大海。
36
+ */
37
+ lastChannel?: SandboxChannel;
38
+ }
39
+
40
+ export interface SandboxEndpointOptions {
41
+ readonly id: CapabilityId;
42
+ readonly gateway: MessageGateway;
43
+ readonly http: HttpHost;
44
+ readonly defaults: ResolvedSandboxBot;
45
+ }
46
+
47
+ export class SandboxWsEndpoint implements EndpointInstance {
48
+ readonly #options: SandboxEndpointOptions;
49
+ readonly #connections = new Map<string, SandboxConnection>();
50
+ #wsHandleRelease?: () => void;
51
+ #open = false;
52
+ #started = false;
53
+
54
+ constructor(options: SandboxEndpointOptions) {
55
+ this.#options = options;
56
+ }
57
+
58
+ /** Live endpoint name (config `name`) — Console endpoint.list/resolve uses it. */
59
+ get name(): string {
60
+ return this.#options.defaults.name;
61
+ }
62
+
63
+ start(): void {
64
+ if (this.#started) return;
65
+ this.#started = true;
66
+ const handle = this.#options.http.ws('/sandbox');
67
+ this.#wsHandleRelease = handle.onConnection((connection) => {
68
+ this.#acceptConnection(connection);
69
+ });
70
+ if (!this.#options.defaults.randomNamePerConnection) {
71
+ this.#ensurePlaceholder(this.#options.defaults.name, this.#options.defaults.owner);
72
+ }
73
+ logger.info(formatCompact({
74
+ op: 'sandbox_ws_mounted',
75
+ path: '/sandbox',
76
+ endpoint: this.#options.defaults.name,
77
+ }));
78
+ }
79
+
80
+ open(): void {
81
+ this.#open = true;
82
+ }
83
+
84
+ close(): void {
85
+ this.#open = false;
86
+ }
87
+
88
+ stop(): void {
89
+ this.#open = false;
90
+ this.#wsHandleRelease?.();
91
+ this.#wsHandleRelease = undefined;
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
+ }
102
+ this.#connections.clear();
103
+ this.#started = false;
104
+ logger.debug(formatCompact({ op: 'sandbox_stopped' }));
105
+ }
106
+
107
+ send({ target, payload }: { readonly target: string; readonly payload: unknown }): unknown {
108
+ if (!this.#open) return undefined;
109
+ // Reply target is the connection key (bot name / sandbox-uuid), not private:channelId.
110
+ const connection = this.#connections.get(target)
111
+ ?? this.#findLiveConnection();
112
+ if (!connection) {
113
+ logger.debug(formatCompact({ op: 'sandbox_send_miss', target }));
114
+ return undefined;
115
+ }
116
+ if (connection.placeholder) {
117
+ logger.debug(formatCompact({ op: 'sandbox_send_placeholder', target }));
118
+ return undefined;
119
+ }
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
+ }));
137
+ return payload;
138
+ }
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
+
148
+ #acceptConnection(connection: WsConnection): void {
149
+ const target = this.#options.defaults.randomNamePerConnection
150
+ ? `sandbox-${randomUUID().slice(0, 8)}`
151
+ : this.#options.defaults.name;
152
+ const owner = this.#options.defaults.owner;
153
+ const socket = connection.socket as SandboxWsSocket;
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
+ }
167
+ this.#connections.delete(target);
168
+ }
169
+ const release = bindSandboxWsSocket(socket, {
170
+ onMessage: (raw) => {
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
+ }
179
+ logger.debug(formatCompact({
180
+ op: 'sandbox_recv',
181
+ target,
182
+ sender: parsed.id || owner,
183
+ channelType: parsed.type,
184
+ channelId: parsed.id || owner,
185
+ text: parsed.text.slice(0, 80),
186
+ }));
187
+ // Don't gate on #open — inbound must always reach the gateway so
188
+ // Command/AI dispatch and outbound replies work.
189
+ void this.#options.gateway.receive({
190
+ adapter: this.#options.id,
191
+ target,
192
+ content: parsed.text,
193
+ sender: parsed.id || owner,
194
+ metadata: Object.freeze({
195
+ type: parsed.type,
196
+ channelType: parsed.type,
197
+ channelId: parsed.id || owner,
198
+ endpoint: target,
199
+ elements: parsed.content,
200
+ timestamp: parsed.timestamp,
201
+ ...(parsed.action ? { action: parsed.action } : {}),
202
+ }),
203
+ }).catch((err) => {
204
+ logger.warn(formatCompact({
205
+ op: 'sandbox_gateway_receive_failed',
206
+ target,
207
+ error: err instanceof Error ? err.message : String(err),
208
+ }));
209
+ });
210
+ },
211
+ onClose: () => {
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
+ }
219
+ },
220
+ onError: (err) => {
221
+ logger.warn(formatCompact({
222
+ op: 'sandbox_ws_error',
223
+ target,
224
+ error: err instanceof Error ? err.message : String(err),
225
+ }));
226
+ },
227
+ });
228
+ this.#connections.set(target, { target, owner, socket, release });
229
+ logger.debug(formatCompact({ op: 'sandbox_ws_connected', target, owner }));
230
+ if (!this.#options.defaults.randomNamePerConnection) {
231
+ const readyPayload = JSON.stringify({
232
+ type: 'ready',
233
+ id: owner,
234
+ endpoint: target,
235
+ content: [{
236
+ type: 'text',
237
+ data: {
238
+ text: [
239
+ `已连接 Sandbox「${target}」`,
240
+ '与 Node Host 控制台沙盒协议一致(/sandbox)',
241
+ '命令: help · ping · zt · status',
242
+ ].join('\n'),
243
+ },
244
+ }],
245
+ timestamp: Date.now(),
246
+ });
247
+ whenWsOpen(socket, () => socket.send(readyPayload));
248
+ }
249
+ }
250
+
251
+ #ensurePlaceholder(name: string, owner: string): void {
252
+ if (this.#connections.has(name)) return;
253
+ this.#connections.set(name, {
254
+ target: name,
255
+ owner,
256
+ socket: { send: () => undefined, close: () => undefined },
257
+ release: () => undefined,
258
+ placeholder: true,
259
+ });
260
+ }
261
+ }
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,240 @@
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
+ 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
+
172
+ /**
173
+ * Wire-encode an already-rendered outbound payload.
174
+ * Stamps `channel` so Console SandboxChat can filter by type+id (otherwise
175
+ * replies look like they disappeared).
176
+ */
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
+
188
+ if (typeof payload === 'string') {
189
+ return JSON.stringify({
190
+ ...stamp,
191
+ content: [{ type: 'text', data: { text: payload } }],
192
+ timestamp: Date.now(),
193
+ });
194
+ }
195
+ if (Array.isArray(payload)) {
196
+ return JSON.stringify({
197
+ ...stamp,
198
+ content: payload,
199
+ timestamp: Date.now(),
200
+ });
201
+ }
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() });
224
+ }
225
+
226
+ /** WebSocket.OPEN 常量值;Node <22 无全局 WebSocket,不能用 WebSocket.OPEN。 */
227
+ const WS_OPEN = 1;
228
+
229
+ export function whenWsOpen(ws: SandboxWsSocket, fn: () => void): void {
230
+ const std = ws as WebSocket;
231
+ if (typeof std.readyState === 'number') {
232
+ if (std.readyState === WS_OPEN) {
233
+ fn();
234
+ return;
235
+ }
236
+ std.addEventListener('open', fn, { once: true });
237
+ return;
238
+ }
239
+ fn();
240
+ }
package/client/index.tsx DELETED
@@ -1,11 +0,0 @@
1
- import type { PluginRegisterHostApi } from '@zhin.js/contract'
2
- import Sandbox from './Sandbox'
3
-
4
- export function register(api: PluginRegisterHostApi) {
5
- api.addRoute({
6
- path: '/console/sandbox',
7
- name: '沙盒',
8
- element: api.React.createElement(Sandbox, { hostReact: api.React }),
9
- })
10
- api.addTool({ id: 'sandbox', name: '沙盒', path: '/console/sandbox' })
11
- }
@@ -1,28 +0,0 @@
1
- export function getSandboxApiBase(): string {
2
- const stored = localStorage.getItem("zhin_api_base")?.trim();
3
- return (stored ? stored.replace(/\/$/, "") : null) ?? window.location.origin;
4
- }
5
-
6
- export function getSandboxBearerToken(): string {
7
- return (
8
- localStorage.getItem("zhin_api_token")?.trim() ||
9
- localStorage.getItem("HTTP_TOKEN")?.trim() ||
10
- localStorage.getItem("zhin_http_token")?.trim() ||
11
- ""
12
- );
13
- }
14
-
15
- export function getSandboxAuthHeaders(): Record<string, string> {
16
- const token = getSandboxBearerToken();
17
- return token ? { Authorization: `Bearer ${token}` } : {};
18
- }
19
-
20
- /** Browser WebSocket cannot set Authorization reliably; pass token in query when needed. */
21
- export function buildSandboxWebSocketUrl(base?: string): string {
22
- const apiBase = (base ?? getSandboxApiBase()).replace(/\/$/, "");
23
- const wsUrl = new URL("/sandbox", `${apiBase}/`);
24
- wsUrl.protocol = wsUrl.protocol === "https:" ? "wss:" : "ws:";
25
- const token = getSandboxBearerToken();
26
- if (token) wsUrl.searchParams.set("token", token);
27
- return wsUrl.href;
28
- }
@@ -1,7 +0,0 @@
1
- {
2
- "extends": "../node_modules/@zhin.js/host-api/browser.tsconfig.json",
3
- "compilerOptions": {
4
- "outDir": "../dist"
5
- },
6
- "include": ["./**/*"]
7
- }