@zhin.js/client 2.0.5 → 2.1.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/README.md +6 -0
- package/client/bootstrap/loadConsoleEntries.test.ts +28 -2
- package/client/bootstrap/loadConsoleEntries.ts +39 -1
- package/client/console-utils/remoteApi.ts +11 -1
- package/client/index.ts +16 -0
- package/client/mediaSrc.test.ts +34 -0
- package/client/mediaSrc.ts +16 -1
- package/client/persistence/idb-store.ts +13 -16
- package/client/websocket/hooks.ts +38 -23
- package/client/websocket/manager.test.ts +216 -0
- package/client/websocket/manager.ts +57 -28
- package/client/websocket/remote-settings.ts +13 -1
- package/dist/bootstrap/loadConsoleEntries.d.ts +9 -0
- package/dist/bootstrap/loadConsoleEntries.d.ts.map +1 -1
- package/dist/bootstrap/loadConsoleEntries.js +36 -1
- package/dist/bootstrap/loadConsoleEntries.js.map +1 -1
- package/dist/console-utils/remoteApi.d.ts.map +1 -1
- package/dist/console-utils/remoteApi.js +14 -1
- package/dist/console-utils/remoteApi.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/mediaSrc.d.ts.map +1 -1
- package/dist/mediaSrc.js +17 -1
- package/dist/mediaSrc.js.map +1 -1
- package/dist/persistence/idb-store.d.ts.map +1 -1
- package/dist/persistence/idb-store.js +13 -17
- package/dist/persistence/idb-store.js.map +1 -1
- package/dist/websocket/hooks.d.ts.map +1 -1
- package/dist/websocket/hooks.js +38 -27
- package/dist/websocket/hooks.js.map +1 -1
- package/dist/websocket/manager.d.ts.map +1 -1
- package/dist/websocket/manager.js +47 -27
- package/dist/websocket/manager.js.map +1 -1
- package/dist/websocket/remote-settings.d.ts.map +1 -1
- package/dist/websocket/remote-settings.js +16 -1
- package/dist/websocket/remote-settings.js.map +1 -1
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -23,9 +23,15 @@ Peer:`react >= 18`(`createPluginRegisterHostApi` 需要 React 引用)。
|
|
|
23
23
|
| `app` | 路由与工具注册单例(`addRoute`、`addTool` 等) |
|
|
24
24
|
| `configureConsole` / `getRuntimeEnv` | 运行时环境(development / production) |
|
|
25
25
|
| `createRegistryStore` / `useRegistry` | 可选 registry store |
|
|
26
|
+
| `ENDPOINT_RPC` / `INBOX_RPC` / `SIDE_EVENT_RPC` / `SIDE_EVENT_PUSH` | `@zhin.js/console-protocol` 的规范 RPC 与推送名称 |
|
|
27
|
+
| `normalizeConsolePushType` / `normalizeConsolePushMessage` | 在 SDK 边界兼容旧 `endpoint:*` 推送并输出规范事件与 payload |
|
|
28
|
+
| `parseConsoleInboxEvent` | 一次完成 Inbox 推送名称、身份别名和 message/request/notice 分类 |
|
|
29
|
+
| `ConsoleEndpointSummary` / `EndpointManagementCapability` | Host 与 Remote Console 共享的 Endpoint wire 类型 |
|
|
26
30
|
|
|
27
31
|
类型与 Entry 契约来自 `@zhin.js/contract`。
|
|
28
32
|
|
|
33
|
+
业务 UI 应使用 SDK 导出的协议常量和 Endpoint 类型,不要硬编码旧 `endpoint:list`、`endpoint:sendMessage` 等名称,也不要复制 `EndpointInfo`。SDK 会把旧 Host 推送别名归一化为 `message.receive` / `request.receive` / `notice.receive`,并统一 `endpointId`、`channelId` 字段。
|
|
34
|
+
|
|
29
35
|
## 启动:加载插件 Console Entry
|
|
30
36
|
|
|
31
37
|
Remote Console 壳层在登录后调用:
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { describe, expect, it, vi } from "vitest";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
createPluginRegisterHostApi,
|
|
4
|
+
getRegisterFn,
|
|
5
|
+
resolveEntryRegister,
|
|
6
|
+
} from "./loadConsoleEntries.js";
|
|
3
7
|
|
|
4
8
|
describe("console entries bootstrap", () => {
|
|
5
9
|
it("resolves named register export", () => {
|
|
@@ -16,6 +20,29 @@ describe("console entries bootstrap", () => {
|
|
|
16
20
|
expect(getRegisterFn({ default: {} })).toBeNull();
|
|
17
21
|
});
|
|
18
22
|
|
|
23
|
+
it("synthesizes register from default component + entry.route", () => {
|
|
24
|
+
const Page = () => null;
|
|
25
|
+
const register = resolveEntryRegister(
|
|
26
|
+
{ default: Page, meta: { title: "Sandbox", icon: "Box" } },
|
|
27
|
+
{ id: "sandbox", resolvedModule: "/assets/client/sandbox.js", route: "/p-sandbox" } as any,
|
|
28
|
+
);
|
|
29
|
+
expect(typeof register).toBe("function");
|
|
30
|
+
const addRoute = vi.fn();
|
|
31
|
+
const createElement = vi.fn((c: unknown) => ({ type: c }));
|
|
32
|
+
register!({
|
|
33
|
+
React: { createElement } as any,
|
|
34
|
+
addRoute,
|
|
35
|
+
addPage: addRoute,
|
|
36
|
+
addTool: vi.fn(),
|
|
37
|
+
});
|
|
38
|
+
expect(addRoute).toHaveBeenCalledWith(expect.objectContaining({
|
|
39
|
+
path: "/p-sandbox",
|
|
40
|
+
name: "Sandbox",
|
|
41
|
+
icon: "Box",
|
|
42
|
+
}));
|
|
43
|
+
expect(createElement).toHaveBeenCalledWith(Page);
|
|
44
|
+
});
|
|
45
|
+
|
|
19
46
|
it("creates a host API with addPage aliased to addRoute", () => {
|
|
20
47
|
const React = {} as any;
|
|
21
48
|
const addRoute = vi.fn();
|
|
@@ -29,4 +56,3 @@ describe("console entries bootstrap", () => {
|
|
|
29
56
|
expect(api.addTool({ id: "t", name: "T" })).toBe("tool-id");
|
|
30
57
|
});
|
|
31
58
|
});
|
|
32
|
-
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { DEFAULT_CONSOLE_BASE_PATH, type ConsoleClientEntry, type ConsoleEntriesResponse, type ConsolePluginRegister, type PluginRegisterHostApi } from '@zhin.js/contract';
|
|
2
|
+
import type * as React from 'react';
|
|
2
3
|
|
|
3
4
|
export type FetchConsoleEntriesOptions = {
|
|
4
5
|
entriesUrl?: string;
|
|
@@ -91,6 +92,43 @@ export function getRegisterFn(mod: Record<string, unknown> | null | undefined):
|
|
|
91
92
|
return null;
|
|
92
93
|
}
|
|
93
94
|
|
|
95
|
+
/**
|
|
96
|
+
* Convention pages (ADR 0046) export a React component as `default` + optional `meta`,
|
|
97
|
+
* without a legacy `register(api)`. Synthesize register from entry.route / entry meta
|
|
98
|
+
* so Remote Console can still mount them.
|
|
99
|
+
*/
|
|
100
|
+
export function resolveEntryRegister(
|
|
101
|
+
mod: Record<string, unknown> | null | undefined,
|
|
102
|
+
entry: ConsoleClientEntry & { readonly route?: string; readonly title?: string },
|
|
103
|
+
): ConsolePluginRegister | null {
|
|
104
|
+
const named = getRegisterFn(mod);
|
|
105
|
+
if (named) return named;
|
|
106
|
+
if (!mod) return null;
|
|
107
|
+
const Component = mod["default"];
|
|
108
|
+
if (typeof Component !== "function" && !(Component && typeof Component === "object")) {
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
const path = typeof entry.route === "string" && entry.route
|
|
112
|
+
? entry.route
|
|
113
|
+
: `/${entry.id}`;
|
|
114
|
+
const meta = mod["meta"] && typeof mod["meta"] === "object"
|
|
115
|
+
? mod["meta"] as { title?: string; icon?: unknown; hideInNav?: boolean }
|
|
116
|
+
: undefined;
|
|
117
|
+
const name = (typeof meta?.title === "string" && meta.title)
|
|
118
|
+
|| (typeof entry.title === "string" && entry.title)
|
|
119
|
+
|| entry.meta?.name
|
|
120
|
+
|| entry.id;
|
|
121
|
+
return (api) => {
|
|
122
|
+
api.addRoute({
|
|
123
|
+
path,
|
|
124
|
+
name,
|
|
125
|
+
element: api.React.createElement(Component as React.ComponentType),
|
|
126
|
+
...(meta?.icon != null ? { icon: meta.icon } : {}),
|
|
127
|
+
meta: { hideInMenu: meta?.hideInNav === true },
|
|
128
|
+
});
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
94
132
|
export async function registerConsolePluginsFromEntries(
|
|
95
133
|
entries: ConsoleClientEntry[],
|
|
96
134
|
hostApi: PluginRegisterHostApi,
|
|
@@ -103,7 +141,7 @@ export async function registerConsolePluginsFromEntries(
|
|
|
103
141
|
try {
|
|
104
142
|
const specifier = resolvePluginImportUrl(entry.resolvedModule, assetOrigin);
|
|
105
143
|
const mod = (await import(/* @vite-ignore */ specifier)) as Record<string, unknown>;
|
|
106
|
-
const register =
|
|
144
|
+
const register = resolveEntryRegister(mod, entry);
|
|
107
145
|
if (register) await register(hostApi);
|
|
108
146
|
else throw new Error(`entry "${entry.id}" has no register export`);
|
|
109
147
|
} catch (error) {
|
|
@@ -9,7 +9,17 @@ export function getApiBase(): string {
|
|
|
9
9
|
}
|
|
10
10
|
|
|
11
11
|
export function getToken(): string | null {
|
|
12
|
-
|
|
12
|
+
const runtime = getRuntimeToken();
|
|
13
|
+
if (runtime) return runtime;
|
|
14
|
+
if (typeof localStorage !== "undefined") {
|
|
15
|
+
const local = localStorage.getItem(TOKEN_KEY)?.trim();
|
|
16
|
+
if (local) return local;
|
|
17
|
+
}
|
|
18
|
+
if (typeof sessionStorage !== "undefined") {
|
|
19
|
+
const session = sessionStorage.getItem(TOKEN_KEY)?.trim();
|
|
20
|
+
if (session) return session;
|
|
21
|
+
}
|
|
22
|
+
return null;
|
|
13
23
|
}
|
|
14
24
|
|
|
15
25
|
export function resolveApiUrl(path: string): string {
|
package/client/index.ts
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
// Types
|
|
2
2
|
export * from "./types";
|
|
3
|
+
export {
|
|
4
|
+
ENDPOINT_RPC,
|
|
5
|
+
ENDPOINT_MANAGEMENT_CAPABILITIES,
|
|
6
|
+
INBOX_RPC,
|
|
7
|
+
SIDE_EVENT_PUSH,
|
|
8
|
+
SIDE_EVENT_RPC,
|
|
9
|
+
normalizeConsolePushMessage,
|
|
10
|
+
normalizeConsolePushType,
|
|
11
|
+
parseConsoleInboxEvent,
|
|
12
|
+
type ConsoleInboxEvent,
|
|
13
|
+
type ConsoleInboxEventKind,
|
|
14
|
+
type ConsoleEndpointPhase,
|
|
15
|
+
type ConsoleEndpointSummary,
|
|
16
|
+
type EndpointManagementCapability,
|
|
17
|
+
} from "@zhin.js/console-protocol";
|
|
3
18
|
|
|
4
19
|
// Media URL resolution
|
|
5
20
|
export { resolveMediaSrc, pickMediaRawUrl, type MediaKind } from "./mediaSrc";
|
|
@@ -46,6 +61,7 @@ export {
|
|
|
46
61
|
getRegisterFn,
|
|
47
62
|
loadConsoleEntries,
|
|
48
63
|
registerConsolePluginsFromEntries,
|
|
64
|
+
resolveEntryRegister,
|
|
49
65
|
type CreatePluginRegisterHostApiOptions,
|
|
50
66
|
type FetchConsoleEntriesOptions,
|
|
51
67
|
type LoadConsoleEntriesOptions,
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { pickMediaRawUrl, resolveMediaSrc } from '../client/mediaSrc.js';
|
|
3
|
+
|
|
4
|
+
describe('pickMediaRawUrl', () => {
|
|
5
|
+
it('prefers canonical media.value / url / file', () => {
|
|
6
|
+
expect(pickMediaRawUrl({ media: { value: 'https://a/b.png' } })).toBe('https://a/b.png');
|
|
7
|
+
expect(pickMediaRawUrl({ url: 'https://a/b.png' })).toBe('https://a/b.png');
|
|
8
|
+
expect(pickMediaRawUrl({ file: '/tmp/a.png' })).toBe('/tmp/a.png');
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it('reads agent outbound image segment (base64 + mime, no url)', () => {
|
|
12
|
+
// media-publisher 出站形态:{ type:'image', data:{ base64, mime, name } }
|
|
13
|
+
const raw = pickMediaRawUrl({ base64: 'aGVsbG8=', mime: 'image/png', name: 'system-status.png' });
|
|
14
|
+
expect(raw).toBe('data:image/png;base64,aGVsbG8=');
|
|
15
|
+
expect(resolveMediaSrc(raw, 'image')).toBe('data:image/png;base64,aGVsbG8=');
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('falls back to base64:// so resolveMediaSrc applies the kind default MIME', () => {
|
|
19
|
+
const raw = pickMediaRawUrl({ base64: 'aGVsbG8=' });
|
|
20
|
+
expect(raw).toBe('base64://aGVsbG8=');
|
|
21
|
+
expect(resolveMediaSrc(raw, 'image')).toBe('data:image/png;base64,aGVsbG8=');
|
|
22
|
+
expect(resolveMediaSrc(raw, 'audio')).toBe('data:audio/mpeg;base64,aGVsbG8=');
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('passes through base64 values that already carry a scheme', () => {
|
|
26
|
+
expect(pickMediaRawUrl({ base64: 'base64://aGVsbG8=' })).toBe('base64://aGVsbG8=');
|
|
27
|
+
expect(pickMediaRawUrl({ base64: 'data:image/png;base64,aGVsbG8=' })).toBe('data:image/png;base64,aGVsbG8=');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('returns undefined when no media field exists', () => {
|
|
31
|
+
expect(pickMediaRawUrl(undefined)).toBeUndefined();
|
|
32
|
+
expect(pickMediaRawUrl({})).toBeUndefined();
|
|
33
|
+
});
|
|
34
|
+
});
|
package/client/mediaSrc.ts
CHANGED
|
@@ -52,5 +52,20 @@ export function pickMediaRawUrl(data: Record<string, unknown> | undefined): stri
|
|
|
52
52
|
if (typeof value === 'string' && value.trim()) return value.trim()
|
|
53
53
|
}
|
|
54
54
|
const v = data.url ?? data.file ?? data.src ?? data.href
|
|
55
|
-
|
|
55
|
+
if (typeof v === 'string' && v.trim()) return v.trim()
|
|
56
|
+
// agent 出站媒体段(media-publisher)直接携带纯 base64,无 url/file;
|
|
57
|
+
// 有 mime 时拼成 data: URL 透传,否则走 base64:// 让 resolveMediaSrc 按 kind 补默认 MIME
|
|
58
|
+
const b64 = data.base64
|
|
59
|
+
if (typeof b64 === 'string' && b64.trim()) {
|
|
60
|
+
const payload = b64.trim()
|
|
61
|
+
if (payload.startsWith('data:') || payload.startsWith('base64://')
|
|
62
|
+
|| payload.startsWith('http://') || payload.startsWith('https://')) {
|
|
63
|
+
return payload
|
|
64
|
+
}
|
|
65
|
+
const mime = typeof data.mime === 'string' && data.mime.trim() ? data.mime.trim() : ''
|
|
66
|
+
return mime
|
|
67
|
+
? `data:${mime};base64,${payload.replace(/\s/g, '')}`
|
|
68
|
+
: `base64://${payload}`
|
|
69
|
+
}
|
|
70
|
+
return undefined
|
|
56
71
|
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { parseConsoleInboxEvent } from "@zhin.js/console-protocol";
|
|
2
|
+
|
|
1
3
|
const DB_NAME = "zhin-console";
|
|
2
4
|
const DB_VERSION = 2;
|
|
3
5
|
const STORE_INBOX = "inbox";
|
|
@@ -63,20 +65,15 @@ export async function idbListInbox(
|
|
|
63
65
|
}
|
|
64
66
|
|
|
65
67
|
export async function applyConsoleEvent(event: { type: string; data?: unknown }): Promise<void> {
|
|
66
|
-
const
|
|
67
|
-
if (
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
kind: t === "message.receive" ? "message" : t === "request.receive" ? "request" : "notice",
|
|
78
|
-
payload: data,
|
|
79
|
-
updatedAt: Date.now(),
|
|
80
|
-
});
|
|
81
|
-
}
|
|
68
|
+
const parsed = parseConsoleInboxEvent(event);
|
|
69
|
+
if (!parsed) return;
|
|
70
|
+
const updatedAt = Date.now();
|
|
71
|
+
await idbPutInbox({
|
|
72
|
+
id: `${parsed.adapter}:${parsed.endpointId}:${parsed.type}:${updatedAt}`,
|
|
73
|
+
adapter: parsed.adapter,
|
|
74
|
+
endpoint_id: parsed.endpointId,
|
|
75
|
+
kind: parsed.kind,
|
|
76
|
+
payload: parsed.payload,
|
|
77
|
+
updatedAt,
|
|
78
|
+
});
|
|
82
79
|
}
|
|
@@ -1,7 +1,25 @@
|
|
|
1
|
-
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
2
2
|
import { getWebSocketManager } from "./instance";
|
|
3
3
|
import type { UseWebSocketOptions } from "./types";
|
|
4
4
|
|
|
5
|
+
/**
|
|
6
|
+
* 每次连接会话只对同一 key 自动加载一次:失败后不再零退避死循环
|
|
7
|
+
* (旧行为:加载失败 → 状态保持空 → effect 反复立即重试,打爆 /api/console/request)。
|
|
8
|
+
* 断连后重置;key 变化(如 pluginName 切换)视为新目标重新尝试;手动重试走各 hook 返回的 load 函数。
|
|
9
|
+
*/
|
|
10
|
+
function useAutoLoadOnce(connected: boolean, key: unknown, ready: boolean, fire: () => void) {
|
|
11
|
+
const attemptedRef = useRef<unknown>(undefined);
|
|
12
|
+
useEffect(() => {
|
|
13
|
+
if (!connected) {
|
|
14
|
+
attemptedRef.current = undefined;
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
if (ready || attemptedRef.current === key) return;
|
|
18
|
+
attemptedRef.current = key;
|
|
19
|
+
fire();
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
5
23
|
export function useWebSocket(options: UseWebSocketOptions = {}) {
|
|
6
24
|
const { autoConnect = true } = options;
|
|
7
25
|
const wsManager = getWebSocketManager();
|
|
@@ -78,13 +96,12 @@ export function useConfig(pluginName: string, options?: { autoLoad?: boolean; au
|
|
|
78
96
|
}
|
|
79
97
|
}, [pluginName, wsManager]);
|
|
80
98
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
}, [connected, autoLoadSchema, schema, getSchema]);
|
|
99
|
+
useAutoLoadOnce(connected, `config:${pluginName}`, !autoLoad || config != null || loading, () => {
|
|
100
|
+
getConfig().catch(() => {});
|
|
101
|
+
});
|
|
102
|
+
useAutoLoadOnce(connected, `schema:${pluginName}`, !autoLoadSchema || schema != null, () => {
|
|
103
|
+
getSchema().catch(() => {});
|
|
104
|
+
});
|
|
88
105
|
|
|
89
106
|
return useMemo(
|
|
90
107
|
() => ({ config, schema, loading, error, connected, getConfig, setConfig, getSchema }),
|
|
@@ -136,9 +153,9 @@ export function useConfigYaml() {
|
|
|
136
153
|
[wsManager],
|
|
137
154
|
);
|
|
138
155
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
}
|
|
156
|
+
useAutoLoadOnce(connected, "config-yaml", !!yaml || loading, () => {
|
|
157
|
+
load().catch(() => {});
|
|
158
|
+
});
|
|
142
159
|
|
|
143
160
|
return useMemo(() => ({ yaml, pluginKeys, loading, error, load, save }), [yaml, pluginKeys, loading, error, load, save]);
|
|
144
161
|
}
|
|
@@ -173,9 +190,9 @@ export function useFiles() {
|
|
|
173
190
|
[wsManager],
|
|
174
191
|
);
|
|
175
192
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
}
|
|
193
|
+
useAutoLoadOnce(connected, "file-tree", tree.length > 0 || loading, () => {
|
|
194
|
+
loadTree().catch(() => {});
|
|
195
|
+
});
|
|
179
196
|
|
|
180
197
|
return useMemo(() => ({ tree, loading, error, loadTree, readFile, saveFile }), [tree, loading, error, loadTree, readFile, saveFile]);
|
|
181
198
|
}
|
|
@@ -217,9 +234,9 @@ export function useEnvFiles() {
|
|
|
217
234
|
[wsManager],
|
|
218
235
|
);
|
|
219
236
|
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
}
|
|
237
|
+
useAutoLoadOnce(connected, "env-files", files.length > 0 || loading, () => {
|
|
238
|
+
listFiles().catch(() => {});
|
|
239
|
+
});
|
|
223
240
|
|
|
224
241
|
return useMemo(
|
|
225
242
|
() => ({ files, loading, error, listFiles, getFile, saveFile }),
|
|
@@ -275,12 +292,10 @@ export function useDatabase() {
|
|
|
275
292
|
const kvDelete = useCallback(async (table: string, key: string) => wsManager.kvDelete(table, key), [wsManager]);
|
|
276
293
|
const kvEntries = useCallback(async (table: string) => wsManager.kvGetEntries(table), [wsManager]);
|
|
277
294
|
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
}
|
|
283
|
-
}, [connected, info, loading, loadInfo, loadTables]);
|
|
295
|
+
useAutoLoadOnce(connected, "db-info", info != null || loading, () => {
|
|
296
|
+
loadInfo().catch(() => {});
|
|
297
|
+
loadTables().catch(() => {});
|
|
298
|
+
});
|
|
284
299
|
|
|
285
300
|
return useMemo(
|
|
286
301
|
() => ({ info, tables, loading, error, loadInfo, loadTables, dropTable, select, insert, update, remove, kvGet, kvSet, kvDelete, kvEntries }),
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
const mockApplyConsoleEvent = vi.fn(async () => undefined);
|
|
4
|
+
|
|
5
|
+
vi.mock("../persistence/idb-store.js", () => ({
|
|
6
|
+
applyConsoleEvent: (...args: unknown[]) => mockApplyConsoleEvent(...args),
|
|
7
|
+
}));
|
|
8
|
+
|
|
9
|
+
import { WebSocketManager } from "./manager.js";
|
|
10
|
+
import { ConnectionState, type WebSocketMessage } from "./types.js";
|
|
11
|
+
|
|
12
|
+
function mockStorage(map: Record<string, string> = {}): Storage {
|
|
13
|
+
return {
|
|
14
|
+
getItem: (key: string) => map[key] ?? null,
|
|
15
|
+
setItem: (key: string, value: string) => {
|
|
16
|
+
map[key] = value;
|
|
17
|
+
},
|
|
18
|
+
removeItem: (key: string) => {
|
|
19
|
+
delete map[key];
|
|
20
|
+
},
|
|
21
|
+
clear: () => {
|
|
22
|
+
for (const key of Object.keys(map)) delete map[key];
|
|
23
|
+
},
|
|
24
|
+
key: () => null,
|
|
25
|
+
length: 0,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function installBrowserGlobals(storage: Storage = mockStorage()) {
|
|
30
|
+
const win = {
|
|
31
|
+
location: { origin: "http://localhost:5173", protocol: "http:", host: "localhost:5173" },
|
|
32
|
+
__ZHIN_API_TOKEN: undefined as string | undefined,
|
|
33
|
+
dispatchEvent: vi.fn(),
|
|
34
|
+
};
|
|
35
|
+
vi.stubGlobal("window", win);
|
|
36
|
+
vi.stubGlobal("localStorage", storage);
|
|
37
|
+
vi.stubGlobal("sessionStorage", mockStorage());
|
|
38
|
+
return { win, storage };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function sseResponse(status = 200, body?: ReadableStream<Uint8Array> | null) {
|
|
42
|
+
return {
|
|
43
|
+
ok: status >= 200 && status < 300,
|
|
44
|
+
status,
|
|
45
|
+
body: body === undefined
|
|
46
|
+
? new ReadableStream<Uint8Array>({
|
|
47
|
+
start(controller) {
|
|
48
|
+
// Keep the stream open until abort; tests control lifecycle via disconnect().
|
|
49
|
+
void controller;
|
|
50
|
+
},
|
|
51
|
+
})
|
|
52
|
+
: body,
|
|
53
|
+
json: async () => ({}),
|
|
54
|
+
} as Response;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
describe("WebSocketManager REST/SSE transport", () => {
|
|
58
|
+
beforeEach(() => {
|
|
59
|
+
vi.useFakeTimers();
|
|
60
|
+
mockApplyConsoleEvent.mockClear();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
afterEach(() => {
|
|
64
|
+
vi.useRealTimers();
|
|
65
|
+
vi.unstubAllGlobals();
|
|
66
|
+
vi.restoreAllMocks();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("connects SSE without a token (local Host with empty TokenRegistry)", async () => {
|
|
70
|
+
installBrowserGlobals();
|
|
71
|
+
const fetchMock = vi.fn(async () => sseResponse(200));
|
|
72
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
73
|
+
|
|
74
|
+
const manager = new WebSocketManager({ reconnectInterval: 100, maxReconnectAttempts: 3 });
|
|
75
|
+
const states: boolean[] = [];
|
|
76
|
+
manager.onConnectionChange((c) => states.push(c));
|
|
77
|
+
manager.connect();
|
|
78
|
+
|
|
79
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
80
|
+
await Promise.resolve();
|
|
81
|
+
await Promise.resolve();
|
|
82
|
+
|
|
83
|
+
expect(fetchMock).toHaveBeenCalled();
|
|
84
|
+
const init = fetchMock.mock.calls[0]![1] as RequestInit;
|
|
85
|
+
const headers = init.headers as Record<string, string>;
|
|
86
|
+
expect(headers.Accept).toBe("text/event-stream");
|
|
87
|
+
expect(headers.Authorization).toBeUndefined();
|
|
88
|
+
expect(manager.getState()).toBe(ConnectionState.CONNECTED);
|
|
89
|
+
expect(manager.isConnected()).toBe(true);
|
|
90
|
+
manager.disconnect();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("sends Authorization when a token is stored", async () => {
|
|
94
|
+
installBrowserGlobals(mockStorage({ zhin_api_token: "secret" }));
|
|
95
|
+
const fetchMock = vi.fn(async () => sseResponse(200));
|
|
96
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
97
|
+
|
|
98
|
+
const manager = new WebSocketManager();
|
|
99
|
+
manager.connect();
|
|
100
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
101
|
+
await Promise.resolve();
|
|
102
|
+
await Promise.resolve();
|
|
103
|
+
|
|
104
|
+
const init = fetchMock.mock.calls[0]![1] as RequestInit;
|
|
105
|
+
expect((init.headers as Record<string, string>).Authorization).toBe("Bearer secret");
|
|
106
|
+
manager.disconnect();
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("reconnects after a transient SSE failure (no ERROR deadlock)", async () => {
|
|
110
|
+
installBrowserGlobals();
|
|
111
|
+
let calls = 0;
|
|
112
|
+
const fetchMock = vi.fn(async () => {
|
|
113
|
+
calls += 1;
|
|
114
|
+
if (calls === 1) return sseResponse(503, null);
|
|
115
|
+
return sseResponse(200);
|
|
116
|
+
});
|
|
117
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
118
|
+
|
|
119
|
+
const manager = new WebSocketManager({
|
|
120
|
+
reconnectInterval: 100,
|
|
121
|
+
maxReconnectAttempts: 5,
|
|
122
|
+
});
|
|
123
|
+
manager.connect();
|
|
124
|
+
|
|
125
|
+
// First attempt fails → RECONNECTING
|
|
126
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
127
|
+
await Promise.resolve();
|
|
128
|
+
await Promise.resolve();
|
|
129
|
+
expect(manager.getState()).toBe(ConnectionState.RECONNECTING);
|
|
130
|
+
expect(calls).toBe(1);
|
|
131
|
+
|
|
132
|
+
// scheduleReconnect delay = 100 * attempt(1) = 100ms
|
|
133
|
+
await vi.advanceTimersByTimeAsync(100);
|
|
134
|
+
await Promise.resolve();
|
|
135
|
+
await Promise.resolve();
|
|
136
|
+
|
|
137
|
+
expect(calls).toBeGreaterThanOrEqual(2);
|
|
138
|
+
expect(manager.getState()).toBe(ConnectionState.CONNECTED);
|
|
139
|
+
manager.disconnect();
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("sendRequest posts without Authorization when no token is stored", async () => {
|
|
143
|
+
installBrowserGlobals();
|
|
144
|
+
const fetchMock = vi.fn(async () => ({
|
|
145
|
+
ok: true,
|
|
146
|
+
status: 200,
|
|
147
|
+
json: async () => ({ success: true, data: { yaml: "http:\n port: 1\n" } }),
|
|
148
|
+
} as Response));
|
|
149
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
150
|
+
|
|
151
|
+
const manager = new WebSocketManager();
|
|
152
|
+
const data = await manager.getConfigYaml();
|
|
153
|
+
expect(data.yaml).toContain("port");
|
|
154
|
+
const init = fetchMock.mock.calls[0]![1] as RequestInit;
|
|
155
|
+
expect((init.headers as Record<string, string>).Authorization).toBeUndefined();
|
|
156
|
+
expect(init.method).toBe("POST");
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("sendRequest includes Bearer token when present", async () => {
|
|
160
|
+
installBrowserGlobals(mockStorage({ zhin_api_token: "t" }));
|
|
161
|
+
const fetchMock = vi.fn(async () => ({
|
|
162
|
+
ok: true,
|
|
163
|
+
status: 200,
|
|
164
|
+
json: async () => ({ success: true, data: { ok: true } }),
|
|
165
|
+
} as Response));
|
|
166
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
167
|
+
|
|
168
|
+
const manager = new WebSocketManager();
|
|
169
|
+
await manager.setConfig("sandbox", { endpoints: [] });
|
|
170
|
+
const init = fetchMock.mock.calls[0]![1] as RequestInit;
|
|
171
|
+
expect((init.headers as Record<string, string>).Authorization).toBe("Bearer t");
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("normalizes legacy endpoint push names and payload aliases before callbacks", async () => {
|
|
175
|
+
installBrowserGlobals();
|
|
176
|
+
const received: WebSocketMessage[] = [];
|
|
177
|
+
const manager = new WebSocketManager({}, {
|
|
178
|
+
onMessage: (message) => received.push(message),
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
(manager as unknown as { handleMessage(event: MessageEvent): void }).handleMessage({
|
|
182
|
+
data: '{"type":"endpoint:message","data":{"$adapter":"sandbox","endpoint":"bot"}}',
|
|
183
|
+
} as MessageEvent);
|
|
184
|
+
await Promise.resolve();
|
|
185
|
+
|
|
186
|
+
expect(received).toEqual([expect.objectContaining({
|
|
187
|
+
type: "message.receive",
|
|
188
|
+
data: expect.objectContaining({ adapter: "sandbox", endpointId: "bot" }),
|
|
189
|
+
})]);
|
|
190
|
+
expect(mockApplyConsoleEvent).toHaveBeenCalledWith(expect.objectContaining({
|
|
191
|
+
type: "message.receive",
|
|
192
|
+
}));
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("stops reconnecting after maxReconnectAttempts", async () => {
|
|
196
|
+
installBrowserGlobals();
|
|
197
|
+
const fetchMock = vi.fn(async () => sseResponse(500, null));
|
|
198
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
199
|
+
|
|
200
|
+
const manager = new WebSocketManager({
|
|
201
|
+
reconnectInterval: 10,
|
|
202
|
+
maxReconnectAttempts: 2,
|
|
203
|
+
});
|
|
204
|
+
manager.connect();
|
|
205
|
+
|
|
206
|
+
for (let i = 0; i < 6; i++) {
|
|
207
|
+
await vi.advanceTimersByTimeAsync(50);
|
|
208
|
+
await Promise.resolve();
|
|
209
|
+
await Promise.resolve();
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
expect(manager.getState()).toBe(ConnectionState.ERROR);
|
|
213
|
+
expect(fetchMock.mock.calls.length).toBeLessThanOrEqual(3);
|
|
214
|
+
manager.disconnect();
|
|
215
|
+
});
|
|
216
|
+
});
|