@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.
Files changed (51) hide show
  1. package/client/bootstrap/loadConsoleEntries.test.ts +32 -0
  2. package/client/bootstrap/loadConsoleEntries.ts +149 -0
  3. package/client/console-utils/cn.ts +6 -0
  4. package/client/console-utils/remoteApi.ts +48 -0
  5. package/client/index.ts +24 -5
  6. package/client/persistence/idb-store.ts +76 -0
  7. package/client/runtime/index.ts +12 -0
  8. package/client/store/createRegistryStore.ts +46 -0
  9. package/client/websocket/manager.ts +136 -3
  10. package/client/websocket/remote-settings.ts +25 -0
  11. package/dist/bootstrap/loadConsoleEntries.d.ts +26 -0
  12. package/dist/bootstrap/loadConsoleEntries.d.ts.map +1 -0
  13. package/dist/bootstrap/loadConsoleEntries.js +111 -0
  14. package/dist/bootstrap/loadConsoleEntries.js.map +1 -0
  15. package/dist/bootstrap/loadConsoleEntries.test.d.ts +2 -0
  16. package/dist/bootstrap/loadConsoleEntries.test.d.ts.map +1 -0
  17. package/dist/bootstrap/loadConsoleEntries.test.js +27 -0
  18. package/dist/bootstrap/loadConsoleEntries.test.js.map +1 -0
  19. package/dist/console-utils/cn.d.ts +3 -0
  20. package/dist/console-utils/cn.d.ts.map +1 -0
  21. package/dist/console-utils/cn.js +6 -0
  22. package/dist/console-utils/cn.js.map +1 -0
  23. package/dist/console-utils/remoteApi.d.ts +7 -0
  24. package/dist/console-utils/remoteApi.d.ts.map +1 -0
  25. package/dist/console-utils/remoteApi.js +41 -0
  26. package/dist/console-utils/remoteApi.js.map +1 -0
  27. package/dist/index.d.ts +5 -1
  28. package/dist/index.d.ts.map +1 -1
  29. package/dist/index.js +5 -2
  30. package/dist/index.js.map +1 -1
  31. package/dist/persistence/idb-store.d.ts +15 -0
  32. package/dist/persistence/idb-store.d.ts.map +1 -0
  33. package/dist/persistence/idb-store.js +61 -0
  34. package/dist/persistence/idb-store.js.map +1 -0
  35. package/dist/runtime/index.d.ts +6 -0
  36. package/dist/runtime/index.d.ts.map +1 -0
  37. package/dist/runtime/index.js +10 -0
  38. package/dist/runtime/index.js.map +1 -0
  39. package/dist/store/createRegistryStore.d.ts +9 -0
  40. package/dist/store/createRegistryStore.d.ts.map +1 -0
  41. package/dist/store/createRegistryStore.js +38 -0
  42. package/dist/store/createRegistryStore.js.map +1 -0
  43. package/dist/websocket/manager.d.ts +5 -0
  44. package/dist/websocket/manager.d.ts.map +1 -1
  45. package/dist/websocket/manager.js +134 -2
  46. package/dist/websocket/manager.js.map +1 -1
  47. package/dist/websocket/remote-settings.d.ts +5 -0
  48. package/dist/websocket/remote-settings.d.ts.map +1 -0
  49. package/dist/websocket/remote-settings.js +23 -0
  50. package/dist/websocket/remote-settings.js.map +1 -0
  51. 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,6 @@
1
+ import { type ClassValue, clsx } from "clsx";
2
+ import { twMerge } from "tailwind-merge";
3
+
4
+ export function cn(...inputs: ClassValue[]) {
5
+ return twMerge(clsx(inputs));
6
+ }
@@ -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
- // Re-export console-core browser utilities
40
+ export { configureConsole, getRuntimeEnv } from "./runtime/index.js";
41
+
41
42
  export {
42
43
  createRegistryStore,
43
44
  useRegistry,
44
45
  type RegistryStore,
45
- configureConsole,
46
- getRuntimeEnv,
47
- cn,
48
- } from "@zhin.js/console-core/browser";
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 }>({ type: "config:set", pluginName, data: config });
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) => this.handleConnectionError(new ConnectionError("WebSocket error", event as unknown as Error));
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("[WebSocket] Connection error:", 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"}