@checkmate-monitor/frontend-api 0.0.2
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/CHANGELOG.md +8 -0
- package/package.json +25 -0
- package/src/api-context.ts +53 -0
- package/src/api-ref.ts +15 -0
- package/src/components/ExtensionSlot.tsx +46 -0
- package/src/core-apis.ts +41 -0
- package/src/index.ts +9 -0
- package/src/plugin-registry.ts +245 -0
- package/src/plugin.ts +75 -0
- package/src/slots.test.ts +200 -0
- package/src/slots.ts +48 -0
- package/src/use-plugin-route.ts +65 -0
- package/src/utils.tsx +16 -0
- package/tsconfig.json +6 -0
package/CHANGELOG.md
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@checkmate-monitor/frontend-api",
|
|
3
|
+
"version": "0.0.2",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "./src/index.ts",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"typecheck": "tsc --noEmit",
|
|
8
|
+
"lint": "bun run lint:code",
|
|
9
|
+
"lint:code": "eslint . --max-warnings 0"
|
|
10
|
+
},
|
|
11
|
+
"peerDependencies": {
|
|
12
|
+
"react": "^18.0.0"
|
|
13
|
+
},
|
|
14
|
+
"dependencies": {
|
|
15
|
+
"@checkmate-monitor/common": "workspace:*",
|
|
16
|
+
"@orpc/client": "^1.13.2"
|
|
17
|
+
},
|
|
18
|
+
"devDependencies": {
|
|
19
|
+
"@types/bun": "^1.3.5",
|
|
20
|
+
"@types/react": "^18.0.0",
|
|
21
|
+
"typescript": "^5.0.0",
|
|
22
|
+
"@checkmate-monitor/tsconfig": "workspace:*",
|
|
23
|
+
"@checkmate-monitor/scripts": "workspace:*"
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import React, { createContext, useContext, ReactNode } from "react";
|
|
2
|
+
import { ApiRef } from "./api-ref";
|
|
3
|
+
|
|
4
|
+
export type ApiRegistry = Map<string, unknown>;
|
|
5
|
+
|
|
6
|
+
const ApiContext = createContext<ApiRegistry | undefined>(undefined);
|
|
7
|
+
|
|
8
|
+
export class ApiRegistryBuilder {
|
|
9
|
+
private registry: ApiRegistry = new Map();
|
|
10
|
+
|
|
11
|
+
register<T>(ref: ApiRef<T>, impl: T) {
|
|
12
|
+
this.registry.set(ref.id, impl);
|
|
13
|
+
return this;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
registerFactory<T>(ref: ApiRef<T>, factory: (registry: ApiRegistry) => T) {
|
|
17
|
+
const impl = factory(this.registry);
|
|
18
|
+
this.registry.set(ref.id, impl);
|
|
19
|
+
return this;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
build() {
|
|
23
|
+
return this.registry;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const ApiProvider = ({
|
|
28
|
+
registry,
|
|
29
|
+
children,
|
|
30
|
+
}: {
|
|
31
|
+
registry: ApiRegistry;
|
|
32
|
+
children: ReactNode;
|
|
33
|
+
}) => {
|
|
34
|
+
return React.createElement(
|
|
35
|
+
ApiContext.Provider,
|
|
36
|
+
{ value: registry },
|
|
37
|
+
children
|
|
38
|
+
);
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export function useApi<T>(ref: ApiRef<T>): T {
|
|
42
|
+
const registry = useContext(ApiContext);
|
|
43
|
+
if (!registry) {
|
|
44
|
+
throw new Error("useApi must be used within an ApiProvider");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const output = registry.get(ref.id);
|
|
48
|
+
if (!output) {
|
|
49
|
+
throw new Error(`No implementation found for API '${ref.id}'`);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return output as T;
|
|
53
|
+
}
|
package/src/api-ref.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { pluginRegistry } from "../plugin-registry";
|
|
2
|
+
import type { SlotContext } from "../plugin";
|
|
3
|
+
import type { SlotDefinition } from "../slots";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Type-safe props for ExtensionSlot.
|
|
7
|
+
* Extracts the context type from the slot definition itself,
|
|
8
|
+
* ensuring the context matches what the slot expects.
|
|
9
|
+
*/
|
|
10
|
+
type ExtensionSlotProps<TSlot extends SlotDefinition<unknown>> =
|
|
11
|
+
SlotContext<TSlot> extends undefined
|
|
12
|
+
? { slot: TSlot; context?: undefined }
|
|
13
|
+
: { slot: TSlot; context: SlotContext<TSlot> };
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Renders all extensions registered for the given slot.
|
|
17
|
+
*
|
|
18
|
+
* @example
|
|
19
|
+
* ```tsx
|
|
20
|
+
* // Slot with context - context is required and type-checked
|
|
21
|
+
* <ExtensionSlot slot={SystemDetailsSlot} context={{ system }} />
|
|
22
|
+
*
|
|
23
|
+
* // Slot without context
|
|
24
|
+
* <ExtensionSlot slot={NavbarSlot} />
|
|
25
|
+
* ```
|
|
26
|
+
*/
|
|
27
|
+
export function ExtensionSlot<TSlot extends SlotDefinition<unknown>>({
|
|
28
|
+
slot,
|
|
29
|
+
context,
|
|
30
|
+
}: ExtensionSlotProps<TSlot>) {
|
|
31
|
+
const extensions = pluginRegistry.getExtensions(slot.id);
|
|
32
|
+
|
|
33
|
+
if (extensions.length === 0) {
|
|
34
|
+
return <></>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return (
|
|
38
|
+
<>
|
|
39
|
+
{extensions.map((ext) => {
|
|
40
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
41
|
+
const Component = ext.component as React.ComponentType<any>;
|
|
42
|
+
return <Component key={ext.id} {...(context ?? {})} />;
|
|
43
|
+
})}
|
|
44
|
+
</>
|
|
45
|
+
);
|
|
46
|
+
}
|
package/src/core-apis.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import {
|
|
2
|
+
PermissionAction,
|
|
3
|
+
ClientDefinition,
|
|
4
|
+
InferClient,
|
|
5
|
+
} from "@checkmate-monitor/common";
|
|
6
|
+
import { createApiRef } from "./api-ref";
|
|
7
|
+
|
|
8
|
+
export interface LoggerApi {
|
|
9
|
+
info(message: string, ...args: unknown[]): void;
|
|
10
|
+
error(message: string, ...args: unknown[]): void;
|
|
11
|
+
warn(message: string, ...args: unknown[]): void;
|
|
12
|
+
debug(message: string, ...args: unknown[]): void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface FetchApi {
|
|
16
|
+
fetch(input: string | URL, init?: RequestInit): Promise<Response>;
|
|
17
|
+
forPlugin(pluginId: string): {
|
|
18
|
+
fetch(path: string, init?: RequestInit): Promise<Response>;
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const loggerApiRef = createApiRef<LoggerApi>("core.logger");
|
|
23
|
+
export const fetchApiRef = createApiRef<FetchApi>("core.fetch");
|
|
24
|
+
|
|
25
|
+
export interface PermissionApi {
|
|
26
|
+
usePermission(permission: string): { loading: boolean; allowed: boolean };
|
|
27
|
+
useResourcePermission(
|
|
28
|
+
resource: string,
|
|
29
|
+
action: PermissionAction
|
|
30
|
+
): { loading: boolean; allowed: boolean };
|
|
31
|
+
useManagePermission(resource: string): { loading: boolean; allowed: boolean };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const permissionApiRef = createApiRef<PermissionApi>("core.permission");
|
|
35
|
+
|
|
36
|
+
export interface RpcApi {
|
|
37
|
+
client: unknown;
|
|
38
|
+
forPlugin<T extends ClientDefinition>(def: T): InferClient<T>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export const rpcApiRef = createApiRef<RpcApi>("core.rpc");
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export * from "./api-ref";
|
|
2
|
+
export * from "./api-context";
|
|
3
|
+
export * from "./core-apis";
|
|
4
|
+
export * from "./plugin";
|
|
5
|
+
export * from "./plugin-registry";
|
|
6
|
+
export * from "./components/ExtensionSlot";
|
|
7
|
+
export * from "./utils";
|
|
8
|
+
export * from "./slots";
|
|
9
|
+
export * from "./use-plugin-route";
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import { FrontendPlugin, Extension } from "./plugin";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Listener function for registry changes
|
|
5
|
+
*/
|
|
6
|
+
type RegistryListener = () => void;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Resolved route information for runtime access.
|
|
10
|
+
*/
|
|
11
|
+
interface ResolvedRoute {
|
|
12
|
+
id: string;
|
|
13
|
+
path: string;
|
|
14
|
+
pluginId: string;
|
|
15
|
+
element?: React.ReactNode;
|
|
16
|
+
title?: string;
|
|
17
|
+
permission?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
class PluginRegistry {
|
|
21
|
+
private plugins: FrontendPlugin[] = [];
|
|
22
|
+
private extensions = new Map<string, Extension[]>();
|
|
23
|
+
private routeMap = new Map<string, ResolvedRoute>();
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Version counter that increments on every registry change.
|
|
27
|
+
* Used by React components to trigger re-renders when plugins are added/removed.
|
|
28
|
+
*/
|
|
29
|
+
private version = 0;
|
|
30
|
+
private listeners: Set<RegistryListener> = new Set();
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Validate and register routes from a plugin.
|
|
34
|
+
*/
|
|
35
|
+
private registerRoutes(plugin: FrontendPlugin) {
|
|
36
|
+
if (!plugin.routes) return;
|
|
37
|
+
|
|
38
|
+
const pluginId = plugin.metadata.pluginId;
|
|
39
|
+
|
|
40
|
+
for (const route of plugin.routes) {
|
|
41
|
+
// Validate that route's pluginId matches the frontend plugin
|
|
42
|
+
if (route.route.pluginId !== pluginId) {
|
|
43
|
+
console.error(
|
|
44
|
+
`❌ Route pluginId mismatch: route "${route.route.id}" has pluginId "${route.route.pluginId}" ` +
|
|
45
|
+
`but plugin is "${pluginId}"`
|
|
46
|
+
);
|
|
47
|
+
throw new Error(
|
|
48
|
+
`Route pluginId "${route.route.pluginId}" doesn't match plugin "${pluginId}"`
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const fullPath = `/${route.route.pluginId}${
|
|
53
|
+
route.route.path.startsWith("/")
|
|
54
|
+
? route.route.path
|
|
55
|
+
: `/${route.route.path}`
|
|
56
|
+
}`;
|
|
57
|
+
|
|
58
|
+
const resolvedRoute: ResolvedRoute = {
|
|
59
|
+
id: route.route.id,
|
|
60
|
+
path: fullPath,
|
|
61
|
+
pluginId: route.route.pluginId,
|
|
62
|
+
element: route.element,
|
|
63
|
+
title: route.title,
|
|
64
|
+
permission: route.permission?.id,
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
// Add to route map for resolution
|
|
68
|
+
this.routeMap.set(route.route.id, resolvedRoute);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Unregister routes from a plugin.
|
|
74
|
+
*/
|
|
75
|
+
private unregisterRoutes(plugin: FrontendPlugin) {
|
|
76
|
+
if (!plugin.routes) return;
|
|
77
|
+
|
|
78
|
+
for (const route of plugin.routes) {
|
|
79
|
+
this.routeMap.delete(route.route.id);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
register(plugin: FrontendPlugin) {
|
|
84
|
+
const pluginId = plugin.metadata.pluginId;
|
|
85
|
+
|
|
86
|
+
// Avoid duplicate registration
|
|
87
|
+
if (this.plugins.some((p) => p.metadata.pluginId === pluginId)) {
|
|
88
|
+
console.warn(`⚠️ Plugin ${pluginId} already registered`);
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
console.log(`🔌 Registering frontend plugin: ${pluginId}`);
|
|
93
|
+
this.plugins.push(plugin);
|
|
94
|
+
|
|
95
|
+
if (plugin.extensions) {
|
|
96
|
+
for (const extension of plugin.extensions) {
|
|
97
|
+
if (!this.extensions.has(extension.slot.id)) {
|
|
98
|
+
this.extensions.set(extension.slot.id, []);
|
|
99
|
+
}
|
|
100
|
+
this.extensions.get(extension.slot.id)!.push(extension);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
this.registerRoutes(plugin);
|
|
105
|
+
this.incrementVersion();
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Unregister a plugin by ID.
|
|
110
|
+
* Removes the plugin and all its extensions from the registry.
|
|
111
|
+
*/
|
|
112
|
+
unregister(pluginId: string): boolean {
|
|
113
|
+
const pluginIndex = this.plugins.findIndex(
|
|
114
|
+
(p) => p.metadata.pluginId === pluginId
|
|
115
|
+
);
|
|
116
|
+
if (pluginIndex === -1) {
|
|
117
|
+
console.warn(`⚠️ Plugin ${pluginId} not found for unregistration`);
|
|
118
|
+
return false;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const plugin = this.plugins[pluginIndex];
|
|
122
|
+
console.log(`🔌 Unregistering frontend plugin: ${pluginId}`);
|
|
123
|
+
|
|
124
|
+
// Remove plugin from list
|
|
125
|
+
this.plugins.splice(pluginIndex, 1);
|
|
126
|
+
|
|
127
|
+
// Remove extensions
|
|
128
|
+
if (plugin.extensions) {
|
|
129
|
+
for (const extension of plugin.extensions) {
|
|
130
|
+
const slotExtensions = this.extensions.get(extension.slot.id);
|
|
131
|
+
if (slotExtensions) {
|
|
132
|
+
const extIndex = slotExtensions.findIndex(
|
|
133
|
+
(e) => e.id === extension.id
|
|
134
|
+
);
|
|
135
|
+
if (extIndex !== -1) {
|
|
136
|
+
slotExtensions.splice(extIndex, 1);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
this.unregisterRoutes(plugin);
|
|
143
|
+
this.incrementVersion();
|
|
144
|
+
return true;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Check if a plugin is registered.
|
|
149
|
+
*/
|
|
150
|
+
hasPlugin(pluginId: string): boolean {
|
|
151
|
+
return this.plugins.some((p) => p.metadata.pluginId === pluginId);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
getPlugins() {
|
|
155
|
+
return this.plugins;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
getExtensions(slotId: string): Extension[] {
|
|
159
|
+
return this.extensions.get(slotId) || [];
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Get all routes for rendering in the router.
|
|
164
|
+
*/
|
|
165
|
+
getAllRoutes() {
|
|
166
|
+
return this.plugins.flatMap((plugin) => {
|
|
167
|
+
return (plugin.routes || []).map((route) => {
|
|
168
|
+
const fullPath = `/${route.route.pluginId}${
|
|
169
|
+
route.route.path.startsWith("/")
|
|
170
|
+
? route.route.path
|
|
171
|
+
: `/${route.route.path}`
|
|
172
|
+
}`;
|
|
173
|
+
|
|
174
|
+
return {
|
|
175
|
+
path: fullPath,
|
|
176
|
+
element: route.element,
|
|
177
|
+
title: route.title,
|
|
178
|
+
permission: route.permission?.id,
|
|
179
|
+
};
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Resolve a route by its ID to get the full path.
|
|
186
|
+
*
|
|
187
|
+
* @param routeId - Route ID in format "{pluginId}.{routeName}"
|
|
188
|
+
* @param params - Optional path parameters to substitute
|
|
189
|
+
* @returns The resolved full path, or undefined if not found
|
|
190
|
+
*/
|
|
191
|
+
resolveRoute(
|
|
192
|
+
routeId: string,
|
|
193
|
+
params?: Record<string, string>
|
|
194
|
+
): string | undefined {
|
|
195
|
+
const route = this.routeMap.get(routeId);
|
|
196
|
+
if (!route) {
|
|
197
|
+
console.warn(`⚠️ Route "${routeId}" not found in registry`);
|
|
198
|
+
return undefined;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (!params) {
|
|
202
|
+
return route.path;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Substitute path parameters
|
|
206
|
+
let result = route.path;
|
|
207
|
+
for (const [key, value] of Object.entries(params)) {
|
|
208
|
+
result = result.replace(`:${key}`, value);
|
|
209
|
+
}
|
|
210
|
+
return result;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Get the current version number.
|
|
215
|
+
* Increments on every register/unregister.
|
|
216
|
+
*/
|
|
217
|
+
getVersion(): number {
|
|
218
|
+
return this.version;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Subscribe to registry changes.
|
|
223
|
+
* Returns an unsubscribe function.
|
|
224
|
+
*/
|
|
225
|
+
subscribe(listener: RegistryListener): () => void {
|
|
226
|
+
this.listeners.add(listener);
|
|
227
|
+
return () => this.listeners.delete(listener);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
private incrementVersion() {
|
|
231
|
+
this.version++;
|
|
232
|
+
for (const listener of this.listeners) {
|
|
233
|
+
listener();
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
reset() {
|
|
238
|
+
this.plugins = [];
|
|
239
|
+
this.extensions.clear();
|
|
240
|
+
this.routeMap.clear();
|
|
241
|
+
this.incrementVersion();
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
export const pluginRegistry = new PluginRegistry();
|
package/src/plugin.ts
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { ApiRef } from "./api-ref";
|
|
3
|
+
import type { SlotDefinition } from "./slots";
|
|
4
|
+
import type {
|
|
5
|
+
RouteDefinition,
|
|
6
|
+
PluginMetadata,
|
|
7
|
+
Permission,
|
|
8
|
+
} from "@checkmate-monitor/common";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Extract the context type from a SlotDefinition
|
|
12
|
+
*/
|
|
13
|
+
export type SlotContext<T> = T extends SlotDefinition<infer C> ? C : never;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Type-safe extension that infers component props from the slot definition.
|
|
17
|
+
*/
|
|
18
|
+
export interface Extension<
|
|
19
|
+
TSlot extends SlotDefinition<unknown> = SlotDefinition<unknown>
|
|
20
|
+
> {
|
|
21
|
+
id: string;
|
|
22
|
+
slot: TSlot;
|
|
23
|
+
component: React.ComponentType<SlotContext<TSlot>>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Helper to create a type-safe extension from a slot definition.
|
|
28
|
+
* This ensures the component props match the slot's expected context.
|
|
29
|
+
*/
|
|
30
|
+
export function createSlotExtension<TSlot extends SlotDefinition<unknown>>(
|
|
31
|
+
slot: TSlot,
|
|
32
|
+
extension: Omit<Extension<TSlot>, "slot">
|
|
33
|
+
): Extension<TSlot> {
|
|
34
|
+
return {
|
|
35
|
+
...extension,
|
|
36
|
+
slot,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Route configuration for a frontend plugin.
|
|
42
|
+
* Uses RouteDefinition from the plugin's common package.
|
|
43
|
+
*/
|
|
44
|
+
export interface PluginRoute {
|
|
45
|
+
/** Route definition from common package */
|
|
46
|
+
route: RouteDefinition;
|
|
47
|
+
|
|
48
|
+
/** React element to render */
|
|
49
|
+
element?: React.ReactNode;
|
|
50
|
+
|
|
51
|
+
/** Page title */
|
|
52
|
+
title?: string;
|
|
53
|
+
|
|
54
|
+
/** Permission required to access this route (use permission object from common package) */
|
|
55
|
+
permission?: Permission;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Frontend plugin configuration.
|
|
60
|
+
* Uses PluginMetadata from the common package for consistent plugin identification.
|
|
61
|
+
*/
|
|
62
|
+
export interface FrontendPlugin {
|
|
63
|
+
/** Plugin metadata from the common package (contains pluginId) */
|
|
64
|
+
metadata: PluginMetadata;
|
|
65
|
+
extensions?: Extension[];
|
|
66
|
+
apis?: {
|
|
67
|
+
ref: ApiRef<unknown>;
|
|
68
|
+
factory: (deps: { get: <T>(ref: ApiRef<T>) => T }) => unknown;
|
|
69
|
+
}[];
|
|
70
|
+
routes?: PluginRoute[];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function createFrontendPlugin(plugin: FrontendPlugin): FrontendPlugin {
|
|
74
|
+
return plugin;
|
|
75
|
+
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { describe, expect, test, beforeEach, afterEach } from "bun:test";
|
|
2
|
+
import { createSlot, SlotDefinition } from "./slots";
|
|
3
|
+
import { pluginRegistry } from "./plugin-registry";
|
|
4
|
+
import { createFrontendPlugin } from "./plugin";
|
|
5
|
+
|
|
6
|
+
describe("createSlot", () => {
|
|
7
|
+
test("creates a slot definition with the correct id", () => {
|
|
8
|
+
const slot = createSlot("test.slot.id");
|
|
9
|
+
expect(slot.id).toBe("test.slot.id");
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
test("creates a slot definition with typed context", () => {
|
|
13
|
+
interface MyContext {
|
|
14
|
+
userId: string;
|
|
15
|
+
label: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const slot = createSlot<MyContext>("typed.slot");
|
|
19
|
+
expect(slot.id).toBe("typed.slot");
|
|
20
|
+
|
|
21
|
+
// Type check: slot._contextType should be assignable to MyContext
|
|
22
|
+
// This is a compile-time check, at runtime _contextType is undefined
|
|
23
|
+
const contextType: MyContext | undefined = slot._contextType;
|
|
24
|
+
expect(contextType).toBeUndefined();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("creates slot with undefined context type by default", () => {
|
|
28
|
+
const slot = createSlot("simple.slot");
|
|
29
|
+
expect(slot.id).toBe("simple.slot");
|
|
30
|
+
expect(slot._contextType).toBeUndefined();
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
describe("Cross-Plugin Slot Usage", () => {
|
|
35
|
+
// Simulate: PluginA exposes a slot from its common package
|
|
36
|
+
const PluginASlot = createSlot<{ message: string }>("plugin-a.custom.slot");
|
|
37
|
+
|
|
38
|
+
// Simulate: PluginB creates an extension for PluginA's slot
|
|
39
|
+
const MockComponent = () => null;
|
|
40
|
+
|
|
41
|
+
beforeEach(() => {
|
|
42
|
+
pluginRegistry.reset();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
afterEach(() => {
|
|
46
|
+
pluginRegistry.reset();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("PluginB can register an extension to PluginA's exported slot", () => {
|
|
50
|
+
// PluginA would typically define its slot in its -common package
|
|
51
|
+
// e.g., export const PluginASlot = createSlot<...>("plugin-a.custom.slot")
|
|
52
|
+
|
|
53
|
+
// PluginB imports and uses the slot:
|
|
54
|
+
const pluginB = createFrontendPlugin({
|
|
55
|
+
metadata: { pluginId: "plugin-b" },
|
|
56
|
+
extensions: [
|
|
57
|
+
{
|
|
58
|
+
id: "plugin-b.extension-for-plugin-a",
|
|
59
|
+
slot: PluginASlot, // Using the SlotDefinition directly
|
|
60
|
+
component: MockComponent,
|
|
61
|
+
},
|
|
62
|
+
],
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
pluginRegistry.register(pluginB);
|
|
66
|
+
|
|
67
|
+
// Verify the extension is registered to the correct slot
|
|
68
|
+
const extensions = pluginRegistry.getExtensions(PluginASlot.id);
|
|
69
|
+
expect(extensions).toHaveLength(1);
|
|
70
|
+
expect(extensions[0].id).toBe("plugin-b.extension-for-plugin-a");
|
|
71
|
+
expect(extensions[0].component).toBe(MockComponent);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("multiple plugins can register extensions to the same slot", () => {
|
|
75
|
+
const MockComponentB = () => null;
|
|
76
|
+
const MockComponentC = () => null;
|
|
77
|
+
|
|
78
|
+
const pluginB = createFrontendPlugin({
|
|
79
|
+
metadata: { pluginId: "plugin-b" },
|
|
80
|
+
extensions: [
|
|
81
|
+
{
|
|
82
|
+
id: "plugin-b.extension",
|
|
83
|
+
slot: PluginASlot,
|
|
84
|
+
component: MockComponentB,
|
|
85
|
+
},
|
|
86
|
+
],
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
const pluginC = createFrontendPlugin({
|
|
90
|
+
metadata: { pluginId: "plugin-c" },
|
|
91
|
+
extensions: [
|
|
92
|
+
{
|
|
93
|
+
id: "plugin-c.extension",
|
|
94
|
+
slot: PluginASlot,
|
|
95
|
+
component: MockComponentC,
|
|
96
|
+
},
|
|
97
|
+
],
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
pluginRegistry.register(pluginB);
|
|
101
|
+
pluginRegistry.register(pluginC);
|
|
102
|
+
|
|
103
|
+
const extensions = pluginRegistry.getExtensions(PluginASlot.id);
|
|
104
|
+
expect(extensions).toHaveLength(2);
|
|
105
|
+
expect(extensions.map((e) => e.id)).toContain("plugin-b.extension");
|
|
106
|
+
expect(extensions.map((e) => e.id)).toContain("plugin-c.extension");
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("extensions are removed when plugin is unregistered", () => {
|
|
110
|
+
const pluginB = createFrontendPlugin({
|
|
111
|
+
metadata: { pluginId: "plugin-b" },
|
|
112
|
+
extensions: [
|
|
113
|
+
{
|
|
114
|
+
id: "plugin-b.extension",
|
|
115
|
+
slot: PluginASlot,
|
|
116
|
+
component: MockComponent,
|
|
117
|
+
},
|
|
118
|
+
],
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
pluginRegistry.register(pluginB);
|
|
122
|
+
expect(pluginRegistry.getExtensions(PluginASlot.id)).toHaveLength(1);
|
|
123
|
+
|
|
124
|
+
pluginRegistry.unregister("plugin-b");
|
|
125
|
+
expect(pluginRegistry.getExtensions(PluginASlot.id)).toHaveLength(0);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("slot id property is readonly and consistent", () => {
|
|
129
|
+
const slot1 = createSlot("consistent.slot");
|
|
130
|
+
const slot2 = createSlot("consistent.slot");
|
|
131
|
+
|
|
132
|
+
// Same id string creates equivalent slot references
|
|
133
|
+
expect(slot1.id).toBe(slot2.id);
|
|
134
|
+
|
|
135
|
+
// Extensions registered to either slot.id will be in the same collection
|
|
136
|
+
const pluginB = createFrontendPlugin({
|
|
137
|
+
metadata: { pluginId: "plugin-b" },
|
|
138
|
+
extensions: [
|
|
139
|
+
{
|
|
140
|
+
id: "plugin-b.ext1",
|
|
141
|
+
slot: slot1,
|
|
142
|
+
component: MockComponent,
|
|
143
|
+
},
|
|
144
|
+
],
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
const pluginC = createFrontendPlugin({
|
|
148
|
+
metadata: { pluginId: "plugin-c" },
|
|
149
|
+
extensions: [
|
|
150
|
+
{
|
|
151
|
+
id: "plugin-c.ext2",
|
|
152
|
+
slot: slot2,
|
|
153
|
+
component: MockComponent,
|
|
154
|
+
},
|
|
155
|
+
],
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
pluginRegistry.register(pluginB);
|
|
159
|
+
pluginRegistry.register(pluginC);
|
|
160
|
+
|
|
161
|
+
// Both extensions should be in the same slot
|
|
162
|
+
expect(pluginRegistry.getExtensions(slot1.id)).toHaveLength(2);
|
|
163
|
+
expect(pluginRegistry.getExtensions(slot2.id)).toHaveLength(2);
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
describe("SlotDefinition type safety", () => {
|
|
168
|
+
test("slot context type is preserved as phantom type", () => {
|
|
169
|
+
interface SystemContext {
|
|
170
|
+
systemId: string;
|
|
171
|
+
systemName: string;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const slot: SlotDefinition<SystemContext> =
|
|
175
|
+
createSlot<SystemContext>("system.details");
|
|
176
|
+
|
|
177
|
+
// The id is accessible
|
|
178
|
+
expect(slot.id).toBe("system.details");
|
|
179
|
+
|
|
180
|
+
// Type assertion to verify the phantom type is correct
|
|
181
|
+
// This is a compile-time check - the actual value is undefined
|
|
182
|
+
type ExtractedContext = NonNullable<typeof slot._contextType>;
|
|
183
|
+
const _typeCheck: ExtractedContext = { systemId: "1", systemName: "Test" };
|
|
184
|
+
expect(_typeCheck).toBeDefined();
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test("createSlot with object context type", () => {
|
|
188
|
+
const slot = createSlot<{ count: number; items: string[] }>("complex.slot");
|
|
189
|
+
expect(slot.id).toBe("complex.slot");
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test("createSlot without context type defaults to undefined", () => {
|
|
193
|
+
const slot = createSlot("no-context.slot");
|
|
194
|
+
|
|
195
|
+
// Type should be SlotDefinition<undefined>
|
|
196
|
+
// When NonNullable is applied to undefined, it becomes never
|
|
197
|
+
// This is just a compile-time type verification
|
|
198
|
+
expect(slot.id).toBe("no-context.slot");
|
|
199
|
+
});
|
|
200
|
+
});
|
package/src/slots.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A type-safe slot definition that can be exported from plugin common packages.
|
|
3
|
+
* The context type parameter defines what props extensions will receive.
|
|
4
|
+
*/
|
|
5
|
+
export interface SlotDefinition<TContext = undefined> {
|
|
6
|
+
/** Unique slot identifier, recommended format: "plugin-name.area.purpose" */
|
|
7
|
+
readonly id: string;
|
|
8
|
+
/** Phantom type for context type inference - do not use directly */
|
|
9
|
+
readonly _contextType?: TContext;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Creates a type-safe slot definition that can be exported from any package.
|
|
14
|
+
*
|
|
15
|
+
* @example
|
|
16
|
+
* // In @checkmate-monitor/catalog-common
|
|
17
|
+
* export const SystemDetailsSlot = createSlot<{ systemId: string }>(
|
|
18
|
+
* "catalog.system.details"
|
|
19
|
+
* );
|
|
20
|
+
*
|
|
21
|
+
* // In your frontend plugin
|
|
22
|
+
* extensions: [{
|
|
23
|
+
* id: "my-plugin.system-details",
|
|
24
|
+
* slot: SystemDetailsSlot,
|
|
25
|
+
* component: MySystemDetailsExtension, // Receives { systemId: string }
|
|
26
|
+
* }]
|
|
27
|
+
*
|
|
28
|
+
* @param id - Unique slot identifier
|
|
29
|
+
* @returns A slot definition that can be used for type-safe extension registration
|
|
30
|
+
*/
|
|
31
|
+
export function createSlot<TContext = undefined>(
|
|
32
|
+
id: string
|
|
33
|
+
): SlotDefinition<TContext> {
|
|
34
|
+
return { id } as SlotDefinition<TContext>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Core layout slots - no context required
|
|
39
|
+
*/
|
|
40
|
+
export const DashboardSlot = createSlot("dashboard");
|
|
41
|
+
export const NavbarSlot = createSlot("core.layout.navbar");
|
|
42
|
+
export const NavbarMainSlot = createSlot("core.layout.navbar.main");
|
|
43
|
+
export const UserMenuItemsSlot = createSlot(
|
|
44
|
+
"core.layout.navbar.user-menu.items"
|
|
45
|
+
);
|
|
46
|
+
export const UserMenuItemsBottomSlot = createSlot(
|
|
47
|
+
"core.layout.navbar.user-menu.items.bottom"
|
|
48
|
+
);
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { useCallback } from "react";
|
|
2
|
+
import type { RouteDefinition } from "@checkmate-monitor/common";
|
|
3
|
+
import { resolveRoute } from "@checkmate-monitor/common";
|
|
4
|
+
import { pluginRegistry } from "./plugin-registry";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* React hook for resolving plugin routes to full paths.
|
|
8
|
+
*
|
|
9
|
+
* Can resolve routes in two ways:
|
|
10
|
+
* 1. Using a RouteDefinition object from a plugin's routes (type-safe, recommended)
|
|
11
|
+
* 2. Using a route ID string (for cross-plugin resolution)
|
|
12
|
+
*
|
|
13
|
+
* @example Using RouteDefinition (recommended)
|
|
14
|
+
* ```tsx
|
|
15
|
+
* import { maintenanceRoutes } from "@checkmate-monitor/maintenance-common";
|
|
16
|
+
*
|
|
17
|
+
* const MyComponent = () => {
|
|
18
|
+
* const getRoute = usePluginRoute();
|
|
19
|
+
*
|
|
20
|
+
* return (
|
|
21
|
+
* <Link to={getRoute(maintenanceRoutes.routes.config)}>Config</Link>
|
|
22
|
+
* );
|
|
23
|
+
* };
|
|
24
|
+
* ```
|
|
25
|
+
*
|
|
26
|
+
* @example With path parameters
|
|
27
|
+
* ```tsx
|
|
28
|
+
* const getRoute = usePluginRoute();
|
|
29
|
+
*
|
|
30
|
+
* // For a route like "/detail/:id"
|
|
31
|
+
* const detailPath = getRoute(maintenanceRoutes.routes.detail, { id: "123" });
|
|
32
|
+
* // Returns: "/maintenance/detail/123"
|
|
33
|
+
* ```
|
|
34
|
+
*
|
|
35
|
+
* @example Using route ID string (cross-plugin)
|
|
36
|
+
* ```tsx
|
|
37
|
+
* const path = getRoute("maintenance.config");
|
|
38
|
+
* ```
|
|
39
|
+
*/
|
|
40
|
+
export function usePluginRoute(): {
|
|
41
|
+
<TParams extends string>(
|
|
42
|
+
route: RouteDefinition<TParams>,
|
|
43
|
+
params?: TParams extends never ? never : Record<TParams, string>
|
|
44
|
+
): string;
|
|
45
|
+
(routeId: string, params?: Record<string, string>): string | undefined;
|
|
46
|
+
} {
|
|
47
|
+
return useCallback(
|
|
48
|
+
(
|
|
49
|
+
routeOrId: RouteDefinition | string,
|
|
50
|
+
params?: Record<string, string>
|
|
51
|
+
): string | undefined => {
|
|
52
|
+
if (typeof routeOrId === "string") {
|
|
53
|
+
// Resolve by route ID through registry
|
|
54
|
+
return pluginRegistry.resolveRoute(routeOrId, params);
|
|
55
|
+
}
|
|
56
|
+
// Resolve using route definition directly
|
|
57
|
+
// Cast needed because overload signatures hide implementation details
|
|
58
|
+
if (params) {
|
|
59
|
+
return resolveRoute(routeOrId as RouteDefinition<string>, params);
|
|
60
|
+
}
|
|
61
|
+
return resolveRoute(routeOrId as RouteDefinition<never>);
|
|
62
|
+
},
|
|
63
|
+
[]
|
|
64
|
+
) as ReturnType<typeof usePluginRoute>;
|
|
65
|
+
}
|
package/src/utils.tsx
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import React, { Suspense, ComponentType } from "react";
|
|
2
|
+
|
|
3
|
+
export function wrapInSuspense<P extends object>(
|
|
4
|
+
Component: ComponentType<P>,
|
|
5
|
+
fallback: React.ReactNode = <div>Loading...</div>
|
|
6
|
+
): React.FC<P> {
|
|
7
|
+
const WrappedComponent: React.FC<P> = (props) => (
|
|
8
|
+
<Suspense fallback={fallback}>
|
|
9
|
+
<Component {...props} />
|
|
10
|
+
</Suspense>
|
|
11
|
+
);
|
|
12
|
+
WrappedComponent.displayName = `Suspense(${
|
|
13
|
+
Component.displayName || Component.name || "Component"
|
|
14
|
+
})`;
|
|
15
|
+
return WrappedComponent;
|
|
16
|
+
}
|