@zhin.js/client 1.0.17 → 1.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/client/app.ts +228 -0
- package/client/bootstrap/loadConsoleEntries.test.ts +32 -0
- package/client/bootstrap/loadConsoleEntries.ts +149 -0
- package/client/console-utils/cn.ts +6 -0
- package/client/console-utils/remoteApi.ts +48 -0
- package/client/index.ts +61 -14
- package/client/persistence/idb-store.ts +76 -0
- package/client/runtime/index.ts +12 -0
- package/client/store/createRegistryStore.ts +46 -0
- package/client/websocket/hooks.ts +218 -510
- package/client/websocket/index.ts +6 -43
- package/client/websocket/instance.ts +10 -32
- package/client/websocket/manager.ts +301 -385
- package/client/websocket/remote-settings.ts +25 -0
- package/client/websocket/types.ts +58 -222
- package/dist/app.d.ts +95 -0
- package/dist/app.d.ts.map +1 -0
- package/dist/app.js +147 -0
- package/dist/app.js.map +1 -0
- package/dist/bootstrap/loadConsoleEntries.d.ts +26 -0
- package/dist/bootstrap/loadConsoleEntries.d.ts.map +1 -0
- package/dist/bootstrap/loadConsoleEntries.js +111 -0
- package/dist/bootstrap/loadConsoleEntries.js.map +1 -0
- package/dist/bootstrap/loadConsoleEntries.test.d.ts +2 -0
- package/dist/bootstrap/loadConsoleEntries.test.d.ts.map +1 -0
- package/dist/bootstrap/loadConsoleEntries.test.js +27 -0
- package/dist/bootstrap/loadConsoleEntries.test.js.map +1 -0
- package/dist/console-utils/cn.d.ts +3 -0
- package/dist/console-utils/cn.d.ts.map +1 -0
- package/dist/console-utils/cn.js +6 -0
- package/dist/console-utils/cn.js.map +1 -0
- package/dist/console-utils/remoteApi.d.ts +7 -0
- package/dist/console-utils/remoteApi.d.ts.map +1 -0
- package/dist/console-utils/remoteApi.js +41 -0
- package/dist/console-utils/remoteApi.js.map +1 -0
- package/dist/index.d.ts +11 -7
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +13 -14
- package/dist/index.js.map +1 -1
- package/dist/persistence/idb-store.d.ts +15 -0
- package/dist/persistence/idb-store.d.ts.map +1 -0
- package/dist/persistence/idb-store.js +61 -0
- package/dist/persistence/idb-store.js.map +1 -0
- package/dist/runtime/index.d.ts +6 -0
- package/dist/runtime/index.d.ts.map +1 -0
- package/dist/runtime/index.js +10 -0
- package/dist/runtime/index.js.map +1 -0
- package/dist/store/createRegistryStore.d.ts +9 -0
- package/dist/store/createRegistryStore.d.ts.map +1 -0
- package/dist/store/createRegistryStore.js +38 -0
- package/dist/store/createRegistryStore.js.map +1 -0
- package/dist/websocket/hooks.d.ts +35 -58
- package/dist/websocket/hooks.d.ts.map +1 -1
- package/dist/websocket/hooks.js +134 -410
- package/dist/websocket/hooks.js.map +1 -1
- package/dist/websocket/index.d.ts +5 -10
- package/dist/websocket/index.d.ts.map +1 -1
- package/dist/websocket/index.js +5 -25
- package/dist/websocket/index.js.map +1 -1
- package/dist/websocket/instance.d.ts +1 -14
- package/dist/websocket/instance.d.ts.map +1 -1
- package/dist/websocket/instance.js +2 -20
- package/dist/websocket/instance.js.map +1 -1
- package/dist/websocket/manager.d.ts +28 -93
- package/dist/websocket/manager.d.ts.map +1 -1
- package/dist/websocket/manager.js +213 -221
- package/dist/websocket/manager.js.map +1 -1
- package/dist/websocket/remote-settings.d.ts +5 -0
- package/dist/websocket/remote-settings.d.ts.map +1 -0
- package/dist/websocket/remote-settings.js +23 -0
- package/dist/websocket/remote-settings.js.map +1 -0
- package/dist/websocket/types.d.ts +35 -122
- package/dist/websocket/types.d.ts.map +1 -1
- package/dist/websocket/types.js +4 -13
- package/dist/websocket/types.js.map +1 -1
- package/package.json +13 -11
- package/client/router/index.tsx +0 -54
- package/client/store/index.ts +0 -132
- package/client/store/reducers/config.ts +0 -135
- package/client/store/reducers/index.ts +0 -19
- package/client/store/reducers/route.ts +0 -126
- package/client/store/reducers/script.ts +0 -109
- package/client/store/reducers/ui.ts +0 -31
- package/client/websocket/messageHandler.ts +0 -208
- package/dist/router/index.d.ts +0 -25
- package/dist/router/index.d.ts.map +0 -1
- package/dist/router/index.js +0 -48
- package/dist/router/index.js.map +0 -1
- package/dist/store/index.d.ts +0 -19
- package/dist/store/index.d.ts.map +0 -1
- package/dist/store/index.js +0 -67
- package/dist/store/index.js.map +0 -1
- package/dist/store/reducers/config.d.ts +0 -54
- package/dist/store/reducers/config.d.ts.map +0 -1
- package/dist/store/reducers/config.js +0 -78
- package/dist/store/reducers/config.js.map +0 -1
- package/dist/store/reducers/index.d.ts +0 -13
- package/dist/store/reducers/index.d.ts.map +0 -1
- package/dist/store/reducers/index.js +0 -11
- package/dist/store/reducers/index.js.map +0 -1
- package/dist/store/reducers/route.d.ts +0 -30
- package/dist/store/reducers/route.d.ts.map +0 -1
- package/dist/store/reducers/route.js +0 -85
- package/dist/store/reducers/route.js.map +0 -1
- package/dist/store/reducers/script.d.ts +0 -13
- package/dist/store/reducers/script.d.ts.map +0 -1
- package/dist/store/reducers/script.js +0 -78
- package/dist/store/reducers/script.js.map +0 -1
- package/dist/store/reducers/ui.d.ts +0 -8
- package/dist/store/reducers/ui.d.ts.map +0 -1
- package/dist/store/reducers/ui.js +0 -23
- package/dist/store/reducers/ui.js.map +0 -1
- package/dist/websocket/messageHandler.d.ts +0 -58
- package/dist/websocket/messageHandler.d.ts.map +0 -1
- package/dist/websocket/messageHandler.js +0 -174
- package/dist/websocket/messageHandler.js.map +0 -1
package/client/app.ts
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
|
|
3
|
+
export type AddRouteInput = {
|
|
4
|
+
path: string;
|
|
5
|
+
name: string;
|
|
6
|
+
element: React.ReactNode;
|
|
7
|
+
parent?: string | null;
|
|
8
|
+
icon?: React.ReactNode | string;
|
|
9
|
+
requiredPermissions?: string[];
|
|
10
|
+
requiredRoles?: string[];
|
|
11
|
+
meta?: {
|
|
12
|
+
hideInMenu?: boolean;
|
|
13
|
+
order?: number;
|
|
14
|
+
group?: string;
|
|
15
|
+
fullWidth?: boolean;
|
|
16
|
+
};
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export type AddToolInput = {
|
|
20
|
+
id?: string;
|
|
21
|
+
name: string;
|
|
22
|
+
icon?: React.ReactNode;
|
|
23
|
+
parent?: string | null;
|
|
24
|
+
path?: string;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export type ConsoleRouteRecord = {
|
|
28
|
+
path: string;
|
|
29
|
+
name: string;
|
|
30
|
+
element: React.ReactNode;
|
|
31
|
+
parent: string | null;
|
|
32
|
+
icon?: React.ReactNode | string;
|
|
33
|
+
requiredPermissions?: string[];
|
|
34
|
+
requiredRoles?: string[];
|
|
35
|
+
meta?: {
|
|
36
|
+
hideInMenu?: boolean;
|
|
37
|
+
order?: number;
|
|
38
|
+
group?: string;
|
|
39
|
+
fullWidth?: boolean;
|
|
40
|
+
};
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export type InternalToolRecord = {
|
|
44
|
+
id: string;
|
|
45
|
+
name: string;
|
|
46
|
+
icon?: React.ReactNode;
|
|
47
|
+
parent: string | null;
|
|
48
|
+
path?: string;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export type RouteTreeNode = ConsoleRouteRecord & { children: RouteTreeNode[] };
|
|
52
|
+
export type ToolTreeNode = InternalToolRecord & { children: ToolTreeNode[] };
|
|
53
|
+
|
|
54
|
+
type SidebarRenderer = (options: { routes: RouteTreeNode[] }) => React.ReactNode;
|
|
55
|
+
type ToolbarRenderer = (options: { tools: ToolTreeNode[] }) => React.ReactNode;
|
|
56
|
+
export type ConsoleRouteRenderer = (route: ConsoleRouteRecord) => React.ReactNode;
|
|
57
|
+
|
|
58
|
+
const listeners = new Set<() => void>();
|
|
59
|
+
let version = 0;
|
|
60
|
+
|
|
61
|
+
function bump() {
|
|
62
|
+
version++;
|
|
63
|
+
for (const l of listeners) l();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function subscribe(listener: () => void) {
|
|
67
|
+
listeners.add(listener);
|
|
68
|
+
return () => listeners.delete(listener);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function getVersion() {
|
|
72
|
+
return version;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
let routes: ConsoleRouteRecord[] = [];
|
|
76
|
+
let tools: InternalToolRecord[] = [];
|
|
77
|
+
let sidebarRenderer: SidebarRenderer | null = null;
|
|
78
|
+
let toolbarRenderer: ToolbarRenderer | null = null;
|
|
79
|
+
let routeRenderer: ConsoleRouteRenderer | null = null;
|
|
80
|
+
|
|
81
|
+
function slugId(name: string) {
|
|
82
|
+
return (
|
|
83
|
+
name
|
|
84
|
+
.toLowerCase()
|
|
85
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
86
|
+
.replace(/^-|-$/g, "") || "tool"
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function buildRouteTree(flatRoutes: ConsoleRouteRecord[]): RouteTreeNode[] {
|
|
91
|
+
const nodes: RouteTreeNode[] = flatRoutes.map((r) => ({ ...r, children: [] }));
|
|
92
|
+
const roots: RouteTreeNode[] = [];
|
|
93
|
+
|
|
94
|
+
for (const node of nodes) {
|
|
95
|
+
if (!node.parent) {
|
|
96
|
+
roots.push(node);
|
|
97
|
+
} else {
|
|
98
|
+
const parent = nodes.find((n) => n.path === node.parent);
|
|
99
|
+
if (parent) {
|
|
100
|
+
parent.children.push(node);
|
|
101
|
+
} else {
|
|
102
|
+
roots.push(node);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const sortFn = (a: RouteTreeNode, b: RouteTreeNode) =>
|
|
108
|
+
(a.meta?.order ?? 999) - (b.meta?.order ?? 999);
|
|
109
|
+
roots.sort(sortFn);
|
|
110
|
+
for (const n of nodes) n.children.sort(sortFn);
|
|
111
|
+
|
|
112
|
+
return roots;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function buildToolTree(flatTools: InternalToolRecord[]): ToolTreeNode[] {
|
|
116
|
+
const nodes: ToolTreeNode[] = flatTools.map((t) => ({ ...t, children: [] }));
|
|
117
|
+
const roots: ToolTreeNode[] = [];
|
|
118
|
+
|
|
119
|
+
for (const node of nodes) {
|
|
120
|
+
if (!node.parent) {
|
|
121
|
+
roots.push(node);
|
|
122
|
+
} else {
|
|
123
|
+
const parent = nodes.find((n) => n.id === node.parent);
|
|
124
|
+
if (parent) {
|
|
125
|
+
parent.children.push(node);
|
|
126
|
+
} else {
|
|
127
|
+
roots.push(node);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return roots;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function renderRouteElement(route: ConsoleRouteRecord): React.ReactNode {
|
|
135
|
+
if (routeRenderer) return routeRenderer(route);
|
|
136
|
+
return route.element;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function createConsoleApp() {
|
|
140
|
+
return {
|
|
141
|
+
subscribe,
|
|
142
|
+
getVersion,
|
|
143
|
+
|
|
144
|
+
defineSidebar(render: SidebarRenderer) {
|
|
145
|
+
sidebarRenderer = render;
|
|
146
|
+
bump();
|
|
147
|
+
},
|
|
148
|
+
|
|
149
|
+
defineToolbar(render: ToolbarRenderer) {
|
|
150
|
+
toolbarRenderer = render;
|
|
151
|
+
bump();
|
|
152
|
+
},
|
|
153
|
+
|
|
154
|
+
defineRouter(render: ConsoleRouteRenderer) {
|
|
155
|
+
routeRenderer = render;
|
|
156
|
+
bump();
|
|
157
|
+
},
|
|
158
|
+
|
|
159
|
+
addRoute(input: AddRouteInput) {
|
|
160
|
+
const next = routes.filter((r) => r.path !== input.path);
|
|
161
|
+
next.push({
|
|
162
|
+
path: input.path,
|
|
163
|
+
name: input.name,
|
|
164
|
+
element: input.element,
|
|
165
|
+
parent: input.parent ?? null,
|
|
166
|
+
icon: input.icon,
|
|
167
|
+
requiredPermissions: input.requiredPermissions,
|
|
168
|
+
requiredRoles: input.requiredRoles,
|
|
169
|
+
meta: input.meta,
|
|
170
|
+
});
|
|
171
|
+
routes = next;
|
|
172
|
+
bump();
|
|
173
|
+
},
|
|
174
|
+
|
|
175
|
+
removeRoute(path: string) {
|
|
176
|
+
routes = routes.filter((r) => r.path !== path);
|
|
177
|
+
bump();
|
|
178
|
+
},
|
|
179
|
+
|
|
180
|
+
addTool(input: AddToolInput): string {
|
|
181
|
+
const id =
|
|
182
|
+
input.id ?? `${slugId(input.name)}-${Math.random().toString(36).slice(2, 8)}`;
|
|
183
|
+
if (tools.some((t) => t.id === id)) {
|
|
184
|
+
throw new Error(`[zhin-console] addTool: id already exists: ${id}`);
|
|
185
|
+
}
|
|
186
|
+
tools = [
|
|
187
|
+
...tools,
|
|
188
|
+
{
|
|
189
|
+
id,
|
|
190
|
+
name: input.name,
|
|
191
|
+
icon: input.icon,
|
|
192
|
+
parent: input.parent ?? null,
|
|
193
|
+
path: input.path,
|
|
194
|
+
},
|
|
195
|
+
];
|
|
196
|
+
bump();
|
|
197
|
+
return id;
|
|
198
|
+
},
|
|
199
|
+
|
|
200
|
+
getRouteTree(): RouteTreeNode[] {
|
|
201
|
+
return buildRouteTree(routes);
|
|
202
|
+
},
|
|
203
|
+
|
|
204
|
+
getToolTree(): ToolTreeNode[] {
|
|
205
|
+
return buildToolTree(tools);
|
|
206
|
+
},
|
|
207
|
+
|
|
208
|
+
_getRoutes(): readonly ConsoleRouteRecord[] {
|
|
209
|
+
return routes;
|
|
210
|
+
},
|
|
211
|
+
|
|
212
|
+
_getSidebarRenderer(): SidebarRenderer | null {
|
|
213
|
+
return sidebarRenderer;
|
|
214
|
+
},
|
|
215
|
+
|
|
216
|
+
_getToolbarRenderer(): ToolbarRenderer | null {
|
|
217
|
+
return toolbarRenderer;
|
|
218
|
+
},
|
|
219
|
+
|
|
220
|
+
_renderRouteElement(route: ConsoleRouteRecord): React.ReactNode {
|
|
221
|
+
return renderRouteElement(route);
|
|
222
|
+
},
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export type ConsoleApp = ReturnType<typeof createConsoleApp>;
|
|
227
|
+
|
|
228
|
+
export const app = createConsoleApp();
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { createPluginRegisterHostApi, getRegisterFn } from "./loadConsoleEntries.js";
|
|
3
|
+
|
|
4
|
+
describe("console entries bootstrap", () => {
|
|
5
|
+
it("resolves named register export", () => {
|
|
6
|
+
const register = () => {};
|
|
7
|
+
expect(getRegisterFn({ register })).toBe(register);
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it("resolves default register export", () => {
|
|
11
|
+
const register = () => {};
|
|
12
|
+
expect(getRegisterFn({ default: { register } })).toBe(register);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("returns null when no register export exists", () => {
|
|
16
|
+
expect(getRegisterFn({ default: {} })).toBeNull();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("creates a host API with addPage aliased to addRoute", () => {
|
|
20
|
+
const React = {} as any;
|
|
21
|
+
const addRoute = vi.fn();
|
|
22
|
+
const addTool = vi.fn(() => "tool-id");
|
|
23
|
+
const api = createPluginRegisterHostApi({ React, addRoute, addTool });
|
|
24
|
+
|
|
25
|
+
api.addPage({ path: "/x", name: "x", element: "page" as any });
|
|
26
|
+
api.addRoute({ path: "/y", name: "y", element: "route" as any });
|
|
27
|
+
expect(api.React).toBe(React);
|
|
28
|
+
expect(addRoute).toHaveBeenCalledTimes(2);
|
|
29
|
+
expect(api.addTool({ id: "t", name: "T" })).toBe("tool-id");
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ConsoleClientEntry,
|
|
3
|
+
ConsoleEntriesResponse,
|
|
4
|
+
ConsolePluginRegister,
|
|
5
|
+
PluginRegisterHostApi,
|
|
6
|
+
} from "@zhin.js/console-types";
|
|
7
|
+
import { DEFAULT_CONSOLE_BASE_PATH } from "@zhin.js/console-types";
|
|
8
|
+
|
|
9
|
+
export type FetchConsoleEntriesOptions = {
|
|
10
|
+
entriesUrl?: string;
|
|
11
|
+
signal?: AbortSignal;
|
|
12
|
+
fetchInit?: RequestInit | (() => RequestInit);
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export type LoadConsoleEntriesOptions = FetchConsoleEntriesOptions & {
|
|
16
|
+
/** Origin for `/@dev` / `/@assets` module URLs (Remote Console: Host API base). */
|
|
17
|
+
assetOrigin?: string;
|
|
18
|
+
hostApi: PluginRegisterHostApi;
|
|
19
|
+
beforeLoad?: () => void;
|
|
20
|
+
onEmpty?: () => void;
|
|
21
|
+
onFetchError?: (status: number) => void;
|
|
22
|
+
onEntryError?: (entry: ConsoleClientEntry, error: unknown) => void;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export type CreatePluginRegisterHostApiOptions = {
|
|
26
|
+
React: PluginRegisterHostApi["React"];
|
|
27
|
+
addRoute: PluginRegisterHostApi["addRoute"];
|
|
28
|
+
addTool: PluginRegisterHostApi["addTool"];
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export function createPluginRegisterHostApi(
|
|
32
|
+
options: CreatePluginRegisterHostApiOptions,
|
|
33
|
+
): PluginRegisterHostApi {
|
|
34
|
+
const addRoute = options.addRoute;
|
|
35
|
+
return {
|
|
36
|
+
React: options.React,
|
|
37
|
+
addRoute,
|
|
38
|
+
addPage: addRoute,
|
|
39
|
+
addTool: options.addTool,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const CONSOLE_API_BASE_STORAGE_KEY = "zhin_api_base";
|
|
44
|
+
|
|
45
|
+
function resolveStoredApiBase(): string {
|
|
46
|
+
if (typeof window === "undefined") return "";
|
|
47
|
+
const stored = localStorage.getItem(CONSOLE_API_BASE_STORAGE_KEY)?.trim();
|
|
48
|
+
return stored ? stored.replace(/\/$/, "") : "";
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function defaultEntriesUrl(): string {
|
|
52
|
+
const apiBase = resolveStoredApiBase();
|
|
53
|
+
if (apiBase) return `${apiBase}/entries`;
|
|
54
|
+
const root = DEFAULT_CONSOLE_BASE_PATH.replace(/\/$/, "");
|
|
55
|
+
return `${root ? `${root}/entries` : "/entries"}`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function resolveRequestUrl(pathOrUrl: string): string {
|
|
59
|
+
if (pathOrUrl.startsWith("http://") || pathOrUrl.startsWith("https://")) return pathOrUrl;
|
|
60
|
+
if (typeof window === "undefined") return pathOrUrl;
|
|
61
|
+
const apiBase = resolveStoredApiBase();
|
|
62
|
+
const origin = apiBase || window.location.origin;
|
|
63
|
+
return new URL(pathOrUrl, `${origin}/`).href;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function resolvePluginImportUrl(resolvedModule: string, assetOrigin?: string): string {
|
|
67
|
+
if (resolvedModule.startsWith("http://") || resolvedModule.startsWith("https://")) return resolvedModule;
|
|
68
|
+
if (typeof window === "undefined") return resolvedModule;
|
|
69
|
+
const origin = assetOrigin?.replace(/\/$/, "") || window.location.origin;
|
|
70
|
+
return new URL(resolvedModule, `${origin}/`).href;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function resolveFetchInit(init?: RequestInit | (() => RequestInit)): RequestInit {
|
|
74
|
+
return typeof init === "function" ? init() : (init ?? {});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export async function fetchConsoleEntries(
|
|
78
|
+
options?: FetchConsoleEntriesOptions,
|
|
79
|
+
): Promise<ConsoleEntriesResponse> {
|
|
80
|
+
const pathOrUrl = options?.entriesUrl ?? defaultEntriesUrl();
|
|
81
|
+
const url = resolveRequestUrl(pathOrUrl);
|
|
82
|
+
const init = resolveFetchInit(options?.fetchInit);
|
|
83
|
+
const res = await fetch(url, {
|
|
84
|
+
...init,
|
|
85
|
+
signal: options?.signal ?? init.signal,
|
|
86
|
+
} as RequestInit);
|
|
87
|
+
if (!res.ok) throw Object.assign(new Error(`load entries failed: ${res.status}`), { status: res.status });
|
|
88
|
+
return (await res.json()) as ConsoleEntriesResponse;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function getRegisterFn(mod: Record<string, unknown> | null | undefined): ConsolePluginRegister | null {
|
|
92
|
+
if (!mod) return null;
|
|
93
|
+
const r = mod["register"];
|
|
94
|
+
if (typeof r === "function") return r as ConsolePluginRegister;
|
|
95
|
+
const d = mod["default"] as Record<string, unknown> | undefined;
|
|
96
|
+
if (d && typeof d["register"] === "function") return d["register"] as ConsolePluginRegister;
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export async function registerConsolePluginsFromEntries(
|
|
101
|
+
entries: ConsoleClientEntry[],
|
|
102
|
+
hostApi: PluginRegisterHostApi,
|
|
103
|
+
onEntryError?: (entry: ConsoleClientEntry, error: unknown) => void,
|
|
104
|
+
assetOrigin?: string,
|
|
105
|
+
): Promise<void> {
|
|
106
|
+
if (!entries.length) return;
|
|
107
|
+
await Promise.all(
|
|
108
|
+
entries.map(async (entry) => {
|
|
109
|
+
try {
|
|
110
|
+
const specifier = resolvePluginImportUrl(entry.resolvedModule, assetOrigin);
|
|
111
|
+
const mod = (await import(/* @vite-ignore */ specifier)) as Record<string, unknown>;
|
|
112
|
+
const register = getRegisterFn(mod);
|
|
113
|
+
if (register) await register(hostApi);
|
|
114
|
+
else throw new Error(`entry "${entry.id}" has no register export`);
|
|
115
|
+
} catch (error) {
|
|
116
|
+
if (onEntryError) onEntryError(entry, error);
|
|
117
|
+
else throw error;
|
|
118
|
+
}
|
|
119
|
+
}),
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export async function loadConsoleEntries(options: LoadConsoleEntriesOptions): Promise<void> {
|
|
124
|
+
options.beforeLoad?.();
|
|
125
|
+
let data: ConsoleEntriesResponse;
|
|
126
|
+
try {
|
|
127
|
+
data = await fetchConsoleEntries(options);
|
|
128
|
+
} catch (error) {
|
|
129
|
+
const status = typeof (error as { status?: unknown }).status === "number"
|
|
130
|
+
? (error as { status: number }).status
|
|
131
|
+
: 0;
|
|
132
|
+
if (options.onFetchError) {
|
|
133
|
+
options.onFetchError(status);
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
throw error;
|
|
137
|
+
}
|
|
138
|
+
if (!data.entries?.length) {
|
|
139
|
+
options.onEmpty?.();
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
await registerConsolePluginsFromEntries(
|
|
143
|
+
data.entries,
|
|
144
|
+
options.hostApi,
|
|
145
|
+
options.onEntryError,
|
|
146
|
+
options.assetOrigin,
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
const TOKEN_KEY = "zhin_api_token";
|
|
2
|
+
const API_BASE_KEY = "zhin_api_base";
|
|
3
|
+
|
|
4
|
+
export function getApiBase(): string {
|
|
5
|
+
const stored = localStorage.getItem(API_BASE_KEY)?.trim();
|
|
6
|
+
if (stored) return stored.replace(/\/$/, "");
|
|
7
|
+
if (typeof window !== "undefined") return window.location.origin;
|
|
8
|
+
return "";
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function getToken(): string | null {
|
|
12
|
+
return localStorage.getItem(TOKEN_KEY);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function resolveApiUrl(path: string): string {
|
|
16
|
+
const base = getApiBase();
|
|
17
|
+
const p = path.startsWith("/") ? path : `/${path}`;
|
|
18
|
+
return `${base}${p}`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function resolveWebSocketUrl(path: string): string {
|
|
22
|
+
const httpUrl = resolveApiUrl(path);
|
|
23
|
+
const url = new URL(httpUrl);
|
|
24
|
+
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
|
|
25
|
+
return url.href;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** fetch with Bearer token; relative paths resolve against {@link getApiBase}. */
|
|
29
|
+
export async function apiFetch(
|
|
30
|
+
input: RequestInfo | URL,
|
|
31
|
+
init?: RequestInit,
|
|
32
|
+
): Promise<Response> {
|
|
33
|
+
const token = getToken();
|
|
34
|
+
const headers = new Headers(init?.headers);
|
|
35
|
+
if (token) headers.set("Authorization", `Bearer ${token}`);
|
|
36
|
+
|
|
37
|
+
const url =
|
|
38
|
+
typeof input === "string" && input.startsWith("/")
|
|
39
|
+
? resolveApiUrl(input)
|
|
40
|
+
: input;
|
|
41
|
+
|
|
42
|
+
const res = await fetch(url, { ...init, headers });
|
|
43
|
+
if (res.status === 401) {
|
|
44
|
+
localStorage.removeItem(TOKEN_KEY);
|
|
45
|
+
window.dispatchEvent(new CustomEvent("zhin:auth-required"));
|
|
46
|
+
}
|
|
47
|
+
return res;
|
|
48
|
+
}
|
package/client/index.ts
CHANGED
|
@@ -1,20 +1,67 @@
|
|
|
1
|
-
import { type ClassValue, clsx } from 'clsx'
|
|
2
|
-
import { twMerge } from 'tailwind-merge'
|
|
3
1
|
// Types
|
|
4
|
-
export * from
|
|
2
|
+
export * from "./types";
|
|
5
3
|
|
|
6
|
-
//
|
|
7
|
-
export { resolveMediaSrc, pickMediaRawUrl, type MediaKind } from
|
|
4
|
+
// Media URL resolution
|
|
5
|
+
export { resolveMediaSrc, pickMediaRawUrl, type MediaKind } from "./mediaSrc";
|
|
8
6
|
|
|
9
|
-
//
|
|
10
|
-
export
|
|
7
|
+
// Console app singleton (page-manager style)
|
|
8
|
+
export {
|
|
9
|
+
app,
|
|
10
|
+
type ConsoleApp,
|
|
11
|
+
type AddRouteInput,
|
|
12
|
+
type AddToolInput,
|
|
13
|
+
type ConsoleRouteRecord,
|
|
14
|
+
type RouteTreeNode,
|
|
15
|
+
type ToolTreeNode,
|
|
16
|
+
type ConsoleRouteRenderer,
|
|
17
|
+
} from "./app";
|
|
11
18
|
|
|
12
|
-
//
|
|
13
|
-
export * from
|
|
19
|
+
// WebSocket (business data only)
|
|
20
|
+
export * from "./websocket";
|
|
14
21
|
|
|
15
|
-
//
|
|
16
|
-
export
|
|
22
|
+
// Re-export console-types
|
|
23
|
+
export type {
|
|
24
|
+
PluginRegisterHostApi,
|
|
25
|
+
PluginAddRouteInput,
|
|
26
|
+
PluginAddToolInput,
|
|
27
|
+
ConsolePluginRegister,
|
|
28
|
+
ConsoleClientEntry,
|
|
29
|
+
ConsoleEntriesResponse,
|
|
30
|
+
ConsoleEntry,
|
|
31
|
+
ConsoleFileAddEntryInput,
|
|
32
|
+
RuntimeEnv,
|
|
33
|
+
} from "@zhin.js/console-types";
|
|
17
34
|
|
|
18
|
-
export
|
|
19
|
-
|
|
20
|
-
|
|
35
|
+
export {
|
|
36
|
+
DEFAULT_CONSOLE_BASE_PATH,
|
|
37
|
+
CONSOLE_HOST_REACT_NAMESPACE_KEY,
|
|
38
|
+
} from "@zhin.js/console-types";
|
|
39
|
+
|
|
40
|
+
export { configureConsole, getRuntimeEnv } from "./runtime/index.js";
|
|
41
|
+
|
|
42
|
+
export {
|
|
43
|
+
createRegistryStore,
|
|
44
|
+
useRegistry,
|
|
45
|
+
type RegistryStore,
|
|
46
|
+
} from "./store/createRegistryStore.js";
|
|
47
|
+
|
|
48
|
+
export { cn } from "./console-utils/cn.js";
|
|
49
|
+
|
|
50
|
+
export {
|
|
51
|
+
apiFetch,
|
|
52
|
+
getApiBase,
|
|
53
|
+
getToken,
|
|
54
|
+
resolveApiUrl,
|
|
55
|
+
resolveWebSocketUrl,
|
|
56
|
+
} from "./console-utils/remoteApi.js";
|
|
57
|
+
|
|
58
|
+
export {
|
|
59
|
+
fetchConsoleEntries,
|
|
60
|
+
createPluginRegisterHostApi,
|
|
61
|
+
getRegisterFn,
|
|
62
|
+
loadConsoleEntries,
|
|
63
|
+
registerConsolePluginsFromEntries,
|
|
64
|
+
type CreatePluginRegisterHostApiOptions,
|
|
65
|
+
type FetchConsoleEntriesOptions,
|
|
66
|
+
type LoadConsoleEntriesOptions,
|
|
67
|
+
} from "./bootstrap/loadConsoleEntries.js";
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
const DB_NAME = "zhin-console";
|
|
2
|
+
const DB_VERSION = 1;
|
|
3
|
+
const STORE_INBOX = "inbox";
|
|
4
|
+
const STORE_PENDING = "pending";
|
|
5
|
+
|
|
6
|
+
export type InboxRecord = {
|
|
7
|
+
id: string;
|
|
8
|
+
adapter: string;
|
|
9
|
+
botId: string;
|
|
10
|
+
kind: "message" | "request" | "notice";
|
|
11
|
+
payload: unknown;
|
|
12
|
+
updatedAt: number;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
function openDb(): Promise<IDBDatabase> {
|
|
16
|
+
return new Promise((resolve, reject) => {
|
|
17
|
+
const req = indexedDB.open(DB_NAME, DB_VERSION);
|
|
18
|
+
req.onerror = () => reject(req.error);
|
|
19
|
+
req.onsuccess = () => resolve(req.result);
|
|
20
|
+
req.onupgradeneeded = () => {
|
|
21
|
+
const db = req.result;
|
|
22
|
+
if (!db.objectStoreNames.contains(STORE_INBOX)) {
|
|
23
|
+
db.createObjectStore(STORE_INBOX, { keyPath: "id" });
|
|
24
|
+
}
|
|
25
|
+
if (!db.objectStoreNames.contains(STORE_PENDING)) {
|
|
26
|
+
db.createObjectStore(STORE_PENDING, { keyPath: "id" });
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function idbPutInbox(record: InboxRecord): Promise<void> {
|
|
33
|
+
const db = await openDb();
|
|
34
|
+
await new Promise<void>((resolve, reject) => {
|
|
35
|
+
const tx = db.transaction(STORE_INBOX, "readwrite");
|
|
36
|
+
tx.objectStore(STORE_INBOX).put(record);
|
|
37
|
+
tx.oncomplete = () => resolve();
|
|
38
|
+
tx.onerror = () => reject(tx.error);
|
|
39
|
+
});
|
|
40
|
+
db.close();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function idbListInbox(
|
|
44
|
+
adapter: string,
|
|
45
|
+
botId: string,
|
|
46
|
+
kind: InboxRecord["kind"],
|
|
47
|
+
): Promise<InboxRecord[]> {
|
|
48
|
+
const db = await openDb();
|
|
49
|
+
const all = await new Promise<InboxRecord[]>((resolve, reject) => {
|
|
50
|
+
const tx = db.transaction(STORE_INBOX, "readonly");
|
|
51
|
+
const req = tx.objectStore(STORE_INBOX).getAll();
|
|
52
|
+
req.onsuccess = () => resolve((req.result as InboxRecord[]) ?? []);
|
|
53
|
+
req.onerror = () => reject(req.error);
|
|
54
|
+
});
|
|
55
|
+
db.close();
|
|
56
|
+
return all.filter((r) => r.adapter === adapter && r.botId === botId && r.kind === kind);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function applyConsoleEvent(event: { type: string; data?: unknown }): Promise<void> {
|
|
60
|
+
const t = event.type;
|
|
61
|
+
if (t === "bot:message" || t === "bot:request" || t === "bot:notice") {
|
|
62
|
+
const data = event.data as Record<string, unknown> | undefined;
|
|
63
|
+
if (!data) return;
|
|
64
|
+
const adapter = String(data.adapter ?? "");
|
|
65
|
+
const botId = String(data.botId ?? data.bot ?? "");
|
|
66
|
+
const id = `${adapter}:${botId}:${t}:${Date.now()}`;
|
|
67
|
+
await idbPutInbox({
|
|
68
|
+
id,
|
|
69
|
+
adapter,
|
|
70
|
+
botId,
|
|
71
|
+
kind: t === "bot:message" ? "message" : t === "bot:request" ? "request" : "notice",
|
|
72
|
+
payload: data,
|
|
73
|
+
updatedAt: Date.now(),
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { RuntimeEnv } from "@zhin.js/console-types";
|
|
2
|
+
|
|
3
|
+
let runtimeEnvFn: (() => RuntimeEnv) | null = null;
|
|
4
|
+
|
|
5
|
+
export function configureConsole(options: { getRuntimeEnv: () => RuntimeEnv }) {
|
|
6
|
+
runtimeEnvFn = options.getRuntimeEnv;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function getRuntimeEnv(): RuntimeEnv {
|
|
10
|
+
if (runtimeEnvFn) return runtimeEnvFn();
|
|
11
|
+
return "production";
|
|
12
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
|
|
3
|
+
export type RegistryStore<T> = {
|
|
4
|
+
add(item: T): void;
|
|
5
|
+
remove(predicate: (item: T) => boolean): void;
|
|
6
|
+
getSnapshot(): readonly T[];
|
|
7
|
+
subscribe(listener: () => void): () => void;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export function createRegistryStore<T>(dedupeKey?: (item: T) => string): RegistryStore<T> {
|
|
11
|
+
let items: T[] = [];
|
|
12
|
+
const listeners = new Set<() => void>();
|
|
13
|
+
|
|
14
|
+
const emit = () => {
|
|
15
|
+
for (const l of listeners) l();
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
return {
|
|
19
|
+
add(item) {
|
|
20
|
+
if (dedupeKey) {
|
|
21
|
+
const key = dedupeKey(item);
|
|
22
|
+
if (items.some((i) => dedupeKey(i) === key)) return;
|
|
23
|
+
}
|
|
24
|
+
items = [...items, item];
|
|
25
|
+
emit();
|
|
26
|
+
},
|
|
27
|
+
remove(predicate) {
|
|
28
|
+
const next = items.filter((i) => !predicate(i));
|
|
29
|
+
if (next.length !== items.length) {
|
|
30
|
+
items = next;
|
|
31
|
+
emit();
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
getSnapshot() {
|
|
35
|
+
return items;
|
|
36
|
+
},
|
|
37
|
+
subscribe(listener) {
|
|
38
|
+
listeners.add(listener);
|
|
39
|
+
return () => listeners.delete(listener);
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function useRegistry<T>(store: RegistryStore<T>): readonly T[] {
|
|
45
|
+
return React.useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot);
|
|
46
|
+
}
|