@zhin.js/client 1.1.5 → 1.1.6

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 (99) hide show
  1. package/README.md +25 -19
  2. package/client/app.test.ts +37 -0
  3. package/client/app.ts +130 -138
  4. package/client/bootstrap/loadConsoleEntries.test.ts +0 -15
  5. package/client/bootstrap/loadConsoleEntries.ts +1 -20
  6. package/client/console-client-context.ts +24 -0
  7. package/client/console-client.test.ts +46 -0
  8. package/client/console-client.ts +70 -0
  9. package/client/console-events.ts +4 -3
  10. package/client/console-utils/remoteApi.ts +0 -7
  11. package/client/index.ts +21 -13
  12. package/client/persistence/idb-store.test.ts +0 -18
  13. package/client/persistence/idb-store.ts +3 -6
  14. package/client/{websocket/manager.test.ts → transport/console-transport.test.ts} +79 -34
  15. package/client/{websocket/manager.ts → transport/console-transport.ts} +237 -178
  16. package/client/{websocket → transport}/hooks.ts +240 -23
  17. package/client/transport/index.ts +23 -0
  18. package/client/{websocket → transport}/types.ts +11 -17
  19. package/client/types.ts +0 -3
  20. package/client/workroom/registerWorkroomConsole.test.ts +0 -1
  21. package/dist/app.d.ts +17 -52
  22. package/dist/app.d.ts.map +1 -1
  23. package/dist/app.js +110 -95
  24. package/dist/app.js.map +1 -1
  25. package/dist/bootstrap/loadConsoleEntries.d.ts +0 -6
  26. package/dist/bootstrap/loadConsoleEntries.d.ts.map +1 -1
  27. package/dist/bootstrap/loadConsoleEntries.js +0 -9
  28. package/dist/bootstrap/loadConsoleEntries.js.map +1 -1
  29. package/dist/console-client-context.d.ts +9 -0
  30. package/dist/console-client-context.d.ts.map +1 -0
  31. package/dist/console-client-context.js +13 -0
  32. package/dist/console-client-context.js.map +1 -0
  33. package/dist/console-client.d.ts +25 -0
  34. package/dist/console-client.d.ts.map +1 -0
  35. package/dist/console-client.js +45 -0
  36. package/dist/console-client.js.map +1 -0
  37. package/dist/console-events.d.ts +1 -0
  38. package/dist/console-events.d.ts.map +1 -1
  39. package/dist/console-events.js +3 -3
  40. package/dist/console-events.js.map +1 -1
  41. package/dist/console-utils/remoteApi.d.ts +0 -1
  42. package/dist/console-utils/remoteApi.d.ts.map +1 -1
  43. package/dist/console-utils/remoteApi.js +0 -6
  44. package/dist/console-utils/remoteApi.js.map +1 -1
  45. package/dist/index.d.ts +7 -6
  46. package/dist/index.d.ts.map +1 -1
  47. package/dist/index.js +9 -8
  48. package/dist/index.js.map +1 -1
  49. package/dist/persistence/idb-store.d.ts.map +1 -1
  50. package/dist/persistence/idb-store.js +3 -5
  51. package/dist/persistence/idb-store.js.map +1 -1
  52. package/dist/transport/console-transport.d.ts +217 -0
  53. package/dist/transport/console-transport.d.ts.map +1 -0
  54. package/dist/{websocket/manager.js → transport/console-transport.js} +147 -165
  55. package/dist/transport/console-transport.js.map +1 -0
  56. package/dist/transport/hooks.d.ts +253 -0
  57. package/dist/transport/hooks.d.ts.map +1 -0
  58. package/dist/{websocket → transport}/hooks.js +234 -19
  59. package/dist/transport/hooks.js.map +1 -0
  60. package/dist/transport/index.d.ts +4 -0
  61. package/dist/transport/index.d.ts.map +1 -0
  62. package/dist/transport/index.js +4 -0
  63. package/dist/transport/index.js.map +1 -0
  64. package/dist/{websocket → transport}/types.d.ts +10 -13
  65. package/dist/transport/types.d.ts.map +1 -0
  66. package/dist/{websocket → transport}/types.js +4 -9
  67. package/dist/transport/types.js.map +1 -0
  68. package/dist/types.d.ts +0 -1
  69. package/dist/types.d.ts.map +1 -1
  70. package/package.json +3 -3
  71. package/client/runtime/index.ts +0 -12
  72. package/client/websocket/index.ts +0 -11
  73. package/client/websocket/instance.ts +0 -24
  74. package/client/websocket/remote-settings.ts +0 -47
  75. package/dist/runtime/index.d.ts +0 -6
  76. package/dist/runtime/index.d.ts.map +0 -1
  77. package/dist/runtime/index.js +0 -10
  78. package/dist/runtime/index.js.map +0 -1
  79. package/dist/websocket/hooks.d.ts +0 -114
  80. package/dist/websocket/hooks.d.ts.map +0 -1
  81. package/dist/websocket/hooks.js.map +0 -1
  82. package/dist/websocket/index.d.ts +0 -7
  83. package/dist/websocket/index.d.ts.map +0 -1
  84. package/dist/websocket/index.js +0 -7
  85. package/dist/websocket/index.js.map +0 -1
  86. package/dist/websocket/instance.d.ts +0 -5
  87. package/dist/websocket/instance.d.ts.map +0 -1
  88. package/dist/websocket/instance.js +0 -21
  89. package/dist/websocket/instance.js.map +0 -1
  90. package/dist/websocket/manager.d.ts +0 -131
  91. package/dist/websocket/manager.d.ts.map +0 -1
  92. package/dist/websocket/manager.js.map +0 -1
  93. package/dist/websocket/remote-settings.d.ts +0 -5
  94. package/dist/websocket/remote-settings.d.ts.map +0 -1
  95. package/dist/websocket/remote-settings.js +0 -47
  96. package/dist/websocket/remote-settings.js.map +0 -1
  97. package/dist/websocket/types.d.ts.map +0 -1
  98. package/dist/websocket/types.js.map +0 -1
  99. /package/client/{websocket → transport}/hooks.test.ts +0 -0
