@zhin.js/client 1.1.4 → 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 (147) hide show
  1. package/README.md +94 -432
  2. package/client/agent-stream.ts +121 -0
  3. package/client/app.test.ts +37 -0
  4. package/client/app.ts +130 -138
  5. package/client/bootstrap/loadConsoleEntries.test.ts +23 -12
  6. package/client/bootstrap/loadConsoleEntries.ts +40 -27
  7. package/client/console-client-context.ts +24 -0
  8. package/client/console-client.test.ts +46 -0
  9. package/client/console-client.ts +70 -0
  10. package/client/console-events.ts +38 -0
  11. package/client/console-utils/remoteApi.test.ts +91 -0
  12. package/client/console-utils/remoteApi.ts +47 -9
  13. package/client/index.ts +92 -28
  14. package/client/mediaSrc.test.ts +49 -0
  15. package/client/mediaSrc.ts +84 -11
  16. package/client/message-content/MarkdownContent.tsx +128 -0
  17. package/client/message-content/markdown.test.ts +48 -0
  18. package/client/message-content/markdown.ts +247 -0
  19. package/client/persistence/idb-store.test.ts +121 -0
  20. package/client/persistence/idb-store.ts +40 -21
  21. package/client/segments.test.ts +16 -0
  22. package/client/segments.ts +39 -0
  23. package/client/transport/console-transport.test.ts +394 -0
  24. package/client/transport/console-transport.ts +676 -0
  25. package/client/transport/hooks.test.ts +21 -0
  26. package/client/transport/hooks.ts +535 -0
  27. package/client/transport/index.ts +23 -0
  28. package/client/{websocket → transport}/types.ts +16 -17
  29. package/client/types.ts +5 -8
  30. package/client/workroom/WorkroomRunsPage.tsx +355 -0
  31. package/client/workroom/registerWorkroomConsole.test.ts +25 -0
  32. package/client/workroom/registerWorkroomConsole.ts +17 -0
  33. package/dist/agent-stream.d.ts +17 -0
  34. package/dist/agent-stream.d.ts.map +1 -0
  35. package/dist/agent-stream.js +73 -0
  36. package/dist/agent-stream.js.map +1 -0
  37. package/dist/app.d.ts +17 -52
  38. package/dist/app.d.ts.map +1 -1
  39. package/dist/app.js +110 -95
  40. package/dist/app.js.map +1 -1
  41. package/dist/bootstrap/loadConsoleEntries.d.ts +10 -7
  42. package/dist/bootstrap/loadConsoleEntries.d.ts.map +1 -1
  43. package/dist/bootstrap/loadConsoleEntries.js +37 -11
  44. package/dist/bootstrap/loadConsoleEntries.js.map +1 -1
  45. package/dist/console-client-context.d.ts +9 -0
  46. package/dist/console-client-context.d.ts.map +1 -0
  47. package/dist/console-client-context.js +13 -0
  48. package/dist/console-client-context.js.map +1 -0
  49. package/dist/console-client.d.ts +25 -0
  50. package/dist/console-client.d.ts.map +1 -0
  51. package/dist/console-client.js +45 -0
  52. package/dist/console-client.js.map +1 -0
  53. package/dist/console-events.d.ts +8 -0
  54. package/dist/console-events.d.ts.map +1 -0
  55. package/dist/console-events.js +26 -0
  56. package/dist/console-events.js.map +1 -0
  57. package/dist/console-utils/remoteApi.d.ts +2 -1
  58. package/dist/console-utils/remoteApi.d.ts.map +1 -1
  59. package/dist/console-utils/remoteApi.js +46 -8
  60. package/dist/console-utils/remoteApi.js.map +1 -1
  61. package/dist/index.d.ts +14 -7
  62. package/dist/index.d.ts.map +1 -1
  63. package/dist/index.js +18 -8
  64. package/dist/index.js.map +1 -1
  65. package/dist/mediaSrc.d.ts +1 -1
  66. package/dist/mediaSrc.d.ts.map +1 -1
  67. package/dist/mediaSrc.js +91 -11
  68. package/dist/mediaSrc.js.map +1 -1
  69. package/dist/message-content/MarkdownContent.d.ts +11 -0
  70. package/dist/message-content/MarkdownContent.d.ts.map +1 -0
  71. package/dist/message-content/MarkdownContent.js +68 -0
  72. package/dist/message-content/MarkdownContent.js.map +1 -0
  73. package/dist/message-content/markdown.d.ts +47 -0
  74. package/dist/message-content/markdown.d.ts.map +1 -0
  75. package/dist/message-content/markdown.js +219 -0
  76. package/dist/message-content/markdown.js.map +1 -0
  77. package/dist/persistence/idb-store.d.ts +5 -2
  78. package/dist/persistence/idb-store.d.ts.map +1 -1
  79. package/dist/persistence/idb-store.js +29 -20
  80. package/dist/persistence/idb-store.js.map +1 -1
  81. package/dist/segments.d.ts +9 -0
  82. package/dist/segments.d.ts.map +1 -0
  83. package/dist/segments.js +35 -0
  84. package/dist/segments.js.map +1 -0
  85. package/dist/transport/console-transport.d.ts +217 -0
  86. package/dist/transport/console-transport.d.ts.map +1 -0
  87. package/dist/transport/console-transport.js +563 -0
  88. package/dist/transport/console-transport.js.map +1 -0
  89. package/dist/transport/hooks.d.ts +253 -0
  90. package/dist/transport/hooks.d.ts.map +1 -0
  91. package/dist/transport/hooks.js +483 -0
  92. package/dist/transport/hooks.js.map +1 -0
  93. package/dist/transport/index.d.ts +4 -0
  94. package/dist/transport/index.d.ts.map +1 -0
  95. package/dist/transport/index.js +4 -0
  96. package/dist/transport/index.js.map +1 -0
  97. package/dist/{websocket → transport}/types.d.ts +14 -13
  98. package/dist/transport/types.d.ts.map +1 -0
  99. package/dist/{websocket → transport}/types.js +4 -9
  100. package/dist/transport/types.js.map +1 -0
  101. package/dist/types.d.ts +4 -4
  102. package/dist/types.d.ts.map +1 -1
  103. package/dist/workroom/WorkroomRunsPage.d.ts +2 -0
  104. package/dist/workroom/WorkroomRunsPage.d.ts.map +1 -0
  105. package/dist/workroom/WorkroomRunsPage.js +111 -0
  106. package/dist/workroom/WorkroomRunsPage.js.map +1 -0
  107. package/dist/workroom/registerWorkroomConsole.d.ts +4 -0
  108. package/dist/workroom/registerWorkroomConsole.d.ts.map +1 -0
  109. package/dist/workroom/registerWorkroomConsole.js +16 -0
  110. package/dist/workroom/registerWorkroomConsole.js.map +1 -0
  111. package/package.json +18 -8
  112. package/client/runtime/index.ts +0 -12
  113. package/client/websocket/hooks.ts +0 -289
  114. package/client/websocket/index.ts +0 -6
  115. package/client/websocket/instance.ts +0 -24
  116. package/client/websocket/manager.ts +0 -426
  117. package/client/websocket/remote-settings.ts +0 -25
  118. package/dist/bootstrap/loadConsoleEntries.test.d.ts +0 -2
  119. package/dist/bootstrap/loadConsoleEntries.test.d.ts.map +0 -1
  120. package/dist/bootstrap/loadConsoleEntries.test.js +0 -27
  121. package/dist/bootstrap/loadConsoleEntries.test.js.map +0 -1
  122. package/dist/runtime/index.d.ts +0 -6
  123. package/dist/runtime/index.d.ts.map +0 -1
  124. package/dist/runtime/index.js +0 -10
  125. package/dist/runtime/index.js.map +0 -1
  126. package/dist/websocket/hooks.d.ts +0 -105
  127. package/dist/websocket/hooks.d.ts.map +0 -1
  128. package/dist/websocket/hooks.js +0 -247
  129. package/dist/websocket/hooks.js.map +0 -1
  130. package/dist/websocket/index.d.ts +0 -7
  131. package/dist/websocket/index.d.ts.map +0 -1
  132. package/dist/websocket/index.js +0 -7
  133. package/dist/websocket/index.js.map +0 -1
  134. package/dist/websocket/instance.d.ts +0 -5
  135. package/dist/websocket/instance.d.ts.map +0 -1
  136. package/dist/websocket/instance.js +0 -21
  137. package/dist/websocket/instance.js.map +0 -1
  138. package/dist/websocket/manager.d.ts +0 -113
  139. package/dist/websocket/manager.d.ts.map +0 -1
  140. package/dist/websocket/manager.js +0 -403
  141. package/dist/websocket/manager.js.map +0 -1
  142. package/dist/websocket/remote-settings.d.ts +0 -5
  143. package/dist/websocket/remote-settings.d.ts.map +0 -1
  144. package/dist/websocket/remote-settings.js +0 -23
  145. package/dist/websocket/remote-settings.js.map +0 -1
  146. package/dist/websocket/types.d.ts.map +0 -1
  147. package/dist/websocket/types.js.map +0 -1
