@majkapp/plugin-kit 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +636 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +18 -0
- package/dist/plugin-kit.d.ts +39 -0
- package/dist/plugin-kit.d.ts.map +1 -0
- package/dist/plugin-kit.js +695 -0
- package/dist/types.d.ts +170 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/package.json +37 -0
|
@@ -0,0 +1,695 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.definePlugin = definePlugin;
|
|
7
|
+
const http_1 = __importDefault(require("http"));
|
|
8
|
+
const fs_1 = __importDefault(require("fs"));
|
|
9
|
+
const path_1 = __importDefault(require("path"));
|
|
10
|
+
/**
|
|
11
|
+
* Build-time errors with clear, actionable messages
|
|
12
|
+
*/
|
|
13
|
+
class PluginBuildError extends Error {
|
|
14
|
+
constructor(message, suggestion, context) {
|
|
15
|
+
super(message);
|
|
16
|
+
this.suggestion = suggestion;
|
|
17
|
+
this.context = context;
|
|
18
|
+
this.name = 'PluginBuildError';
|
|
19
|
+
}
|
|
20
|
+
toString() {
|
|
21
|
+
let msg = `\n❌ Plugin Build Failed: ${this.message}`;
|
|
22
|
+
if (this.suggestion) {
|
|
23
|
+
msg += `\n💡 Suggestion: ${this.suggestion}`;
|
|
24
|
+
}
|
|
25
|
+
if (this.context) {
|
|
26
|
+
msg += `\n📋 Context: ${JSON.stringify(this.context, null, 2)}`;
|
|
27
|
+
}
|
|
28
|
+
return msg;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Runtime errors with clear context
|
|
33
|
+
*/
|
|
34
|
+
class PluginRuntimeError extends Error {
|
|
35
|
+
constructor(message, operation, context) {
|
|
36
|
+
super(message);
|
|
37
|
+
this.operation = operation;
|
|
38
|
+
this.context = context;
|
|
39
|
+
this.name = 'PluginRuntimeError';
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Validation helper for descriptions
|
|
44
|
+
*/
|
|
45
|
+
function validateDescription(desc, name) {
|
|
46
|
+
if (!desc)
|
|
47
|
+
return;
|
|
48
|
+
const trimmed = desc.trim();
|
|
49
|
+
if (!trimmed.endsWith('.')) {
|
|
50
|
+
throw new PluginBuildError(`Description for "${name}" must end with a period`, `Add a period at the end: "${desc}."`);
|
|
51
|
+
}
|
|
52
|
+
const sentences = trimmed.split(/[.!?]/).map(s => s.trim()).filter(Boolean);
|
|
53
|
+
if (sentences.length < 2 || sentences.length > 3) {
|
|
54
|
+
throw new PluginBuildError(`Description for "${name}" must be 2-3 sentences, found ${sentences.length} sentences`, `Rewrite the description to have 2-3 clear sentences. Current: "${desc}"`, { sentences });
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Path parameter regex builder
|
|
59
|
+
*/
|
|
60
|
+
function pathToRegex(p) {
|
|
61
|
+
const keys = [];
|
|
62
|
+
const pattern = p
|
|
63
|
+
.replace(/([.+*?=^!:${}()[\]|/\\])/g, '\\$1')
|
|
64
|
+
.replace(/\/:([A-Za-z0-9_]+)/g, (_m, k) => {
|
|
65
|
+
keys.push(k);
|
|
66
|
+
return '/([^/]+)';
|
|
67
|
+
});
|
|
68
|
+
return { regex: new RegExp(`^${pattern}$`), keys };
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* CORS headers
|
|
72
|
+
*/
|
|
73
|
+
function corsHeaders(extra = {}) {
|
|
74
|
+
return {
|
|
75
|
+
'Access-Control-Allow-Origin': '*',
|
|
76
|
+
'Access-Control-Allow-Methods': 'GET, POST, PUT, PATCH, DELETE, OPTIONS',
|
|
77
|
+
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
|
|
78
|
+
...extra
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Read request body
|
|
83
|
+
*/
|
|
84
|
+
async function readBody(req) {
|
|
85
|
+
if (req.method === 'GET' || req.method === 'HEAD' || req.method === 'OPTIONS') {
|
|
86
|
+
return undefined;
|
|
87
|
+
}
|
|
88
|
+
return new Promise((resolve) => {
|
|
89
|
+
let data = '';
|
|
90
|
+
req.on('data', (chunk) => (data += chunk.toString()));
|
|
91
|
+
req.on('end', () => {
|
|
92
|
+
if (!data) {
|
|
93
|
+
resolve(undefined);
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
try {
|
|
97
|
+
resolve(JSON.parse(data));
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
resolve(data);
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Create response wrapper
|
|
107
|
+
*/
|
|
108
|
+
function makeResponse(res) {
|
|
109
|
+
return {
|
|
110
|
+
status(code) {
|
|
111
|
+
res.statusCode = code;
|
|
112
|
+
return this;
|
|
113
|
+
},
|
|
114
|
+
setHeader(key, value) {
|
|
115
|
+
res.setHeader(key, value);
|
|
116
|
+
},
|
|
117
|
+
json(data) {
|
|
118
|
+
if (!res.headersSent) {
|
|
119
|
+
res.writeHead(res.statusCode || 200, corsHeaders({ 'Content-Type': 'application/json' }));
|
|
120
|
+
}
|
|
121
|
+
res.end(JSON.stringify(data));
|
|
122
|
+
},
|
|
123
|
+
send(data) {
|
|
124
|
+
if (!res.headersSent) {
|
|
125
|
+
res.writeHead(res.statusCode || 200, corsHeaders());
|
|
126
|
+
}
|
|
127
|
+
res.end(data);
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Content type helper
|
|
133
|
+
*/
|
|
134
|
+
function getContentType(filePath) {
|
|
135
|
+
const ext = path_1.default.extname(filePath).toLowerCase();
|
|
136
|
+
const types = {
|
|
137
|
+
'.html': 'text/html',
|
|
138
|
+
'.js': 'application/javascript',
|
|
139
|
+
'.css': 'text/css',
|
|
140
|
+
'.json': 'application/json',
|
|
141
|
+
'.png': 'image/png',
|
|
142
|
+
'.jpg': 'image/jpeg',
|
|
143
|
+
'.jpeg': 'image/jpeg',
|
|
144
|
+
'.svg': 'image/svg+xml',
|
|
145
|
+
'.woff': 'font/woff',
|
|
146
|
+
'.woff2': 'font/woff2',
|
|
147
|
+
'.ttf': 'font/ttf'
|
|
148
|
+
};
|
|
149
|
+
return types[ext] || 'application/octet-stream';
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Serve React SPA
|
|
153
|
+
*/
|
|
154
|
+
function serveSpa(ui, req, res, ctx) {
|
|
155
|
+
const url = new URL(req.url, 'http://localhost');
|
|
156
|
+
const rel = url.pathname.substring(ui.base.length) || '/';
|
|
157
|
+
const distPath = path_1.default.join(process.cwd(), ui.appDir);
|
|
158
|
+
let filePath = path_1.default.join(distPath, rel);
|
|
159
|
+
if (filePath.endsWith('/')) {
|
|
160
|
+
filePath = path_1.default.join(filePath, 'index.html');
|
|
161
|
+
}
|
|
162
|
+
// Security: prevent path traversal
|
|
163
|
+
const normalizedPath = path_1.default.resolve(filePath);
|
|
164
|
+
if (!normalizedPath.startsWith(path_1.default.resolve(distPath))) {
|
|
165
|
+
res.writeHead(403, corsHeaders({ 'Content-Type': 'text/plain' }));
|
|
166
|
+
res.end('Forbidden: Path traversal detected');
|
|
167
|
+
ctx.logger.warn(`Path traversal attempt blocked: ${rel}`);
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
const exists = fs_1.default.existsSync(filePath) && fs_1.default.statSync(filePath).isFile();
|
|
171
|
+
const targetFile = exists ? filePath : path_1.default.join(distPath, 'index.html');
|
|
172
|
+
if (!fs_1.default.existsSync(targetFile)) {
|
|
173
|
+
res.writeHead(404, corsHeaders({ 'Content-Type': 'text/plain' }));
|
|
174
|
+
res.end('Not found');
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
let content = fs_1.default.readFileSync(targetFile);
|
|
178
|
+
// Inject base URL for React apps
|
|
179
|
+
if (path_1.default.basename(targetFile) === 'index.html') {
|
|
180
|
+
const html = content.toString('utf-8');
|
|
181
|
+
const inject = `<script>` +
|
|
182
|
+
`window.__MAJK_BASE_URL__=${JSON.stringify(ctx.http.baseUrl)};` +
|
|
183
|
+
`window.__MAJK_IFRAME_BASE__=${JSON.stringify(ui.base)};` +
|
|
184
|
+
`window.__MAJK_PLUGIN_ID__=${JSON.stringify(ctx.pluginId)};` +
|
|
185
|
+
`</script>`;
|
|
186
|
+
const injected = html.replace('</head>', `${inject}</head>`);
|
|
187
|
+
content = Buffer.from(injected, 'utf-8');
|
|
188
|
+
}
|
|
189
|
+
res.writeHead(200, corsHeaders({ 'Content-Type': getContentType(targetFile) }));
|
|
190
|
+
res.end(content);
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* Main plugin class built by the fluent API
|
|
194
|
+
*/
|
|
195
|
+
class BuiltPlugin {
|
|
196
|
+
constructor(id, name, version, capabilities, tools, apiRoutes, uiConfig, reactScreens, htmlScreens, wizard, onReadyFn, healthCheckFn) {
|
|
197
|
+
this.capabilities = capabilities;
|
|
198
|
+
this.tools = tools;
|
|
199
|
+
this.apiRoutes = apiRoutes;
|
|
200
|
+
this.uiConfig = uiConfig;
|
|
201
|
+
this.reactScreens = reactScreens;
|
|
202
|
+
this.htmlScreens = htmlScreens;
|
|
203
|
+
this.wizard = wizard;
|
|
204
|
+
this.onReadyFn = onReadyFn;
|
|
205
|
+
this.healthCheckFn = healthCheckFn;
|
|
206
|
+
this.cleanups = [];
|
|
207
|
+
this.router = [];
|
|
208
|
+
this.id = id;
|
|
209
|
+
this.name = name;
|
|
210
|
+
this.version = version;
|
|
211
|
+
}
|
|
212
|
+
getCapabilities() {
|
|
213
|
+
return {
|
|
214
|
+
apiVersion: 'majk.capabilities/v1',
|
|
215
|
+
capabilities: this.capabilities
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* Config wizard shouldShow hook - called by ConfigWizardCapabilityHandler
|
|
220
|
+
*/
|
|
221
|
+
async shouldShowConfigWizard() {
|
|
222
|
+
if (this.wizard?.shouldShow) {
|
|
223
|
+
return await this.wizard.shouldShow(this.context);
|
|
224
|
+
}
|
|
225
|
+
return false;
|
|
226
|
+
}
|
|
227
|
+
async createTool(toolName) {
|
|
228
|
+
const handler = this.tools.get(toolName);
|
|
229
|
+
if (!handler) {
|
|
230
|
+
throw new PluginRuntimeError(`Tool "${toolName}" not found`, 'createTool', { toolName, availableTools: Array.from(this.tools.keys()) });
|
|
231
|
+
}
|
|
232
|
+
return {
|
|
233
|
+
handler: (input) => {
|
|
234
|
+
const toolContext = {
|
|
235
|
+
majk: this.context.majk,
|
|
236
|
+
storage: this.context.storage,
|
|
237
|
+
logger: this.context.logger
|
|
238
|
+
};
|
|
239
|
+
return Promise.resolve(handler(input, toolContext));
|
|
240
|
+
}
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
async onLoad(context) {
|
|
244
|
+
this.context = context;
|
|
245
|
+
context.logger.info('═══════════════════════════════════════════════════════');
|
|
246
|
+
context.logger.info(`🚀 Loading Plugin: ${this.name} (${this.id}) v${this.version}`);
|
|
247
|
+
context.logger.info(`📍 Plugin Root: ${context.pluginRoot}`);
|
|
248
|
+
context.logger.info(`🌐 HTTP Port: ${context.http.port}`);
|
|
249
|
+
context.logger.info(`🔗 Base URL: ${context.http.baseUrl}`);
|
|
250
|
+
context.logger.info('═══════════════════════════════════════════════════════');
|
|
251
|
+
try {
|
|
252
|
+
await this.startServer();
|
|
253
|
+
// Register API routes
|
|
254
|
+
for (const route of this.apiRoutes) {
|
|
255
|
+
const { regex, keys } = pathToRegex(route.path);
|
|
256
|
+
this.router.push({
|
|
257
|
+
method: route.method,
|
|
258
|
+
regex,
|
|
259
|
+
keys,
|
|
260
|
+
name: route.name,
|
|
261
|
+
description: route.description,
|
|
262
|
+
handler: route.handler
|
|
263
|
+
});
|
|
264
|
+
context.logger.info(`📝 Registered route: ${route.method} ${route.path} - ${route.name}`);
|
|
265
|
+
}
|
|
266
|
+
// Call onReady hook
|
|
267
|
+
if (this.onReadyFn) {
|
|
268
|
+
context.logger.info('⚙️ Calling onReady hook...');
|
|
269
|
+
await this.onReadyFn(this.context, (fn) => this.cleanups.push(fn));
|
|
270
|
+
context.logger.info('✅ onReady hook completed');
|
|
271
|
+
}
|
|
272
|
+
context.logger.info(`✅ Plugin "${this.name}" loaded successfully`);
|
|
273
|
+
}
|
|
274
|
+
catch (error) {
|
|
275
|
+
context.logger.error(`❌ Failed to load plugin "${this.name}": ${error.message}`);
|
|
276
|
+
if (error.stack) {
|
|
277
|
+
context.logger.error(error.stack);
|
|
278
|
+
}
|
|
279
|
+
throw error;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
async onUnload() {
|
|
283
|
+
this.context.logger.info(`🛑 Unloading plugin: ${this.name}`);
|
|
284
|
+
// Run cleanup functions
|
|
285
|
+
for (const cleanup of this.cleanups.splice(0)) {
|
|
286
|
+
try {
|
|
287
|
+
cleanup();
|
|
288
|
+
}
|
|
289
|
+
catch (error) {
|
|
290
|
+
this.context.logger.error(`Error during cleanup: ${error.message}`);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
// Stop HTTP server
|
|
294
|
+
if (this.server) {
|
|
295
|
+
await new Promise((resolve) => {
|
|
296
|
+
this.server.close(() => {
|
|
297
|
+
this.context.logger.info('✅ HTTP server stopped');
|
|
298
|
+
resolve();
|
|
299
|
+
});
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
this.context.logger.info(`✅ Plugin "${this.name}" unloaded`);
|
|
303
|
+
}
|
|
304
|
+
async isHealthy() {
|
|
305
|
+
if (this.healthCheckFn) {
|
|
306
|
+
try {
|
|
307
|
+
const result = await this.healthCheckFn({
|
|
308
|
+
majk: this.context.majk,
|
|
309
|
+
storage: this.context.storage,
|
|
310
|
+
logger: this.context.logger
|
|
311
|
+
});
|
|
312
|
+
return { ...result, errors: [], warnings: [] };
|
|
313
|
+
}
|
|
314
|
+
catch (error) {
|
|
315
|
+
return {
|
|
316
|
+
healthy: false,
|
|
317
|
+
errors: [`Health check failed: ${error.message}`]
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
return { healthy: true };
|
|
322
|
+
}
|
|
323
|
+
async startServer() {
|
|
324
|
+
const { port } = this.context.http;
|
|
325
|
+
this.server = http_1.default.createServer(async (req, res) => {
|
|
326
|
+
try {
|
|
327
|
+
// CORS preflight
|
|
328
|
+
if (req.method === 'OPTIONS') {
|
|
329
|
+
res.writeHead(204, corsHeaders());
|
|
330
|
+
res.end();
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
// Health check
|
|
334
|
+
if (req.method === 'GET' && req.url === '/health') {
|
|
335
|
+
const health = await this.isHealthy();
|
|
336
|
+
res.writeHead(200, corsHeaders({ 'Content-Type': 'application/json' }));
|
|
337
|
+
res.end(JSON.stringify({ ...health, plugin: this.name, version: this.version }));
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
// HTML screens virtual route
|
|
341
|
+
if (req.method === 'GET' && req.url?.startsWith('/__html/')) {
|
|
342
|
+
const id = decodeURIComponent(req.url.substring('/__html/'.length)).split('?')[0];
|
|
343
|
+
const screen = this.htmlScreens.find(s => s.id === id);
|
|
344
|
+
if (!screen) {
|
|
345
|
+
res.writeHead(404, corsHeaders({ 'Content-Type': 'text/plain' }));
|
|
346
|
+
res.end('HTML screen not found');
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
res.writeHead(200, corsHeaders({ 'Content-Type': 'text/html; charset=utf-8' }));
|
|
350
|
+
if ('html' in screen) {
|
|
351
|
+
res.end(screen.html);
|
|
352
|
+
}
|
|
353
|
+
else {
|
|
354
|
+
const filePath = path_1.default.join(process.cwd(), screen.htmlFile);
|
|
355
|
+
// Security check
|
|
356
|
+
const normalized = path_1.default.resolve(filePath);
|
|
357
|
+
if (!normalized.startsWith(process.cwd())) {
|
|
358
|
+
res.writeHead(403, corsHeaders({ 'Content-Type': 'text/plain' }));
|
|
359
|
+
res.end('Forbidden');
|
|
360
|
+
this.context.logger.warn(`Path traversal attempt: ${screen.htmlFile}`);
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
try {
|
|
364
|
+
res.end(fs_1.default.readFileSync(filePath));
|
|
365
|
+
}
|
|
366
|
+
catch (error) {
|
|
367
|
+
this.context.logger.error(`Failed to read HTML file: ${error.message}`);
|
|
368
|
+
res.writeHead(500, corsHeaders({ 'Content-Type': 'text/html' }));
|
|
369
|
+
res.end('<!doctype html><html><body><h1>Error loading screen</h1></body></html>');
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
// React SPA
|
|
375
|
+
if (req.method === 'GET' && this.uiConfig && req.url?.startsWith(this.uiConfig.base)) {
|
|
376
|
+
serveSpa(this.uiConfig, req, res, this.context);
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
// API routes
|
|
380
|
+
if (req.url?.startsWith('/api/')) {
|
|
381
|
+
const url = new URL(req.url, `http://localhost:${port}`);
|
|
382
|
+
const pathname = url.pathname;
|
|
383
|
+
const method = req.method;
|
|
384
|
+
const route = this.router.find(r => r.method === method && r.regex.test(pathname));
|
|
385
|
+
if (!route) {
|
|
386
|
+
res.writeHead(404, corsHeaders({ 'Content-Type': 'application/json' }));
|
|
387
|
+
res.end(JSON.stringify({
|
|
388
|
+
error: 'Route not found',
|
|
389
|
+
path: pathname,
|
|
390
|
+
method,
|
|
391
|
+
hint: `Available routes: ${this.router.map(r => `${r.method} ${r.name}`).join(', ')}`
|
|
392
|
+
}));
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
const body = await readBody(req);
|
|
396
|
+
const match = pathname.match(route.regex);
|
|
397
|
+
const params = {};
|
|
398
|
+
route.keys.forEach((key, i) => {
|
|
399
|
+
params[key] = decodeURIComponent(match[i + 1]);
|
|
400
|
+
});
|
|
401
|
+
const request = {
|
|
402
|
+
method,
|
|
403
|
+
url: req.url,
|
|
404
|
+
pathname,
|
|
405
|
+
query: url.searchParams,
|
|
406
|
+
params,
|
|
407
|
+
body,
|
|
408
|
+
headers: req.headers
|
|
409
|
+
};
|
|
410
|
+
const response = makeResponse(res);
|
|
411
|
+
const routeContext = {
|
|
412
|
+
majk: this.context.majk,
|
|
413
|
+
storage: this.context.storage,
|
|
414
|
+
logger: this.context.logger,
|
|
415
|
+
http: this.context.http
|
|
416
|
+
};
|
|
417
|
+
try {
|
|
418
|
+
this.context.logger.debug(`📥 ${method} ${pathname}`, { params, body });
|
|
419
|
+
const result = await route.handler(request, response, routeContext);
|
|
420
|
+
if (!res.headersSent) {
|
|
421
|
+
res.writeHead(200, corsHeaders({ 'Content-Type': 'application/json' }));
|
|
422
|
+
res.end(JSON.stringify(result ?? { success: true }));
|
|
423
|
+
}
|
|
424
|
+
this.context.logger.debug(`📤 ${method} ${pathname} - Success`);
|
|
425
|
+
}
|
|
426
|
+
catch (error) {
|
|
427
|
+
this.context.logger.error(`❌ ${method} ${pathname} - Error: ${error.message}`);
|
|
428
|
+
if (error.stack) {
|
|
429
|
+
this.context.logger.error(error.stack);
|
|
430
|
+
}
|
|
431
|
+
if (!res.headersSent) {
|
|
432
|
+
res.writeHead(500, corsHeaders({ 'Content-Type': 'application/json' }));
|
|
433
|
+
res.end(JSON.stringify({
|
|
434
|
+
error: error.message || 'Internal server error',
|
|
435
|
+
route: route.name,
|
|
436
|
+
path: pathname
|
|
437
|
+
}));
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
442
|
+
// 404
|
|
443
|
+
res.writeHead(404, corsHeaders({ 'Content-Type': 'application/json' }));
|
|
444
|
+
res.end(JSON.stringify({ error: 'Not found', path: req.url }));
|
|
445
|
+
}
|
|
446
|
+
catch (error) {
|
|
447
|
+
this.context.logger.error(`Unhandled server error: ${error.message}`);
|
|
448
|
+
if (!res.headersSent) {
|
|
449
|
+
res.writeHead(500, corsHeaders({ 'Content-Type': 'text/plain' }));
|
|
450
|
+
res.end('Internal server error');
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
});
|
|
454
|
+
await new Promise((resolve) => {
|
|
455
|
+
this.server.listen(port, () => {
|
|
456
|
+
this.context.logger.info(`✅ HTTP server listening on port ${port}`);
|
|
457
|
+
resolve();
|
|
458
|
+
});
|
|
459
|
+
});
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
/**
|
|
463
|
+
* Group tools by scope
|
|
464
|
+
*/
|
|
465
|
+
function groupByScope(tools) {
|
|
466
|
+
const map = new Map();
|
|
467
|
+
for (const { scope, spec } of tools) {
|
|
468
|
+
const arr = map.get(scope) || [];
|
|
469
|
+
arr.push(spec);
|
|
470
|
+
map.set(scope, arr);
|
|
471
|
+
}
|
|
472
|
+
return Array.from(map, ([scope, tools]) => ({ scope, tools }));
|
|
473
|
+
}
|
|
474
|
+
/**
|
|
475
|
+
* Create a plugin with fluent builder API
|
|
476
|
+
*/
|
|
477
|
+
function definePlugin(id, name, version) {
|
|
478
|
+
const _capabilities = [];
|
|
479
|
+
const _tools = new Map();
|
|
480
|
+
const _toolSpecs = [];
|
|
481
|
+
const _apiRoutes = [];
|
|
482
|
+
const _reactScreens = [];
|
|
483
|
+
const _htmlScreens = [];
|
|
484
|
+
const _topbars = [];
|
|
485
|
+
const _entities = [];
|
|
486
|
+
let _ui = { appDir: 'ui/dist', base: '/', history: 'browser' };
|
|
487
|
+
let _uiConfigured = false;
|
|
488
|
+
let _wizard = null;
|
|
489
|
+
let _settings = null;
|
|
490
|
+
let _onReady = null;
|
|
491
|
+
let _healthCheck = null;
|
|
492
|
+
const builder = {
|
|
493
|
+
topbar(route, opts) {
|
|
494
|
+
_topbars.push({
|
|
495
|
+
id: opts?.id || `${id}-topbar-${_topbars.length + 1}`,
|
|
496
|
+
name: opts?.name || name,
|
|
497
|
+
icon: opts?.icon,
|
|
498
|
+
route
|
|
499
|
+
});
|
|
500
|
+
return this;
|
|
501
|
+
},
|
|
502
|
+
ui(config) {
|
|
503
|
+
_uiConfigured = true;
|
|
504
|
+
if (config?.appDir)
|
|
505
|
+
_ui.appDir = config.appDir;
|
|
506
|
+
if (config?.base)
|
|
507
|
+
_ui.base = config.base;
|
|
508
|
+
if (config?.history)
|
|
509
|
+
_ui.history = config.history;
|
|
510
|
+
return this;
|
|
511
|
+
},
|
|
512
|
+
screenReact(screen) {
|
|
513
|
+
validateDescription(screen.description, `React screen "${screen.name}"`);
|
|
514
|
+
if (!screen.route.startsWith(`/plugin-screens/${id}/`)) {
|
|
515
|
+
throw new PluginBuildError(`React screen route must start with "/plugin-screens/${id}/"`, `Change route from "${screen.route}" to "/plugin-screens/${id}/your-route"`, { screen: screen.id, route: screen.route });
|
|
516
|
+
}
|
|
517
|
+
const iframeUrl = `http://localhost:{port}${_ui.base}${screen.reactPath}`;
|
|
518
|
+
_reactScreens.push(screen);
|
|
519
|
+
_capabilities.push({
|
|
520
|
+
type: 'screen',
|
|
521
|
+
id: screen.id,
|
|
522
|
+
path: screen.route,
|
|
523
|
+
iframeUrl
|
|
524
|
+
});
|
|
525
|
+
return this;
|
|
526
|
+
},
|
|
527
|
+
screenHtml(screen) {
|
|
528
|
+
validateDescription(screen.description, `HTML screen "${screen.name}"`);
|
|
529
|
+
if (!screen.route.startsWith(`/plugin-screens/${id}/`)) {
|
|
530
|
+
throw new PluginBuildError(`HTML screen route must start with "/plugin-screens/${id}/"`, `Change route from "${screen.route}" to "/plugin-screens/${id}/your-route"`, { screen: screen.id, route: screen.route });
|
|
531
|
+
}
|
|
532
|
+
const iframeUrl = `http://localhost:{port}/__html/${encodeURIComponent(screen.id)}`;
|
|
533
|
+
_htmlScreens.push(screen);
|
|
534
|
+
_capabilities.push({
|
|
535
|
+
type: 'screen',
|
|
536
|
+
id: screen.id,
|
|
537
|
+
path: screen.route,
|
|
538
|
+
iframeUrl
|
|
539
|
+
});
|
|
540
|
+
return this;
|
|
541
|
+
},
|
|
542
|
+
apiRoute(route) {
|
|
543
|
+
validateDescription(route.description, `API route "${route.name}"`);
|
|
544
|
+
if (!route.path.startsWith('/api/')) {
|
|
545
|
+
throw new PluginBuildError(`API route path must start with "/api/"`, `Change path from "${route.path}" to "/api/your-endpoint"`, { route: route.name });
|
|
546
|
+
}
|
|
547
|
+
_apiRoutes.push(route);
|
|
548
|
+
return this;
|
|
549
|
+
},
|
|
550
|
+
tool(scope, spec, handler) {
|
|
551
|
+
validateDescription(spec.description, `Tool "${spec.name}"`);
|
|
552
|
+
if (_tools.has(spec.name)) {
|
|
553
|
+
throw new PluginBuildError(`Duplicate tool name: "${spec.name}"`, 'Tool names must be unique within a plugin', { tool: spec.name, existingTools: Array.from(_tools.keys()) });
|
|
554
|
+
}
|
|
555
|
+
_tools.set(spec.name, handler);
|
|
556
|
+
_toolSpecs.push({ scope, spec });
|
|
557
|
+
return this;
|
|
558
|
+
},
|
|
559
|
+
entity(entityType, entities) {
|
|
560
|
+
_entities.push({ entityType, entities });
|
|
561
|
+
return this;
|
|
562
|
+
},
|
|
563
|
+
configWizard(def) {
|
|
564
|
+
if (_wizard) {
|
|
565
|
+
throw new PluginBuildError('Config wizard already defined', 'You can only have one config wizard per plugin');
|
|
566
|
+
}
|
|
567
|
+
validateDescription(def.description, 'Config wizard');
|
|
568
|
+
_wizard = def;
|
|
569
|
+
return this;
|
|
570
|
+
},
|
|
571
|
+
settings(def) {
|
|
572
|
+
if (_settings) {
|
|
573
|
+
throw new PluginBuildError('Settings already defined', 'You can only have one settings screen per plugin');
|
|
574
|
+
}
|
|
575
|
+
validateDescription(def.description, 'Settings');
|
|
576
|
+
_settings = def;
|
|
577
|
+
return this;
|
|
578
|
+
},
|
|
579
|
+
onReady(fn) {
|
|
580
|
+
_onReady = fn;
|
|
581
|
+
return this;
|
|
582
|
+
},
|
|
583
|
+
health(fn) {
|
|
584
|
+
_healthCheck = fn;
|
|
585
|
+
return this;
|
|
586
|
+
},
|
|
587
|
+
build() {
|
|
588
|
+
// ========== Build-Time Validation ==========
|
|
589
|
+
// Validate React UI setup
|
|
590
|
+
if (_reactScreens.length > 0 && !_uiConfigured) {
|
|
591
|
+
throw new PluginBuildError('UI not configured but React screens were added', 'Call .ui() before adding React screens', { reactScreens: _reactScreens.map(s => s.id) });
|
|
592
|
+
}
|
|
593
|
+
// Validate React dist exists
|
|
594
|
+
if (_reactScreens.length > 0) {
|
|
595
|
+
const indexPath = path_1.default.join(process.cwd(), _ui.appDir, 'index.html');
|
|
596
|
+
if (!fs_1.default.existsSync(indexPath)) {
|
|
597
|
+
throw new PluginBuildError(`React app not built: ${indexPath} does not exist`, `Run "npm run build" in your UI directory to build the React app`, { appDir: _ui.appDir, indexPath });
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
// Validate HTML screen files
|
|
601
|
+
for (const screen of _htmlScreens) {
|
|
602
|
+
if ('htmlFile' in screen) {
|
|
603
|
+
const filePath = path_1.default.join(process.cwd(), screen.htmlFile);
|
|
604
|
+
if (!fs_1.default.existsSync(filePath)) {
|
|
605
|
+
throw new PluginBuildError(`HTML screen file not found: ${screen.htmlFile}`, `Create the HTML file at ${filePath} or fix the path`, { screen: screen.id, file: screen.htmlFile });
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
// Validate screen routes
|
|
610
|
+
const screenRoutes = new Set();
|
|
611
|
+
for (const screen of [..._reactScreens, ..._htmlScreens]) {
|
|
612
|
+
if (screenRoutes.has(screen.route)) {
|
|
613
|
+
throw new PluginBuildError(`Duplicate screen route: ${screen.route}`, 'Each screen must have a unique route', { route: screen.route, screens: [..._reactScreens, ..._htmlScreens].map(s => ({ id: s.id, route: s.route })) });
|
|
614
|
+
}
|
|
615
|
+
screenRoutes.add(screen.route);
|
|
616
|
+
}
|
|
617
|
+
// Validate topbar routes
|
|
618
|
+
for (const topbar of _topbars) {
|
|
619
|
+
if (!screenRoutes.has(topbar.route)) {
|
|
620
|
+
throw new PluginBuildError(`Topbar route "${topbar.route}" doesn't match any screen`, 'Topbar routes must point to declared screens', { topbarId: topbar.id, route: topbar.route, availableScreens: Array.from(screenRoutes) });
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
// Validate API route uniqueness
|
|
624
|
+
const apiSignatures = new Set();
|
|
625
|
+
for (const route of _apiRoutes) {
|
|
626
|
+
const signature = `${route.method} ${route.path}`;
|
|
627
|
+
if (apiSignatures.has(signature)) {
|
|
628
|
+
throw new PluginBuildError(`Duplicate API route: ${signature}`, 'Each API route (method + path) must be unique', { route: signature, allRoutes: Array.from(apiSignatures) });
|
|
629
|
+
}
|
|
630
|
+
apiSignatures.add(signature);
|
|
631
|
+
}
|
|
632
|
+
// ========== Build Capabilities ==========
|
|
633
|
+
// Add topbars
|
|
634
|
+
for (const topbar of _topbars) {
|
|
635
|
+
_capabilities.push({
|
|
636
|
+
type: 'topbar',
|
|
637
|
+
id: topbar.id,
|
|
638
|
+
name: topbar.name,
|
|
639
|
+
icon: topbar.icon,
|
|
640
|
+
route: topbar.route
|
|
641
|
+
});
|
|
642
|
+
}
|
|
643
|
+
// Add tools (grouped by scope)
|
|
644
|
+
const toolGroups = groupByScope(_toolSpecs);
|
|
645
|
+
for (const group of toolGroups) {
|
|
646
|
+
_capabilities.push({
|
|
647
|
+
type: 'tool',
|
|
648
|
+
scope: group.scope,
|
|
649
|
+
tools: group.tools
|
|
650
|
+
});
|
|
651
|
+
}
|
|
652
|
+
// Add entities
|
|
653
|
+
for (const entity of _entities) {
|
|
654
|
+
_capabilities.push({
|
|
655
|
+
type: 'entity',
|
|
656
|
+
entityType: entity.entityType,
|
|
657
|
+
entities: entity.entities
|
|
658
|
+
});
|
|
659
|
+
}
|
|
660
|
+
// Add wizard
|
|
661
|
+
if (_wizard) {
|
|
662
|
+
const wizardCap = {
|
|
663
|
+
type: 'config-wizard',
|
|
664
|
+
screen: {
|
|
665
|
+
path: _wizard.path,
|
|
666
|
+
title: _wizard.title,
|
|
667
|
+
width: _wizard.width,
|
|
668
|
+
height: _wizard.height
|
|
669
|
+
},
|
|
670
|
+
required: true
|
|
671
|
+
};
|
|
672
|
+
// Add shouldShow as method name reference if provided
|
|
673
|
+
if (_wizard.shouldShow) {
|
|
674
|
+
wizardCap.shouldShow = 'shouldShowConfigWizard';
|
|
675
|
+
}
|
|
676
|
+
_capabilities.push(wizardCap);
|
|
677
|
+
}
|
|
678
|
+
// Add settings
|
|
679
|
+
if (_settings) {
|
|
680
|
+
_capabilities.push({
|
|
681
|
+
type: 'settings-screen',
|
|
682
|
+
path: `/plugins/${id}/settings`,
|
|
683
|
+
name: _settings.title,
|
|
684
|
+
screen: {
|
|
685
|
+
path: _settings.path,
|
|
686
|
+
title: _settings.title
|
|
687
|
+
}
|
|
688
|
+
});
|
|
689
|
+
}
|
|
690
|
+
// ========== Build Plugin Instance ==========
|
|
691
|
+
return new BuiltPlugin(id, name, version, _capabilities, _tools, _apiRoutes, _uiConfigured ? _ui : null, _reactScreens, _htmlScreens, _wizard, _onReady, _healthCheck);
|
|
692
|
+
}
|
|
693
|
+
};
|
|
694
|
+
return builder;
|
|
695
|
+
}
|