package/README.md CHANGED
@@ -17,24 +17,22 @@ Peer:`react >= 18`(`createPluginRegisterHostApi` 需要 React 引用)。
17
17
  | 导出 | 用途 |
18
18
  |------|------|
19
19
  | `loadConsoleEntries` | 拉 `GET /entries`、动态 import 各 entry、调用 `register(hostApi)` |
20
- | `createPluginRegisterHostApi` | 由壳层的 `React` / `addRoute` / `addTool` 构造 `PluginRegisterHostApi` |
20
+ | `createConsoleClient` | 创建一个拥有路由、工具、运行时环境与网络连接的 Console 实例 |
21
+ | `ConsoleClientProvider` | 将 Console 实例提供给 `useConsoleTransport` 等 React hooks |
21
22
  | `apiFetch` | 相对 Host API Base 的 `fetch`,自动附加 `Authorization: Bearer` |
22
23
  | `getApiBase` / `getToken` | 读取登录页写入 `localStorage` 的 API Base 与 Token |
23
- | `app` | 路由与工具注册单例(`addRoute`、`addTool` 等) |
24
- | `configureConsole` / `getRuntimeEnv` | 运行时环境(development / production) |
25
24
  | `createRegistryStore` / `useRegistry` | 可选 registry store |
26
25
  | `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 分类 |
26
+ | `parseConsoleInboxEvent` | 校验并分类 canonical message/request/notice 推送 |
29
27
  | `ConsoleEndpointSummary` / `EndpointManagementCapability` | Host 与 Remote Console 共享的 Endpoint wire 类型 |
30
28
  | `fetchConsoleEventHistory` | 按 `(runtimeId, eventId)` 拉取有界事件历史 |
31
- | `WebSocketManager.onConsoleEvent` | 订阅带 `live/history` 投递来源的强类型事件 |
32
- | `WebSocketManager.onConsoleEventRecoveryGap` | 观察不可续接游标并触发领域全量重同步 |
29
+ | `ConsoleTransport.onConsoleEvent` | 订阅带 `live/history` 投递来源的强类型事件 |
30
+ | `ConsoleTransport.onConsoleEventRecoveryGap` | 观察不可续接游标并触发领域全量重同步 |
33
31
  | `ConsoleInboxNoticesQuery` / `ConsoleInboxNoticesResult` | 以 `unreadOnly` 从持久 Inbox 重建未读通知 |
34
32
 
35
33
  类型与 Entry 契约来自 `@zhin.js/contract`。
36
34
 
37
- 业务 UI 应使用 SDK 导出的协议常量和 Endpoint 类型,不要硬编码旧 `endpoint:list`、`endpoint:sendMessage` 等名称,也不要复制 `EndpointInfo`。SDK 会把旧 Host 推送别名归一化为 `message.receive` / `request.receive` / `notice.receive`,并统一 `endpointId`、`channelId` 字段。
35
+ 业务 UI 应使用 SDK 导出的协议常量和 Endpoint 类型,不要复制 RPC 字符串或 `EndpointInfo`。请求使用顶层 camelCase payload,Host 推送使用 `message.receive` / `request.receive` / `notice.receive` 和 canonical `adapter`、`endpointKey`、`channelId` 字段。
38
36
 
39
37
  ## 启动:加载插件 Console Entry
40
38
 
@@ -43,16 +41,15 @@ Remote Console 壳层在登录后调用:
43
41
  ```tsx
