@zhin.js/client 1.0.18 → 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/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 +24 -5
- 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/manager.ts +136 -3
- package/client/websocket/remote-settings.ts +25 -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 +5 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -2
- 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/manager.d.ts +5 -0
- package/dist/websocket/manager.d.ts.map +1 -1
- package/dist/websocket/manager.js +134 -2
- 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/package.json +6 -5
|
@@ -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
|
@@ -37,12 +37,31 @@ export {
|
|
|
37
37
|
CONSOLE_HOST_REACT_NAMESPACE_KEY,
|
|
38
38
|
} from "@zhin.js/console-types";
|
|
39
39
|
|
|
40
|
-
|
|
40
|
+
export { configureConsole, getRuntimeEnv } from "./runtime/index.js";
|
|
41
|
+
|
|
41
42
|
export {
|
|
42
43
|
createRegistryStore,
|
|
43
44
|
useRegistry,
|
|
44
45
|
type RegistryStore,
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
+
}
|
|
@@ -10,6 +10,8 @@ import {
|
|
|
10
10
|
MessageError,
|
|
11
11
|
RequestTimeoutError,
|
|
12
12
|
} from "./types";
|
|
13
|
+
import { getApiBase, getStoredToken, resolveApiUrl } from "./remote-settings.js";
|
|
14
|
+
import { applyConsoleEvent } from "../persistence/idb-store.js";
|
|
13
15
|
|
|
14
16
|
export class WebSocketManager {
|
|
15
17
|
private ws: WebSocket | null = null;
|
|
@@ -24,6 +26,8 @@ export class WebSocketManager {
|
|
|
24
26
|
{ resolve: (value: unknown) => void; reject: (error: Error) => void; timer: ReturnType<typeof setTimeout> }
|
|
25
27
|
>();
|
|
26
28
|
private connectedListeners = new Set<(connected: boolean) => void>();
|
|
29
|
+
private sseAbort: AbortController | null = null;
|
|
30
|
+
private useRestTransport = true;
|
|
27
31
|
|
|
28
32
|
constructor(config: WebSocketConfig = {}, callbacks: WebSocketCallbacks = {}) {
|
|
29
33
|
this.config = {
|
|
@@ -45,6 +49,10 @@ export class WebSocketManager {
|
|
|
45
49
|
}
|
|
46
50
|
|
|
47
51
|
connect(): void {
|
|
52
|
+
if (this.useRestTransport) {
|
|
53
|
+
void this.connectRestSse();
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
48
56
|
if (this.state === ConnectionState.CONNECTED || this.state === ConnectionState.CONNECTING) return;
|
|
49
57
|
this.setState(ConnectionState.CONNECTING);
|
|
50
58
|
try {
|
|
@@ -55,9 +63,82 @@ export class WebSocketManager {
|
|
|
55
63
|
}
|
|
56
64
|
}
|
|
57
65
|
|
|
66
|
+
private async connectRestSse(): Promise<void> {
|
|
67
|
+
const token = getStoredToken();
|
|
68
|
+
if (!token) {
|
|
69
|
+
this.setState(ConnectionState.DISCONNECTED);
|
|
70
|
+
this.notifyConnection(false);
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
this.setState(ConnectionState.CONNECTING);
|
|
74
|
+
this.sseAbort?.abort();
|
|
75
|
+
this.sseAbort = new AbortController();
|
|
76
|
+
try {
|
|
77
|
+
const res = await fetch(resolveApiUrl("/api/events"), {
|
|
78
|
+
headers: {
|
|
79
|
+
Authorization: `Bearer ${token}`,
|
|
80
|
+
Accept: "text/event-stream",
|
|
81
|
+
},
|
|
82
|
+
signal: this.sseAbort.signal,
|
|
83
|
+
});
|
|
84
|
+
if (!res.ok || !res.body) {
|
|
85
|
+
throw new ConnectionError(`SSE failed: HTTP ${res.status}`);
|
|
86
|
+
}
|
|
87
|
+
this.setState(ConnectionState.CONNECTED);
|
|
88
|
+
this.reconnectAttempts = 0;
|
|
89
|
+
this.notifyConnection(true);
|
|
90
|
+
this.callbacks.onConnect?.();
|
|
91
|
+
void this.pumpSse(res.body);
|
|
92
|
+
} catch (error) {
|
|
93
|
+
if ((error as Error).name === "AbortError") return;
|
|
94
|
+
this.handleConnectionError(
|
|
95
|
+
error instanceof ConnectionError ? error : new ConnectionError("SSE connect failed", error as Error),
|
|
96
|
+
);
|
|
97
|
+
this.scheduleReconnect();
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
private async pumpSse(body: ReadableStream<Uint8Array>): Promise<void> {
|
|
102
|
+
const reader = body.getReader();
|
|
103
|
+
const decoder = new TextDecoder();
|
|
104
|
+
let buffer = "";
|
|
105
|
+
try {
|
|
106
|
+
while (true) {
|
|
107
|
+
const { done, value } = await reader.read();
|
|
108
|
+
if (done) break;
|
|
109
|
+
buffer += decoder.decode(value, { stream: true });
|
|
110
|
+
const parts = buffer.split("\n\n");
|
|
111
|
+
buffer = parts.pop() ?? "";
|
|
112
|
+
for (const part of parts) {
|
|
113
|
+
const line = part.split("\n").find((l) => l.startsWith("data: "));
|
|
114
|
+
if (!line) continue;
|
|
115
|
+
const json = line.slice(6);
|
|
116
|
+
try {
|
|
117
|
+
const message = JSON.parse(json) as WebSocketMessage;
|
|
118
|
+
void applyConsoleEvent(message);
|
|
119
|
+
this.handleBroadcast(message);
|
|
120
|
+
this.callbacks.onMessage?.(message);
|
|
121
|
+
} catch {
|
|
122
|
+
/* skip */
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
} catch {
|
|
127
|
+
/* closed */
|
|
128
|
+
} finally {
|
|
129
|
+
if (this.state === ConnectionState.CONNECTED) {
|
|
130
|
+
this.notifyConnection(false);
|
|
131
|
+
this.setState(ConnectionState.RECONNECTING);
|
|
132
|
+
this.scheduleReconnect();
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
58
137
|
disconnect(): void {
|
|
59
138
|
this.clearReconnectTimer();
|
|
60
139
|
this.clearPendingRequests();
|
|
140
|
+
this.sseAbort?.abort();
|
|
141
|
+
this.sseAbort = null;
|
|
61
142
|
if (this.ws) {
|
|
62
143
|
this.ws.close();
|
|
63
144
|
this.ws = null;
|
|
@@ -67,6 +148,10 @@ export class WebSocketManager {
|
|
|
67
148
|
}
|
|
68
149
|
|
|
69
150
|
send(message: unknown): void {
|
|
151
|
+
if (this.useRestTransport) {
|
|
152
|
+
void this.sendRequest(message);
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
70
155
|
if (!this.isConnected()) throw new WebSocketError("WebSocket is not connected", "NOT_CONNECTED");
|
|
71
156
|
try {
|
|
72
157
|
this.ws!.send(JSON.stringify(message));
|
|
@@ -76,6 +161,9 @@ export class WebSocketManager {
|
|
|
76
161
|
}
|
|
77
162
|
|
|
78
163
|
async sendRequest<T = unknown>(message: unknown): Promise<T> {
|
|
164
|
+
if (this.useRestTransport) {
|
|
165
|
+
return this.sendRestRequest<T>(message);
|
|
166
|
+
}
|
|
79
167
|
if (!this.isConnected()) throw new WebSocketError("WebSocket is not connected", "NOT_CONNECTED");
|
|
80
168
|
return new Promise((resolve, reject) => {
|
|
81
169
|
const requestId = ++this.requestId;
|
|
@@ -95,7 +183,46 @@ export class WebSocketManager {
|
|
|
95
183
|
});
|
|
96
184
|
}
|
|
97
185
|
|
|
186
|
+
private async sendRestRequest<T>(message: unknown): Promise<T> {
|
|
187
|
+
const token = getStoredToken();
|
|
188
|
+
if (!token) throw new WebSocketError("Not authenticated", "NOT_CONNECTED");
|
|
189
|
+
const requestId = ++this.requestId;
|
|
190
|
+
const body = { ...(message as Record<string, unknown>), requestId };
|
|
191
|
+
const timer = setTimeout(() => {
|
|
192
|
+
/* handled below */
|
|
193
|
+
}, this.config.requestTimeout);
|
|
194
|
+
try {
|
|
195
|
+
const res = await fetch(resolveApiUrl("/api/console/request"), {
|
|
196
|
+
method: "POST",
|
|
197
|
+
headers: {
|
|
198
|
+
Authorization: `Bearer ${token}`,
|
|
199
|
+
"Content-Type": "application/json",
|
|
200
|
+
},
|
|
201
|
+
body: JSON.stringify(body),
|
|
202
|
+
signal: AbortSignal.timeout(this.config.requestTimeout),
|
|
203
|
+
});
|
|
204
|
+
clearTimeout(timer);
|
|
205
|
+
const json = (await res.json()) as {
|
|
206
|
+
success?: boolean;
|
|
207
|
+
data?: T;
|
|
208
|
+
error?: string;
|
|
209
|
+
requestId?: number;
|
|
210
|
+
};
|
|
211
|
+
if (!res.ok || json.success === false) {
|
|
212
|
+
throw new WebSocketError(json.error ?? `HTTP ${res.status}`, "SERVER_ERROR");
|
|
213
|
+
}
|
|
214
|
+
return json.data as T;
|
|
215
|
+
} catch (error) {
|
|
216
|
+
clearTimeout(timer);
|
|
217
|
+
if (error instanceof WebSocketError) throw error;
|
|
218
|
+
throw new MessageError("REST request failed", error as Error);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
98
222
|
isConnected(): boolean {
|
|
223
|
+
if (this.useRestTransport) {
|
|
224
|
+
return this.state === ConnectionState.CONNECTED;
|
|
225
|
+
}
|
|
99
226
|
return this.ws?.readyState === WebSocket.OPEN;
|
|
100
227
|
}
|
|
101
228
|
|
|
@@ -107,7 +234,11 @@ export class WebSocketManager {
|
|
|
107
234
|
return this.sendRequest<unknown>({ type: "config:get", pluginName });
|
|
108
235
|
}
|
|
109
236
|
async setConfig(pluginName: string, config: unknown) {
|
|
110
|
-
return this.sendRequest<{ success?: boolean; reloaded?: boolean; message?: string }>({
|
|
237
|
+
return this.sendRequest<{ success?: boolean; reloaded?: boolean; message?: string }>({
|
|
238
|
+
type: "config:set",
|
|
239
|
+
pluginName,
|
|
240
|
+
data: config,
|
|
241
|
+
});
|
|
111
242
|
}
|
|
112
243
|
async getSchema(pluginName: string) {
|
|
113
244
|
return this.sendRequest<unknown>({ type: "schema:get", pluginName });
|
|
@@ -191,7 +322,8 @@ export class WebSocketManager {
|
|
|
191
322
|
this.ws.onopen = () => this.handleConnectionOpen();
|
|
192
323
|
this.ws.onmessage = (event) => this.handleMessage(event);
|
|
193
324
|
this.ws.onclose = () => this.handleConnectionClose();
|
|
194
|
-
this.ws.onerror = (event) =>
|
|
325
|
+
this.ws.onerror = (event) =>
|
|
326
|
+
this.handleConnectionError(new ConnectionError("WebSocket error", event as unknown as Error));
|
|
195
327
|
}
|
|
196
328
|
|
|
197
329
|
private handleConnectionOpen(): void {
|
|
@@ -208,6 +340,7 @@ export class WebSocketManager {
|
|
|
208
340
|
this.handleRequestResponse(message);
|
|
209
341
|
return;
|
|
210
342
|
}
|
|
343
|
+
void applyConsoleEvent(message);
|
|
211
344
|
this.handleBroadcast(message);
|
|
212
345
|
this.callbacks.onMessage?.(message);
|
|
213
346
|
} catch (error) {
|
|
@@ -260,7 +393,7 @@ export class WebSocketManager {
|
|
|
260
393
|
private handleConnectionError(error: Error): void {
|
|
261
394
|
this.clearReconnectTimer();
|
|
262
395
|
this.setState(ConnectionState.ERROR);
|
|
263
|
-
console.error("[
|
|
396
|
+
console.error("[Console transport] Connection error:", error);
|
|
264
397
|
this.callbacks.onError?.(error as unknown as Event);
|
|
265
398
|
}
|
|
266
399
|
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
const API_BASE_KEY = "zhin_api_base";
|
|
2
|
+
const TOKEN_KEY = "zhin_api_token";
|
|
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") {
|
|
8
|
+
return window.location.origin;
|
|
9
|
+
}
|
|
10
|
+
return "";
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function setApiBase(base: string): void {
|
|
14
|
+
localStorage.setItem(API_BASE_KEY, base.replace(/\/$/, ""));
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function getStoredToken(): string | null {
|
|
18
|
+
return localStorage.getItem(TOKEN_KEY);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function resolveApiUrl(path: string): string {
|
|
22
|
+
const base = getApiBase();
|
|
23
|
+
const p = path.startsWith("/") ? path : `/${path}`;
|
|
24
|
+
return `${base}${p}`;
|
|
25
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { ConsoleClientEntry, ConsoleEntriesResponse, ConsolePluginRegister, PluginRegisterHostApi } from "@zhin.js/console-types";
|
|
2
|
+
export type FetchConsoleEntriesOptions = {
|
|
3
|
+
entriesUrl?: string;
|
|
4
|
+
signal?: AbortSignal;
|
|
5
|
+
fetchInit?: RequestInit | (() => RequestInit);
|
|
6
|
+
};
|
|
7
|
+
export type LoadConsoleEntriesOptions = FetchConsoleEntriesOptions & {
|
|
8
|
+
/** Origin for `/@dev` / `/@assets` module URLs (Remote Console: Host API base). */
|
|
9
|
+
assetOrigin?: string;
|
|
10
|
+
hostApi: PluginRegisterHostApi;
|
|
11
|
+
beforeLoad?: () => void;
|
|
12
|
+
onEmpty?: () => void;
|
|
13
|
+
onFetchError?: (status: number) => void;
|
|
14
|
+
onEntryError?: (entry: ConsoleClientEntry, error: unknown) => void;
|
|
15
|
+
};
|
|
16
|
+
export type CreatePluginRegisterHostApiOptions = {
|
|
17
|
+
React: PluginRegisterHostApi["React"];
|
|
18
|
+
addRoute: PluginRegisterHostApi["addRoute"];
|
|
19
|
+
addTool: PluginRegisterHostApi["addTool"];
|
|
20
|
+
};
|
|
21
|
+
export declare function createPluginRegisterHostApi(options: CreatePluginRegisterHostApiOptions): PluginRegisterHostApi;
|
|
22
|
+
export declare function fetchConsoleEntries(options?: FetchConsoleEntriesOptions): Promise<ConsoleEntriesResponse>;
|
|
23
|
+
export declare function getRegisterFn(mod: Record<string, unknown> | null | undefined): ConsolePluginRegister | null;
|
|
24
|
+
export declare function registerConsolePluginsFromEntries(entries: ConsoleClientEntry[], hostApi: PluginRegisterHostApi, onEntryError?: (entry: ConsoleClientEntry, error: unknown) => void, assetOrigin?: string): Promise<void>;
|
|
25
|
+
export declare function loadConsoleEntries(options: LoadConsoleEntriesOptions): Promise<void>;
|
|
26
|
+
//# sourceMappingURL=loadConsoleEntries.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"loadConsoleEntries.d.ts","sourceRoot":"","sources":["../../client/bootstrap/loadConsoleEntries.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,kBAAkB,EAClB,sBAAsB,EACtB,qBAAqB,EACrB,qBAAqB,EACtB,MAAM,wBAAwB,CAAC;AAGhC,MAAM,MAAM,0BAA0B,GAAG;IACvC,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,MAAM,CAAC,EAAE,WAAW,CAAC;IACrB,SAAS,CAAC,EAAE,WAAW,GAAG,CAAC,MAAM,WAAW,CAAC,CAAC;CAC/C,CAAC;AAEF,MAAM,MAAM,yBAAyB,GAAG,0BAA0B,GAAG;IACnE,mFAAmF;IACnF,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,OAAO,EAAE,qBAAqB,CAAC;IAC/B,UAAU,CAAC,EAAE,MAAM,IAAI,CAAC;IACxB,OAAO,CAAC,EAAE,MAAM,IAAI,CAAC;IACrB,YAAY,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,IAAI,CAAC;IACxC,YAAY,CAAC,EAAE,CAAC,KAAK,EAAE,kBAAkB,EAAE,KAAK,EAAE,OAAO,KAAK,IAAI,CAAC;CACpE,CAAC;AAEF,MAAM,MAAM,kCAAkC,GAAG;IAC/C,KAAK,EAAE,qBAAqB,CAAC,OAAO,CAAC,CAAC;IACtC,QAAQ,EAAE,qBAAqB,CAAC,UAAU,CAAC,CAAC;IAC5C,OAAO,EAAE,qBAAqB,CAAC,SAAS,CAAC,CAAC;CAC3C,CAAC;AAEF,wBAAgB,2BAA2B,CACzC,OAAO,EAAE,kCAAkC,GAC1C,qBAAqB,CAQvB;AAoCD,wBAAsB,mBAAmB,CACvC,OAAO,CAAC,EAAE,0BAA0B,GACnC,OAAO,CAAC,sBAAsB,CAAC,CAUjC;AAED,wBAAgB,aAAa,CAAC,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,GAAG,SAAS,GAAG,qBAAqB,GAAG,IAAI,CAO3G;AAED,wBAAsB,iCAAiC,CACrD,OAAO,EAAE,kBAAkB,EAAE,EAC7B,OAAO,EAAE,qBAAqB,EAC9B,YAAY,CAAC,EAAE,CAAC,KAAK,EAAE,kBAAkB,EAAE,KAAK,EAAE,OAAO,KAAK,IAAI,EAClE,WAAW,CAAC,EAAE,MAAM,GACnB,OAAO,CAAC,IAAI,CAAC,CAgBf;AAED,wBAAsB,kBAAkB,CAAC,OAAO,EAAE,yBAAyB,GAAG,OAAO,CAAC,IAAI,CAAC,CAyB1F"}
|