@@ -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,5 +1,8 @@
1
1
  import { describe, expect, it, vi } from "vitest";
2
- import { createPluginRegisterHostApi, getRegisterFn } from "./loadConsoleEntries.js";
2
+ import {
3
+ getRegisterFn,
4
+ resolveEntryRegister,
5
+ } from "./loadConsoleEntries.js";
3
6
 
4
7
  describe("console entries bootstrap", () => {
5
8
  it("resolves named register export", () => {
@@ -16,17 +19,25 @@ describe("console entries bootstrap", () => {
16
19
  expect(getRegisterFn({ default: {} })).toBeNull();
17
20
  });
18
21
 
19
- it("creates a host API with addPage aliased to addRoute", () => {
20
- const React = {} as any;
22
+ it("synthesizes register from default component + entry.route", () => {
23
+ const Page = () => null;
24
+ const register = resolveEntryRegister(
25
+ { default: Page, meta: { title: "Sandbox", icon: "Box" } },
26
+ { id: "sandbox", resolvedModule: "/assets/client/sandbox.js", route: "/p-sandbox" } as any,
27
+ );
28
+ expect(typeof register).toBe("function");
21
29
  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
+ const createElement = vi.fn((c: unknown) => ({ type: c }));
31
+ register!({
32
+ React: { createElement } as any,
33
+ addRoute,
34
+ addTool: vi.fn(),
35
+ });
36
+ expect(addRoute).toHaveBeenCalledWith(expect.objectContaining({
37
+ path: "/p-sandbox",
38
+ name: "Sandbox",
39
+ icon: "Box",
40
+ }));
41
+ expect(createElement).toHaveBeenCalledWith(Page);
30
42
  });
31
43
  });
32
-
@@ -1,10 +1,5 @@
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";
1
+ import { DEFAULT_CONSOLE_BASE_PATH, type ConsoleClientEntry, type ConsoleEntriesResponse, type ConsolePluginRegister, type PluginRegisterHostApi } from '@zhin.js/contract';
2
+ import type * as React from 'react';
8
3
 
9
4
  export type FetchConsoleEntriesOptions = {
10
5
  entriesUrl?: string;
@@ -22,24 +17,6 @@ export type LoadConsoleEntriesOptions = FetchConsoleEntriesOptions & {
22
17
  onEntryError?: (entry: ConsoleClientEntry, error: unknown) => void;
23
18
  };
24
19
 
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
20
  const CONSOLE_API_BASE_STORAGE_KEY = "zhin_api_base";
44
21
 
45
22
  function resolveStoredApiBase(): string {
@@ -97,6 +74,43 @@ export function getRegisterFn(mod: Record<string, unknown> | null | undefined):
97
74
  return null;
98
75
  }
99
76
 
77
+ /**
78
+ * Convention pages (ADR 0046) export a React component as `default` + optional `meta`,
79
+ * without a legacy `register(api)`. Synthesize register from entry.route / entry meta
80
+ * so Remote Console can still mount them.
81
+ */
82
+ export function resolveEntryRegister(
83
+ mod: Record<string, unknown> | null | undefined,
84
+ entry: ConsoleClientEntry & { readonly route?: string; readonly title?: string },
85
+ ): ConsolePluginRegister | null {
86
+ const named = getRegisterFn(mod);
87
+ if (named) return named;
88
+ if (!mod) return null;
89
+ const Component = mod["default"];
90
+ if (typeof Component !== "function" && !(Component && typeof Component === "object")) {
91
+ return null;
92
+ }
93
+ const path = typeof entry.route === "string" && entry.route
94
+ ? entry.route
95
+ : `/${entry.id}`;
96
+ const meta = mod["meta"] && typeof mod["meta"] === "object"
97
+ ? mod["meta"] as { title?: string; icon?: string; hideInNav?: boolean }
98
+ : undefined;
99
+ const name = (typeof meta?.title === "string" && meta.title)
100
+ || (typeof entry.title === "string" && entry.title)
101
+ || entry.meta?.name
102
+ || entry.id;
103
+ return (api) => {
104
+ api.addRoute({
105
+ path,
106
+ name,
107
+ element: api.React.createElement(Component as React.ComponentType),
108
+ ...(meta?.icon != null ? { icon: meta.icon } : {}),
109
+ meta: { hideInMenu: meta?.hideInNav === true },
110
+ });
111
+ };
112
+ }
113
+
100
114
  export async function registerConsolePluginsFromEntries(
101
115
  entries: ConsoleClientEntry[],
102
116
  hostApi: PluginRegisterHostApi,
@@ -109,7 +123,7 @@ export async function registerConsolePluginsFromEntries(
109
123
  try {
110
124
  const specifier = resolvePluginImportUrl(entry.resolvedModule, assetOrigin);
111
125
  const mod = (await import(/* @vite-ignore */ specifier)) as Record<string, unknown>;
112
- const register = getRegisterFn(mod);
126
+ const register = resolveEntryRegister(mod, entry);
113
127
  if (register) await register(hostApi);
114
128
  else throw new Error(`entry "${entry.id}" has no register export`);
115
129
  } catch (error) {
@@ -146,4 +160,3 @@ export async function loadConsoleEntries(options: LoadConsoleEntriesOptions): Pr
146
160
  options.assetOrigin,
147
161
  );
148
162
  }
149
-
@@ -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
+ }
@@ -0,0 +1,38 @@
1
+ import type {
2
+ ConsoleEventHistoryPage,
3
+ ConsoleEventHistoryQuery,
4
+ } from '@zhin.js/console-protocol';
5
+ import { getToken, resolveApiUrl } from './console-utils/remoteApi.js';
6
+
7
+ export interface FetchConsoleEventHistoryOptions {
8
+ readonly signal?: AbortSignal;
9
+ readonly fetch?: typeof globalThis.fetch;
10
+ }
11
+
12
+ /** Read a typed page from the Host's bounded Console event journal. */
13
+ export async function fetchConsoleEventHistory(
14
+ query: ConsoleEventHistoryQuery = {},
15
+ options: FetchConsoleEventHistoryOptions = {},
16
+ ): Promise<ConsoleEventHistoryPage> {
17
+ const params = new URLSearchParams();
18
+ if (query.runtimeId) params.set('runtimeId', query.runtimeId);
19
+ if (query.after !== undefined) params.set('after', String(query.after));
20
+ if (query.limit !== undefined) params.set('limit', String(query.limit));
21
+ const token = getToken();
22
+ const headers: Record<string, string> = { Accept: 'application/json' };
23
+ if (token) headers.Authorization = `Bearer ${token}`;
24
+ const suffix = params.size ? `?${params}` : '';
25
+ const response = await (options.fetch ?? globalThis.fetch)(resolveApiUrl(`/api/events/history${suffix}`), {
26
+ headers,
27
+ signal: options.signal,
28
+ });
29
+ const body = await response.json() as {
30
+ success?: boolean;
31
+ data?: ConsoleEventHistoryPage;
32
+ error?: string;
33
+ };
34
+ if (!response.ok || body.success !== true || !body.data) {
35
+ throw new Error(body.error ?? `Console event history failed: HTTP ${response.status}`);
36
+ }
37
+ return body.data;
38
+ }