44
42
  import React from "react";
45
43
  import {
46
- app,
47
- createPluginRegisterHostApi,
44
+ ConsoleClientProvider,
45
+ createConsoleClient,
48
46
  loadConsoleEntries,
49
47
  } from "@zhin.js/client";
50
48
 
51
- const hostApi = createPluginRegisterHostApi({
52
- React,
53
- addRoute: app.addRoute.bind(app),
54
- addTool: app.addTool.bind(app),
49
+ const client = createConsoleClient({
50
+ getRuntimeEnv: () => import.meta.env.DEV ? "development" : "production",
55
51
  });
52
+ const hostApi = client.createPluginRegisterHostApi(React);
56
53
 
57
54
  await loadConsoleEntries({
58
55
  hostApi,
@@ -64,6 +61,12 @@ await loadConsoleEntries({
64
61
  onFetchError: (status) => console.error("entries fetch failed", status),
65
62
  onEmpty: () => console.warn("no console entries"),
66
63
  });
64
+
65
+ // 壳层根组件必须为 hooks 提供同一个 client 所有者。
66
+ export function ConsoleRoot({ children }: { children: React.ReactNode }) {
67
+ React.useEffect(() => () => client.dispose(), []);
68
+ return <ConsoleClientProvider client={client}>{children}</ConsoleClientProvider>;
69
+ }
67
70
  ```
68
71
 
69
72
  `loadConsoleEntries` 内部:`fetchConsoleEntries` → `registerConsolePluginsFromEntries` → 各 entry 模块的 `register` 或 `default.register`。
@@ -99,18 +102,21 @@ const res = await apiFetch("/api/console/request", {
99
102
 
100
103
  401 时清除 Token 并派发 `zhin:auth-required`。
101
104
 
102
- 辅助:`resolveApiUrl`、`resolveWebSocketUrl`(SSE `/api/events` 等)。
105
+ 辅助:`resolveApiUrl`。
103
106
 
104
107
  Client 在连接 `/api/events` 前先补拉 `/api/events/history`,再携带最新游标进入 SSE;Host 会原子重放 HTTP 与订阅之间产生的事件。游标只在 Inbox 持久化成功后推进,重复的 history/live 投递按 `(runtimeId, eventId)` 幂等写入 IndexedDB。`gap` 不会被吞掉,页面可订阅恢复缺口并改走领域 HTTP 全量投影。
105
108
 
106
- ## `createPluginRegisterHostApi`
109
+ ## Console 实例所有权
107
110
 
108
- 将壳层已有的 React 与路由/工具注册函数适配为契约中的 `PluginRegisterHostApi`(含 `addPage` 别名 → `addRoute`):
111
+ 每个 Remote Console mount 创建一个 `ConsoleClient`。插件注册、React hooks 和卸载必须使用同一个实例,多个 mount 之间不会共享路由、工具、运行时环境或连接状态:
109
112
 
110
113
  ```ts
111
- import { createPluginRegisterHostApi } from "@zhin.js/client";
114
+ import React from "react";
115
+ import { createConsoleClient } from "@zhin.js/client";
112
116
 
113
- const hostApi = createPluginRegisterHostApi({ React, addRoute, addTool });
117
+ const client = createConsoleClient();
118
+ const hostApi = client.createPluginRegisterHostApi(React);
119
+ await loadConsoleEntries({ hostApi });
114
120
  ```
115
121
 
116
122
  ## 低级 API
@@ -0,0 +1,37 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { createConsoleApp } from './app.js';
3
+
4
+ describe('ConsoleApp', () => {
5
+ it('isolates registrations and subscriptions by application owner', () => {
6
+ const first = createConsoleApp();
7
+ const second = createConsoleApp();
8
+ const listener = vi.fn();
9
+ first.subscribe(listener);
10
+
11
+ first.addRoute({ path: '/first', name: 'First', element: 'first' });
12
+ first.addTool({ id: 'first', name: 'First' });
13
+
14
+ expect(listener).toHaveBeenCalledTimes(2);
15
+ expect(first.getRoutes()).toHaveLength(1);
16
+ expect(first.getToolTree()).toHaveLength(1);
17
+ expect(second.getRoutes()).toEqual([]);
18
+ expect(second.getToolTree()).toEqual([]);
19
+ expect(second.getVersion()).toBe(0);
20
+ });
21
+
22
+ it('releases all registrations and listeners on disposal', () => {
23
+ const app = createConsoleApp();
24
+ const listener = vi.fn();
25
+ app.subscribe(listener);
26
+ app.addRoute({ path: '/route', name: 'Route', element: 'route' });
27
+ app.addTool({ id: 'tool', name: 'Tool' });
28
+
29
+ app.dispose();
30
+
31
+ expect(listener).toHaveBeenCalledTimes(2);
32
+ expect(app.getRoutes()).toEqual([]);
33
+ expect(app.getToolTree()).toEqual([]);
34
+ expect(() => app.addRoute({ path: '/next', name: 'Next', element: 'next' }))
35
+ .toThrow('ConsoleApp has been disposed');
36
+ });
37
+ });
package/client/app.ts CHANGED
@@ -1,28 +1,11 @@
1
1
  import * as React from "react";
2
+ import type {
3
+ PluginAddRouteInput,
4
+ PluginAddToolInput,
5
+ } from '@zhin.js/contract';
2
6
 
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
- };
7
+ export type AddRouteInput = PluginAddRouteInput;
8
+ export type AddToolInput = PluginAddToolInput;
26
9
 
27
10
  export type ConsoleRouteRecord = {
28
11
  path: string;
@@ -51,33 +34,10 @@ export type InternalToolRecord = {
51
34
  export type RouteTreeNode = ConsoleRouteRecord & { children: RouteTreeNode[] };
52
35
  export type ToolTreeNode = InternalToolRecord & { children: ToolTreeNode[] };
53
36
 
54
- type SidebarRenderer = (options: { routes: RouteTreeNode[] }) => React.ReactNode;
55
- type ToolbarRenderer = (options: { tools: ToolTreeNode[] }) => React.ReactNode;
37
+ export type SidebarRenderer = (options: { routes: RouteTreeNode[] }) => React.ReactNode;
38
+ export type ToolbarRenderer = (options: { tools: ToolTreeNode[] }) => React.ReactNode;
56
39
  export type ConsoleRouteRenderer = (route: ConsoleRouteRecord) => React.ReactNode;
57
40
 
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
41
  function slugId(name: string) {
82
42
  return (
83
43
  name
@@ -130,99 +90,131 @@ function buildToolTree(flatTools: InternalToolRecord[]): ToolTreeNode[] {
130
90
  }
131
91
  return roots;
132
92
  }
93
+ /** Owner-scoped registry for one mounted Remote Console application. */
94
+ export class ConsoleApp {
95
+ readonly #listeners = new Set<() => void>();
96
+ #version = 0;
97
+ #routes: ConsoleRouteRecord[] = [];
98
+ #tools: InternalToolRecord[] = [];
99
+ #sidebarRenderer: SidebarRenderer | null = null;
100
+ #toolbarRenderer: ToolbarRenderer | null = null;
101
+ #routeRenderer: ConsoleRouteRenderer | null = null;
102
+ #disposed = false;
103
+
104
+ readonly subscribe = (listener: () => void): (() => void) => {
105
+ this.#assertActive();
106
+ this.#listeners.add(listener);
107
+ return () => this.#listeners.delete(listener);
108
+ };
133
109
 
134
- function renderRouteElement(route: ConsoleRouteRecord): React.ReactNode {
135
- if (routeRenderer) return routeRenderer(route);
136
- return route.element;
137
- }
110
+ readonly getVersion = (): number => this.#version;
138
111
 
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,
112
+ defineSidebar(render: SidebarRenderer): void {
113
+ this.#assertActive();
114
+ this.#sidebarRenderer = render;
115
+ this.#bump();
116
+ }
117
+
118
+ defineToolbar(render: ToolbarRenderer): void {
119
+ this.#assertActive();
120
+ this.#toolbarRenderer = render;
121
+ this.#bump();
122
+ }
123
+
124
+ defineRouter(render: ConsoleRouteRenderer): void {
125
+ this.#assertActive();
126
+ this.#routeRenderer = render;
127
+ this.#bump();
128
+ }
129
+
130
+ addRoute(input: AddRouteInput): void {
131
+ this.#assertActive();
132
+ const next = this.#routes.filter((route) => route.path !== input.path);
133
+ next.push({
134
+ path: input.path,
135
+ name: input.name,
136
+ element: input.element,
137
+ parent: input.parent ?? null,
138
+ icon: input.icon,
139
+ requiredPermissions: input.requiredPermissions,
140
+ requiredRoles: input.requiredRoles,
141
+ meta: input.meta,
142
+ });
143
+ this.#routes = next;
144
+ this.#bump();
145
+ }
146
+
147
+ removeRoute(path: string): void {
148
+ this.#assertActive();
149
+ this.#routes = this.#routes.filter((route) => route.path !== path);
150
+ this.#bump();
151
+ }
152
+
153
+ addTool(input: AddToolInput): string {
154
+ this.#assertActive();
155
+ const id = input.id ?? `${slugId(input.name)}-${Math.random().toString(36).slice(2, 8)}`;
156
+ if (this.#tools.some((tool) => tool.id === id)) {
157
+ throw new Error(`[zhin-console] addTool: id already exists: ${id}`);
158
+ }
159
+ this.#tools = [
160
+ ...this.#tools,
161
+ {
162
+ id,
163
163
  name: input.name,
164
- element: input.element,
165
- parent: input.parent ?? null,
166
164
  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
- }
165
+ parent: input.parent ?? null,
166
+ path: input.path,
167
+ },
168
+ ];
169
+ this.#bump();
170
+ return id;
171
+ }
172
+
173
+ getRouteTree(): RouteTreeNode[] {
174
+ return buildRouteTree(this.#routes);
175
+ }
225
176
 
226
- export type ConsoleApp = ReturnType<typeof createConsoleApp>;
177
+ getToolTree(): ToolTreeNode[] {
178
+ return buildToolTree(this.#tools);
179
+ }
227
180
 
228
- export const app = createConsoleApp();
181
+ getRoutes(): readonly ConsoleRouteRecord[] {
182
+ return this.#routes;
183
+ }
184
+
185
+ getSidebarRenderer(): SidebarRenderer | null {
186
+ return this.#sidebarRenderer;
187
+ }
188
+
189
+ getToolbarRenderer(): ToolbarRenderer | null {
190
+ return this.#toolbarRenderer;
191
+ }
192
+
193
+ renderRouteElement(route: ConsoleRouteRecord): React.ReactNode {
194
+ return this.#routeRenderer ? this.#routeRenderer(route) : route.element;
195
+ }
196
+
197
+ dispose(): void {
198
+ if (this.#disposed) return;
199
+ this.#disposed = true;
200
+ this.#listeners.clear();
201
+ this.#routes = [];
202
+ this.#tools = [];
203
+ this.#sidebarRenderer = null;
204
+ this.#toolbarRenderer = null;
205
+ this.#routeRenderer = null;
206
+ }
207
+
208
+ #bump(): void {
209
+ this.#version += 1;
210
+ for (const listener of this.#listeners) listener();
211
+ }
212
+
213
+ #assertActive(): void {
214
+ if (this.#disposed) throw new Error('ConsoleApp has been disposed');
215
+ }
216
+ }
217
+
218
+ export function createConsoleApp(): ConsoleApp {
219
+ return new ConsoleApp();
220
+ }
@@ -1,6 +1,5 @@
1
1
  import { describe, expect, it, vi } from "vitest";
2
2
  import {
3
- createPluginRegisterHostApi,
4
3
  getRegisterFn,
5
4
  resolveEntryRegister,
6
5
  } from "./loadConsoleEntries.js";
@@ -32,7 +31,6 @@ describe("console entries bootstrap", () => {
32
31
  register!({
33
32
  React: { createElement } as any,
34
33
  addRoute,
35
- addPage: addRoute,
36
34
  addTool: vi.fn(),
37
35
  });
38
36
  expect(addRoute).toHaveBeenCalledWith(expect.objectContaining({
@@ -42,17 +40,4 @@ describe("console entries bootstrap", () => {
42
40
  }));
43
41
  expect(createElement).toHaveBeenCalledWith(Page);
44
42
  });
45
-
46
- it("creates a host API with addPage aliased to addRoute", () => {
47
- const React = {} as any;
48
- const addRoute = vi.fn();
49
- const addTool = vi.fn(() => "tool-id");
50
- const api = createPluginRegisterHostApi({ React, addRoute, addTool });
51
-
52
- api.addPage({ path: "/x", name: "x", element: "page" as any });
53
- api.addRoute({ path: "/y", name: "y", element: "route" as any });
54
- expect(api.React).toBe(React);
55
- expect(addRoute).toHaveBeenCalledTimes(2);
56
- expect(api.addTool({ id: "t", name: "T" })).toBe("tool-id");
57
- });
58
43
  });
@@ -17,24 +17,6 @@ export type LoadConsoleEntriesOptions = FetchConsoleEntriesOptions & {
17
17
  onEntryError?: (entry: ConsoleClientEntry, error: unknown) => void;
18
18
  };
19
19
 
20
- export type CreatePluginRegisterHostApiOptions = {
21
- React: PluginRegisterHostApi["React"];
22
- addRoute: PluginRegisterHostApi["addRoute"];
23
- addTool: PluginRegisterHostApi["addTool"];
24
- };
25
-
26
- export function createPluginRegisterHostApi(
27
- options: CreatePluginRegisterHostApiOptions,
28
- ): PluginRegisterHostApi {
29
- const addRoute = options.addRoute;
30
- return {
31
- React: options.React,
32
- addRoute,
33
- addPage: addRoute,
34
- addTool: options.addTool,
35
- };
36
- }
37
-
38
20
  const CONSOLE_API_BASE_STORAGE_KEY = "zhin_api_base";
39
21
 
40
22
  function resolveStoredApiBase(): string {
@@ -112,7 +94,7 @@ export function resolveEntryRegister(
112
94
  ? entry.route
113
95
  : `/${entry.id}`;
114
96
  const meta = mod["meta"] && typeof mod["meta"] === "object"
115
- ? mod["meta"] as { title?: string; icon?: unknown; hideInNav?: boolean }
97
+ ? mod["meta"] as { title?: string; icon?: string; hideInNav?: boolean }
116
98
  : undefined;
117
99
  const name = (typeof meta?.title === "string" && meta.title)
118
100
  || (typeof entry.title === "string" && entry.title)
@@ -178,4 +160,3 @@ export async function loadConsoleEntries(options: LoadConsoleEntriesOptions): Pr
178
160
  options.assetOrigin,
179
161
  );
180
162
  }
181
-
@@ -0,0 +1,24 @@
1
+ import * as React from 'react';
2
+ import type { ConsoleClient } from './console-client.js';
3
+
4
+ const ConsoleClientContext = React.createContext<ConsoleClient | null>(null);
5
+
6
+ export interface ConsoleClientProviderProps {
7
+ readonly client: ConsoleClient;
8
+ readonly children?: React.ReactNode;
9
+ }
10
+
11
+ export function ConsoleClientProvider({
12
+ client,
13
+ children,
14
+ }: ConsoleClientProviderProps): React.ReactElement {
15
+ return React.createElement(ConsoleClientContext.Provider, { value: client }, children);
16
+ }
17
+
18
+ export function useConsoleClient(): ConsoleClient {
19
+ const client = React.useContext(ConsoleClientContext);
20
+ if (!client) {
21
+ throw new Error('Console hooks require a ConsoleClientProvider');
22
+ }
23
+ return client;
24
+ }
@@ -0,0 +1,46 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import * as React from 'react';
3
+ import { createConsoleClient } from './console-client.js';
4
+ import { ConsoleTransport } from './transport/console-transport.js';
5
+
6
+ describe('ConsoleClient', () => {
7
+ it('owns application, transport, and runtime environment state', () => {
8
+ const first = createConsoleClient({ getRuntimeEnv: () => 'development' });
9
+ const second = createConsoleClient();
10
+
11
+ first.app.addRoute({ path: '/first', name: 'First', element: 'first' });
12
+
13
+ expect(first.getRuntimeEnv()).toBe('development');
14
+ expect(second.getRuntimeEnv()).toBe('production');
15
+ expect(first.app.getRoutes()).toHaveLength(1);
16
+ expect(second.app.getRoutes()).toEqual([]);
17
+ expect(first.transport).not.toBe(second.transport);
18
+ });
19
+
20
+ it('binds plugin registration to its owned application', () => {
21
+ const client = createConsoleClient();
22
+ const hostApi = client.createPluginRegisterHostApi(React);
23
+
24
+ hostApi.addRoute({ path: '/plugin', name: 'Plugin', element: 'plugin' });
25
+ hostApi.addTool({ id: 'plugin', name: 'Plugin' });
26
+
27
+ expect(client.app.getRoutes().map((route) => route.path)).toEqual(['/plugin']);
28
+ expect(client.app.getToolTree().map((tool) => tool.id)).toEqual(['plugin']);
29
+ });
30
+
31
+ it('disconnects its transport and clears its application on disposal', () => {
32
+ const transport = new ConsoleTransport();
33
+ const dispose = vi.spyOn(transport, 'dispose');
34
+ const client = createConsoleClient({ transport });
35
+ client.app.addRoute({ path: '/route', name: 'Route', element: 'route' });
36
+
37
+ client.dispose();
38
+
39
+ expect(dispose).toHaveBeenCalledOnce();
40
+ expect(client.app.getRoutes()).toEqual([]);
41
+ expect(() => client.connect()).toThrow('ConsoleClient has been disposed');
42
+
43
+ client.dispose();
44
+ expect(dispose).toHaveBeenCalledOnce();
45
+ });
46
+ });
@@ -0,0 +1,70 @@
1
+ import type {
2
+ PluginRegisterHostApi,
3
+ RuntimeEnv,
4
+ } from '@zhin.js/contract';
5
+ import type * as React from 'react';
6
+ import { ConsoleApp, createConsoleApp } from './app.js';
7
+ import { ConsoleTransport } from './transport/console-transport.js';
8
+ import type {
9
+ ConsoleTransportCallbacks,
10
+ ConsoleTransportConfig,
11
+ } from './transport/types.js';
12
+
13
+ export interface ConsoleClientOptions {
14
+ readonly getRuntimeEnv?: () => RuntimeEnv;
15
+ readonly app?: ConsoleApp;
16
+ readonly transport?: ConsoleTransport;
17
+ readonly transportConfig?: ConsoleTransportConfig;
18
+ readonly transportCallbacks?: ConsoleTransportCallbacks;
19
+ }
20
+
21
+ /** Owns all mutable browser-side state for one Remote Console mount. */
22
+ export class ConsoleClient {
23
+ readonly app: ConsoleApp;
24
+ readonly transport: ConsoleTransport;
25
+ readonly #getRuntimeEnv: () => RuntimeEnv;
26
+ #disposed = false;
27
+
28
+ constructor(options: ConsoleClientOptions = {}) {
29
+ this.app = options.app ?? createConsoleApp();
30
+ this.transport = options.transport ?? new ConsoleTransport(
31
+ options.transportConfig,
32
+ options.transportCallbacks,
33
+ );
34
+ this.#getRuntimeEnv = options.getRuntimeEnv ?? (() => 'production');
35
+ }
36
+
37
+ getRuntimeEnv(): RuntimeEnv {
38
+ this.#assertActive();
39
+ return this.#getRuntimeEnv();
40
+ }
41
+
42
+ createPluginRegisterHostApi(ReactRuntime: typeof React): PluginRegisterHostApi {
43
+ this.#assertActive();
44
+ return {
45
+ React: ReactRuntime,
46
+ addRoute: (input) => this.app.addRoute(input),
47
+ addTool: (input) => this.app.addTool(input),
48
+ };
49
+ }
50
+
51
+ connect(): void {
52
+ this.#assertActive();
53
+ this.transport.connect();
54
+ }
55
+
56
+ dispose(): void {
57
+ if (this.#disposed) return;
58
+ this.#disposed = true;
59
+ this.transport.dispose();
60
+ this.app.dispose();
61
+ }
62
+
63
+ #assertActive(): void {
64
+ if (this.#disposed) throw new Error('ConsoleClient has been disposed');
65
+ }
66
+ }
67
+
68
+ export function createConsoleClient(options?: ConsoleClientOptions): ConsoleClient {
69
+ return new ConsoleClient(options);
70
+ }
@@ -2,10 +2,11 @@ import type {
2
2
  ConsoleEventHistoryPage,
3
3
  ConsoleEventHistoryQuery,
4
4
  } from '@zhin.js/console-protocol';
5
- import { getStoredToken, resolveApiUrl } from './websocket/remote-settings.js';
5
+ import { getToken, resolveApiUrl } from './console-utils/remoteApi.js';
6
6
 
7
7
  export interface FetchConsoleEventHistoryOptions {
8
8
  readonly signal?: AbortSignal;
9
+ readonly fetch?: typeof globalThis.fetch;
9
10
  }
10
11
 
11
12
  /** Read a typed page from the Host's bounded Console event journal. */
@@ -17,11 +18,11 @@ export async function fetchConsoleEventHistory(
17
18
  if (query.runtimeId) params.set('runtimeId', query.runtimeId);
18
19
  if (query.after !== undefined) params.set('after', String(query.after));
19
20
  if (query.limit !== undefined) params.set('limit', String(query.limit));
20
- const token = getStoredToken();
21
+ const token = getToken();
21
22
  const headers: Record<string, string> = { Accept: 'application/json' };
22
23
  if (token) headers.Authorization = `Bearer ${token}`;
23
24
  const suffix = params.size ? `?${params}` : '';
24
- const response = await fetch(resolveApiUrl(`/api/events/history${suffix}`), {
25
+ const response = await (options.fetch ?? globalThis.fetch)(resolveApiUrl(`/api/events/history${suffix}`), {
25
26
  headers,
26
27
  signal: options.signal,
27
28
  });
@@ -28,13 +28,6 @@ export function resolveApiUrl(path: string): string {
28
28
  return `${base}${p}`;
29
29
  }
30
30
 
31
- export function resolveWebSocketUrl(path: string): string {
32
- const httpUrl = resolveApiUrl(path);
33
- const url = new URL(httpUrl);
34
- url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
35
- return url.href;
36
- }
37
-
38
31
  /** fetch with Bearer token; relative paths resolve against {@link getApiBase}. */
39
32
  export async function apiFetch(
40
33
  input: RequestInfo | URL,