@majkapp/plugin-kit 2.0.1 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/generator/generator.js +3 -1
- package/dist/index.d.ts +9 -12
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +11 -13
- package/dist/plugin-kit.d.ts +3 -6
- package/dist/plugin-kit.d.ts.map +1 -1
- package/dist/plugin-kit.js +842 -44
- package/dist/registry.d.ts +1 -15
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +1 -41
- package/dist/transports.d.ts +59 -0
- package/dist/transports.d.ts.map +1 -0
- package/dist/transports.js +171 -0
- package/dist/types.d.ts +5 -118
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/dist/resource-provider.d.ts +0 -28
- package/dist/resource-provider.d.ts.map +0 -1
- package/dist/resource-provider.js +0 -86
package/dist/plugin-kit.js
CHANGED
|
@@ -4,8 +4,11 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
6
|
exports.definePlugin = definePlugin;
|
|
7
|
+
const http_1 = __importDefault(require("http"));
|
|
7
8
|
const fs_1 = __importDefault(require("fs"));
|
|
9
|
+
const path_1 = __importDefault(require("path"));
|
|
8
10
|
const registry_1 = require("./registry");
|
|
11
|
+
const transports_1 = require("./transports");
|
|
9
12
|
/**
|
|
10
13
|
* Conditional logging - suppressed in extract mode
|
|
11
14
|
*/
|
|
@@ -150,16 +153,332 @@ function validateEntity(entityType, entity) {
|
|
|
150
153
|
}
|
|
151
154
|
return errors;
|
|
152
155
|
}
|
|
156
|
+
/**
|
|
157
|
+
* Convert JSON Schema to OpenAPI Schema
|
|
158
|
+
*/
|
|
159
|
+
function jsonSchemaToOpenApi(schema) {
|
|
160
|
+
// OpenAPI doesn't use the $schema property
|
|
161
|
+
const { ...openApiSchema } = schema;
|
|
162
|
+
return openApiSchema;
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Generate OpenAPI specification from routes
|
|
166
|
+
*/
|
|
167
|
+
function generateOpenApiSpec(pluginId, pluginName, pluginVersion, routes, baseUrl) {
|
|
168
|
+
const paths = {};
|
|
169
|
+
for (const route of routes) {
|
|
170
|
+
const openApiPath = route.path.replace(/:([A-Za-z0-9_]+)/g, '{$1}');
|
|
171
|
+
if (!paths[openApiPath]) {
|
|
172
|
+
paths[openApiPath] = {};
|
|
173
|
+
}
|
|
174
|
+
const operation = {
|
|
175
|
+
operationId: `${pluginId}_${route.name.replace(/\s+/g, '_')}`,
|
|
176
|
+
summary: route.name,
|
|
177
|
+
description: route.description,
|
|
178
|
+
tags: route.tags || [pluginId],
|
|
179
|
+
deprecated: route.deprecated
|
|
180
|
+
};
|
|
181
|
+
// Add parameters from request schema
|
|
182
|
+
if (route.requestSchema) {
|
|
183
|
+
const parameters = [];
|
|
184
|
+
// Path parameters
|
|
185
|
+
if (route.requestSchema.params) {
|
|
186
|
+
const paramProps = route.requestSchema.params.properties || {};
|
|
187
|
+
for (const [name, schema] of Object.entries(paramProps)) {
|
|
188
|
+
parameters.push({
|
|
189
|
+
name,
|
|
190
|
+
in: 'path',
|
|
191
|
+
required: route.requestSchema.params.required?.includes(name) ?? true,
|
|
192
|
+
schema: jsonSchemaToOpenApi(schema),
|
|
193
|
+
description: schema.description
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
// Query parameters
|
|
198
|
+
if (route.requestSchema.query) {
|
|
199
|
+
const queryProps = route.requestSchema.query.properties || {};
|
|
200
|
+
for (const [name, schema] of Object.entries(queryProps)) {
|
|
201
|
+
parameters.push({
|
|
202
|
+
name,
|
|
203
|
+
in: 'query',
|
|
204
|
+
required: route.requestSchema.query.required?.includes(name) ?? false,
|
|
205
|
+
schema: jsonSchemaToOpenApi(schema),
|
|
206
|
+
description: schema.description
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
if (parameters.length > 0) {
|
|
211
|
+
operation.parameters = parameters;
|
|
212
|
+
}
|
|
213
|
+
// Request body
|
|
214
|
+
if (route.requestSchema.body) {
|
|
215
|
+
operation.requestBody = {
|
|
216
|
+
required: true,
|
|
217
|
+
content: {
|
|
218
|
+
'application/json': {
|
|
219
|
+
schema: jsonSchemaToOpenApi(route.requestSchema.body)
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
// Response schema
|
|
226
|
+
operation.responses = {
|
|
227
|
+
'200': {
|
|
228
|
+
description: 'Successful response',
|
|
229
|
+
content: route.responseSchema ? {
|
|
230
|
+
'application/json': {
|
|
231
|
+
schema: jsonSchemaToOpenApi(route.responseSchema)
|
|
232
|
+
}
|
|
233
|
+
} : {}
|
|
234
|
+
},
|
|
235
|
+
'400': {
|
|
236
|
+
description: 'Bad request'
|
|
237
|
+
},
|
|
238
|
+
'500': {
|
|
239
|
+
description: 'Internal server error'
|
|
240
|
+
}
|
|
241
|
+
};
|
|
242
|
+
paths[openApiPath][route.method.toLowerCase()] = operation;
|
|
243
|
+
}
|
|
244
|
+
return {
|
|
245
|
+
openapi: '3.0.0',
|
|
246
|
+
info: {
|
|
247
|
+
title: `${pluginName} API`,
|
|
248
|
+
version: pluginVersion,
|
|
249
|
+
description: `API documentation for the ${pluginName} MAJK plugin`
|
|
250
|
+
},
|
|
251
|
+
servers: [
|
|
252
|
+
{
|
|
253
|
+
url: baseUrl,
|
|
254
|
+
description: 'Plugin API server'
|
|
255
|
+
}
|
|
256
|
+
],
|
|
257
|
+
paths,
|
|
258
|
+
components: {
|
|
259
|
+
schemas: {}
|
|
260
|
+
}
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* Path parameter regex builder
|
|
265
|
+
*/
|
|
266
|
+
function pathToRegex(p) {
|
|
267
|
+
const keys = [];
|
|
268
|
+
log(`[pathToRegex] Input path: ${p}`);
|
|
269
|
+
// First, escape special regex characters EXCEPT the colon (for path params)
|
|
270
|
+
const escaped = p.replace(/([.+*?=^!${}()[\]|\\])/g, '\\$1');
|
|
271
|
+
log(`[pathToRegex] After escaping: ${escaped}`);
|
|
272
|
+
// Then, replace path parameters with capturing groups
|
|
273
|
+
const pattern = escaped.replace(/\/:([A-Za-z0-9_]+)/g, (_m, k) => {
|
|
274
|
+
keys.push(k);
|
|
275
|
+
log(`[pathToRegex] Found param: ${k}`);
|
|
276
|
+
return '/([^/]+)';
|
|
277
|
+
});
|
|
278
|
+
log(`[pathToRegex] Final pattern: ${pattern}`);
|
|
279
|
+
log(`[pathToRegex] Keys: [${keys.join(', ')}]`);
|
|
280
|
+
const regex = new RegExp(`^${pattern}$`);
|
|
281
|
+
log(`[pathToRegex] Final regex: ${regex}`);
|
|
282
|
+
return { regex, keys };
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* CORS headers
|
|
286
|
+
*/
|
|
287
|
+
function corsHeaders(extra = {}) {
|
|
288
|
+
return {
|
|
289
|
+
'Access-Control-Allow-Origin': '*',
|
|
290
|
+
'Access-Control-Allow-Methods': 'GET, POST, PUT, PATCH, DELETE, OPTIONS',
|
|
291
|
+
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
|
|
292
|
+
...extra
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
/**
|
|
296
|
+
* Read request body
|
|
297
|
+
*/
|
|
298
|
+
async function readBody(req) {
|
|
299
|
+
if (req.method === 'GET' || req.method === 'HEAD' || req.method === 'OPTIONS') {
|
|
300
|
+
return undefined;
|
|
301
|
+
}
|
|
302
|
+
return new Promise((resolve) => {
|
|
303
|
+
let data = '';
|
|
304
|
+
req.on('data', (chunk) => (data += chunk.toString()));
|
|
305
|
+
req.on('end', () => {
|
|
306
|
+
if (!data) {
|
|
307
|
+
resolve(undefined);
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
try {
|
|
311
|
+
resolve(JSON.parse(data));
|
|
312
|
+
}
|
|
313
|
+
catch {
|
|
314
|
+
resolve(data);
|
|
315
|
+
}
|
|
316
|
+
});
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
/**
|
|
320
|
+
* Create response wrapper
|
|
321
|
+
*/
|
|
322
|
+
function makeResponse(res) {
|
|
323
|
+
return {
|
|
324
|
+
status(code) {
|
|
325
|
+
res.statusCode = code;
|
|
326
|
+
return this;
|
|
327
|
+
},
|
|
328
|
+
setHeader(key, value) {
|
|
329
|
+
res.setHeader(key, value);
|
|
330
|
+
},
|
|
331
|
+
json(data) {
|
|
332
|
+
if (!res.headersSent) {
|
|
333
|
+
res.writeHead(res.statusCode || 200, corsHeaders({ 'Content-Type': 'application/json' }));
|
|
334
|
+
}
|
|
335
|
+
res.end(JSON.stringify(data));
|
|
336
|
+
},
|
|
337
|
+
send(data) {
|
|
338
|
+
if (!res.headersSent) {
|
|
339
|
+
res.writeHead(res.statusCode || 200, corsHeaders());
|
|
340
|
+
}
|
|
341
|
+
res.end(data);
|
|
342
|
+
}
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
/**
|
|
346
|
+
* Content type helper
|
|
347
|
+
*/
|
|
348
|
+
function getContentType(filePath) {
|
|
349
|
+
const ext = path_1.default.extname(filePath).toLowerCase();
|
|
350
|
+
const types = {
|
|
351
|
+
'.html': 'text/html',
|
|
352
|
+
'.js': 'application/javascript',
|
|
353
|
+
'.css': 'text/css',
|
|
354
|
+
'.json': 'application/json',
|
|
355
|
+
'.png': 'image/png',
|
|
356
|
+
'.jpg': 'image/jpeg',
|
|
357
|
+
'.jpeg': 'image/jpeg',
|
|
358
|
+
'.svg': 'image/svg+xml',
|
|
359
|
+
'.woff': 'font/woff',
|
|
360
|
+
'.woff2': 'font/woff2',
|
|
361
|
+
'.ttf': 'font/ttf'
|
|
362
|
+
};
|
|
363
|
+
return types[ext] || 'application/octet-stream';
|
|
364
|
+
}
|
|
365
|
+
/**
|
|
366
|
+
* Serve React SPA
|
|
367
|
+
*/
|
|
368
|
+
function serveSpa(ui, req, res, ctx) {
|
|
369
|
+
const url = new URL(req.url, 'http://localhost');
|
|
370
|
+
const rel = url.pathname.substring(ui.base.length) || '/';
|
|
371
|
+
const distPath = path_1.default.join(ctx.pluginRoot, ui.appDir);
|
|
372
|
+
let filePath = path_1.default.join(distPath, rel);
|
|
373
|
+
if (filePath.endsWith('/')) {
|
|
374
|
+
filePath = path_1.default.join(filePath, 'index.html');
|
|
375
|
+
}
|
|
376
|
+
// Security: prevent path traversal
|
|
377
|
+
const normalizedPath = path_1.default.resolve(filePath);
|
|
378
|
+
if (!normalizedPath.startsWith(path_1.default.resolve(distPath))) {
|
|
379
|
+
res.writeHead(403, corsHeaders({ 'Content-Type': 'text/plain' }));
|
|
380
|
+
res.end('Forbidden: Path traversal detected');
|
|
381
|
+
ctx.logger.warn(`Path traversal attempt blocked: ${rel}`);
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
const exists = fs_1.default.existsSync(filePath) && fs_1.default.statSync(filePath).isFile();
|
|
385
|
+
const targetFile = exists ? filePath : path_1.default.join(distPath, 'index.html');
|
|
386
|
+
if (!fs_1.default.existsSync(targetFile)) {
|
|
387
|
+
res.writeHead(404, corsHeaders({ 'Content-Type': 'text/plain' }));
|
|
388
|
+
res.end('Not found');
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
let content = fs_1.default.readFileSync(targetFile);
|
|
392
|
+
// Inject base URL for React apps and MCP DOM Agent
|
|
393
|
+
if (path_1.default.basename(targetFile) === 'index.html') {
|
|
394
|
+
const html = content.toString('utf-8');
|
|
395
|
+
// Inject configuration
|
|
396
|
+
const configInject = `<script>` +
|
|
397
|
+
`window.__MAJK_BASE_URL__=${JSON.stringify(ctx.http.baseUrl)};` +
|
|
398
|
+
`window.__MAJK_IFRAME_BASE__=${JSON.stringify(ui.base)};` +
|
|
399
|
+
`window.__MAJK_PLUGIN_ID__=${JSON.stringify(ctx.pluginId)};` +
|
|
400
|
+
`</script>`;
|
|
401
|
+
// Inject html2canvas library (inline from node_modules)
|
|
402
|
+
const html2canvasScript = BuiltPlugin.html2canvasScript
|
|
403
|
+
? `<script>${BuiltPlugin.html2canvasScript}</script>`
|
|
404
|
+
: '';
|
|
405
|
+
// Inject MCP DOM Agent script
|
|
406
|
+
const mcpScript = BuiltPlugin.mcpDomAgentScript
|
|
407
|
+
? `<script>${BuiltPlugin.mcpDomAgentScript}</script>`
|
|
408
|
+
: '';
|
|
409
|
+
// Debug logging
|
|
410
|
+
if (html2canvasScript) {
|
|
411
|
+
ctx.logger.debug(`[React SPA] Injecting html2canvas (${(BuiltPlugin.html2canvasScript?.length || 0) / 1024} KB)`);
|
|
412
|
+
}
|
|
413
|
+
else {
|
|
414
|
+
ctx.logger.warn('[React SPA] html2canvas NOT injected - script not loaded');
|
|
415
|
+
}
|
|
416
|
+
if (mcpScript) {
|
|
417
|
+
ctx.logger.debug('[React SPA] Injecting MCP DOM Agent');
|
|
418
|
+
}
|
|
419
|
+
const allInjections = configInject + html2canvasScript + mcpScript;
|
|
420
|
+
const injected = html.replace('</head>', `${allInjections}</head>`);
|
|
421
|
+
content = Buffer.from(injected, 'utf-8');
|
|
422
|
+
}
|
|
423
|
+
res.writeHead(200, corsHeaders({ 'Content-Type': getContentType(targetFile) }));
|
|
424
|
+
res.end(content);
|
|
425
|
+
}
|
|
426
|
+
/**
|
|
427
|
+
* Load html2canvas library from node_modules
|
|
428
|
+
*/
|
|
429
|
+
function loadHtml2CanvasScript() {
|
|
430
|
+
try {
|
|
431
|
+
// Try multiple possible paths for html2canvas
|
|
432
|
+
const possiblePaths = [
|
|
433
|
+
// When running from dist/ (after build)
|
|
434
|
+
path_1.default.join(__dirname, '..', 'node_modules', 'html2canvas', 'dist', 'html2canvas.min.js'),
|
|
435
|
+
// When running from src/ (development)
|
|
436
|
+
path_1.default.join(__dirname, '..', '..', 'node_modules', 'html2canvas', 'dist', 'html2canvas.min.js'),
|
|
437
|
+
// When installed as a dependency in user's project
|
|
438
|
+
path_1.default.join(__dirname, '..', '..', '..', 'html2canvas', 'dist', 'html2canvas.min.js'),
|
|
439
|
+
];
|
|
440
|
+
for (const scriptPath of possiblePaths) {
|
|
441
|
+
if (fs_1.default.existsSync(scriptPath)) {
|
|
442
|
+
log(`[html2canvas] Loaded from: ${scriptPath}`);
|
|
443
|
+
return fs_1.default.readFileSync(scriptPath, 'utf-8');
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
log('[html2canvas] Script file not found in any expected location');
|
|
447
|
+
return '';
|
|
448
|
+
}
|
|
449
|
+
catch (error) {
|
|
450
|
+
log(`[html2canvas] Failed to load script: ${error.message}`);
|
|
451
|
+
return '';
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
/**
|
|
455
|
+
* Load MCP DOM Agent script
|
|
456
|
+
*/
|
|
457
|
+
function loadMcpDomAgentScript() {
|
|
458
|
+
try {
|
|
459
|
+
const scriptPath = path_1.default.join(__dirname, 'mcp-dom-agent.js');
|
|
460
|
+
if (fs_1.default.existsSync(scriptPath)) {
|
|
461
|
+
return fs_1.default.readFileSync(scriptPath, 'utf-8');
|
|
462
|
+
}
|
|
463
|
+
// Fallback: return minimal inline version if file not found
|
|
464
|
+
log('[MCP DOM Agent] Script file not found, using minimal fallback');
|
|
465
|
+
return `(function(){if(window.__mcpDomAgent)return;window.__mcpDomAgent=true;console.log('[MCP DOM Agent] Minimal fallback loaded');})();`;
|
|
466
|
+
}
|
|
467
|
+
catch (error) {
|
|
468
|
+
log(`[MCP DOM Agent] Failed to load script: ${error.message}`);
|
|
469
|
+
return '';
|
|
470
|
+
}
|
|
471
|
+
}
|
|
153
472
|
/**
|
|
154
473
|
* Main plugin class built by the fluent API
|
|
155
474
|
*/
|
|
156
475
|
class BuiltPlugin {
|
|
157
476
|
constructor(id, name, version, capabilities, tools, apiRoutes, uiConfig, reactScreens, htmlScreens, wizard, settings, onReadyFn, healthCheckFn, userProvidedPluginRoot,
|
|
158
|
-
//
|
|
159
|
-
functionRegistry,
|
|
160
|
-
//
|
|
477
|
+
// New parameters for function-first architecture
|
|
478
|
+
functionRegistry, transports,
|
|
479
|
+
// New parameter for configurable entities
|
|
161
480
|
configurableEntities,
|
|
162
|
-
//
|
|
481
|
+
// New parameter for secret providers
|
|
163
482
|
secretProviders) {
|
|
164
483
|
this.capabilities = capabilities;
|
|
165
484
|
this.tools = tools;
|
|
@@ -175,10 +494,13 @@ class BuiltPlugin {
|
|
|
175
494
|
this.configurableEntities = configurableEntities;
|
|
176
495
|
this.secretProviders = secretProviders;
|
|
177
496
|
this.cleanups = [];
|
|
497
|
+
this.router = [];
|
|
498
|
+
this.transports = [];
|
|
178
499
|
this.id = id;
|
|
179
500
|
this.name = name;
|
|
180
501
|
this.version = version;
|
|
181
502
|
this.functionRegistry = functionRegistry;
|
|
503
|
+
this.transports = transports || [];
|
|
182
504
|
this.configurableEntities = configurableEntities || [];
|
|
183
505
|
this.secretProviders = secretProviders || [];
|
|
184
506
|
}
|
|
@@ -246,19 +568,6 @@ class BuiltPlugin {
|
|
|
246
568
|
}
|
|
247
569
|
return false;
|
|
248
570
|
}
|
|
249
|
-
/**
|
|
250
|
-
* Get function provider (new async Plugin interface)
|
|
251
|
-
* Returns null if plugin has no functions
|
|
252
|
-
*/
|
|
253
|
-
async getFunctions() {
|
|
254
|
-
if (!this.functionRegistry) {
|
|
255
|
-
return null;
|
|
256
|
-
}
|
|
257
|
-
if (!this._functionProvider) {
|
|
258
|
-
this._functionProvider = new registry_1.FunctionProviderImpl(this.functionRegistry, this.context);
|
|
259
|
-
}
|
|
260
|
-
return this._functionProvider;
|
|
261
|
-
}
|
|
262
571
|
async createTool(toolName) {
|
|
263
572
|
const handler = this.tools.get(toolName);
|
|
264
573
|
if (!handler) {
|
|
@@ -296,17 +605,77 @@ class BuiltPlugin {
|
|
|
296
605
|
}
|
|
297
606
|
}
|
|
298
607
|
async onLoad(context) {
|
|
299
|
-
//
|
|
300
|
-
//
|
|
301
|
-
|
|
608
|
+
// Use user-provided pluginRoot (from __dirname) if available, otherwise fall back to context.pluginRoot
|
|
609
|
+
// The user-provided path comes from Node's __dirname which automatically resolves to the real directory
|
|
610
|
+
// (following symlinks), making it work correctly in both local development and npm installed scenarios
|
|
611
|
+
const effectivePluginRoot = this.userProvidedPluginRoot || context.pluginRoot;
|
|
612
|
+
this.context = {
|
|
613
|
+
...context,
|
|
614
|
+
pluginRoot: effectivePluginRoot
|
|
615
|
+
};
|
|
302
616
|
context.logger.info('═══════════════════════════════════════════════════════');
|
|
303
617
|
context.logger.info(`🚀 Loading Plugin: ${this.name} (${this.id}) v${this.version}`);
|
|
304
|
-
context.logger.info(`🔧 Plugin Kit Version: @majkapp/plugin-kit@
|
|
618
|
+
context.logger.info(`🔧 Plugin Kit Version: @majkapp/plugin-kit@1.0.19`);
|
|
619
|
+
context.logger.info(`📍 Plugin Root: ${effectivePluginRoot}`);
|
|
620
|
+
if (this.userProvidedPluginRoot) {
|
|
621
|
+
context.logger.info(` (user-provided via .pluginRoot(__dirname))`);
|
|
622
|
+
}
|
|
623
|
+
else {
|
|
624
|
+
context.logger.info(` (from MAJK context - consider using .pluginRoot(__dirname) for better reliability)`);
|
|
625
|
+
}
|
|
626
|
+
context.logger.info(`🌐 HTTP Port: ${context.http.port}`);
|
|
305
627
|
context.logger.info(`🔗 Base URL: ${context.http.baseUrl}`);
|
|
306
628
|
context.logger.info('═══════════════════════════════════════════════════════');
|
|
629
|
+
// Load html2canvas and MCP DOM Agent scripts once for all plugins
|
|
630
|
+
if (!BuiltPlugin.html2canvasScript) {
|
|
631
|
+
BuiltPlugin.html2canvasScript = loadHtml2CanvasScript();
|
|
632
|
+
if (BuiltPlugin.html2canvasScript) {
|
|
633
|
+
const sizeKB = (BuiltPlugin.html2canvasScript.length / 1024).toFixed(2);
|
|
634
|
+
context.logger.info(`✅ html2canvas library loaded (${sizeKB} KB) - screenshot support enabled`);
|
|
635
|
+
}
|
|
636
|
+
else {
|
|
637
|
+
context.logger.warn('⚠️ html2canvas not found - screenshots will use fallback mode');
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
if (!BuiltPlugin.mcpDomAgentScript) {
|
|
641
|
+
BuiltPlugin.mcpDomAgentScript = loadMcpDomAgentScript();
|
|
642
|
+
if (BuiltPlugin.mcpDomAgentScript) {
|
|
643
|
+
context.logger.info('✅ MCP DOM Agent script loaded - will be injected into all screens');
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
// Validate file paths now that we have pluginRoot context
|
|
647
|
+
if (this.reactScreens.length > 0 && this.uiConfig) {
|
|
648
|
+
const indexPath = path_1.default.join(this.context.pluginRoot, this.uiConfig.appDir || '', 'index.html');
|
|
649
|
+
if (!fs_1.default.existsSync(indexPath)) {
|
|
650
|
+
throw new PluginRuntimeError(`React app not built: ${indexPath} does not exist. Run "npm run build" in your UI directory to build the React app.`, 'onLoad', { appDir: this.uiConfig.appDir, indexPath, pluginRoot: this.context.pluginRoot });
|
|
651
|
+
}
|
|
652
|
+
context.logger.info(`✅ React UI found at: ${indexPath}`);
|
|
653
|
+
}
|
|
654
|
+
// Validate HTML screen files
|
|
655
|
+
for (const screen of this.htmlScreens) {
|
|
656
|
+
if ('htmlFile' in screen) {
|
|
657
|
+
const filePath = path_1.default.join(this.context.pluginRoot, screen.htmlFile);
|
|
658
|
+
if (!fs_1.default.existsSync(filePath)) {
|
|
659
|
+
throw new PluginRuntimeError(`HTML screen file not found: ${screen.htmlFile}. Create the HTML file at ${filePath} or fix the path.`, 'onLoad', { screen: screen.id, file: screen.htmlFile, resolvedPath: filePath, pluginRoot: this.context.pluginRoot });
|
|
660
|
+
}
|
|
661
|
+
context.logger.info(`✅ HTML screen file found: ${screen.htmlFile}`);
|
|
662
|
+
}
|
|
663
|
+
}
|
|
307
664
|
try {
|
|
308
|
-
|
|
665
|
+
await this.startServer();
|
|
666
|
+
// Initialize transports for function-first architecture
|
|
309
667
|
if (this.functionRegistry) {
|
|
668
|
+
context.logger.info('');
|
|
669
|
+
context.logger.info('🔌 Initializing Transports...');
|
|
670
|
+
for (const transport of this.transports) {
|
|
671
|
+
await transport.initialize(this.functionRegistry, this.context);
|
|
672
|
+
await transport.start();
|
|
673
|
+
const metadata = transport.getMetadata();
|
|
674
|
+
context.logger.info(` ✅ ${transport.name} transport initialized`);
|
|
675
|
+
context.logger.info(` • Base Path: ${metadata.endpoint}`);
|
|
676
|
+
context.logger.info(` • Discovery: ${metadata.discovery}`);
|
|
677
|
+
}
|
|
678
|
+
// Log registered functions
|
|
310
679
|
const functionNames = this.functionRegistry.getFunctionNames();
|
|
311
680
|
if (functionNames.length > 0) {
|
|
312
681
|
context.logger.info('');
|
|
@@ -322,21 +691,136 @@ class BuiltPlugin {
|
|
|
322
691
|
}
|
|
323
692
|
}
|
|
324
693
|
}
|
|
694
|
+
// Register API routes (legacy)
|
|
695
|
+
for (const route of this.apiRoutes) {
|
|
696
|
+
const { regex, keys } = pathToRegex(route.path);
|
|
697
|
+
this.router.push({
|
|
698
|
+
method: route.method,
|
|
699
|
+
regex,
|
|
700
|
+
keys,
|
|
701
|
+
name: route.name,
|
|
702
|
+
description: route.description,
|
|
703
|
+
handler: route.handler,
|
|
704
|
+
requestSchema: route.requestSchema,
|
|
705
|
+
responseSchema: route.responseSchema,
|
|
706
|
+
tags: route.tags,
|
|
707
|
+
deprecated: route.deprecated
|
|
708
|
+
});
|
|
709
|
+
context.logger.info(`📝 Registered route: ${route.method} ${route.path} - ${route.name}`);
|
|
710
|
+
}
|
|
711
|
+
// Register the discovery endpoint at /majk/plugin/api
|
|
712
|
+
const discoveryPath = '/majk/plugin/api';
|
|
713
|
+
const { regex: discoveryRegex, keys: discoveryKeys } = pathToRegex(discoveryPath);
|
|
714
|
+
this.router.push({
|
|
715
|
+
method: 'GET',
|
|
716
|
+
regex: discoveryRegex,
|
|
717
|
+
keys: discoveryKeys,
|
|
718
|
+
name: 'API Discovery',
|
|
719
|
+
description: 'Get OpenAPI specification for all plugin API routes',
|
|
720
|
+
handler: async (_req, _res, _ctx) => {
|
|
721
|
+
// If using function-first architecture, generate spec from function registry
|
|
722
|
+
if (this.functionRegistry && this.functionRegistry.getFunctionNames().length > 0) {
|
|
723
|
+
return this.functionRegistry.toOpenAPISpec();
|
|
724
|
+
}
|
|
725
|
+
// Otherwise fall back to legacy API routes
|
|
726
|
+
const openApiSpec = generateOpenApiSpec(this.id, this.name, this.version, this.apiRoutes, this.context.http.baseUrl);
|
|
727
|
+
return openApiSpec;
|
|
728
|
+
}
|
|
729
|
+
});
|
|
730
|
+
context.logger.info(`📝 Registered discovery route: GET ${discoveryPath}`);
|
|
731
|
+
// Also register a simpler JSON format at /majk/plugin/routes for quick access
|
|
732
|
+
const routesPath = '/majk/plugin/routes';
|
|
733
|
+
const { regex: routesRegex, keys: routesKeys } = pathToRegex(routesPath);
|
|
734
|
+
this.router.push({
|
|
735
|
+
method: 'GET',
|
|
736
|
+
regex: routesRegex,
|
|
737
|
+
keys: routesKeys,
|
|
738
|
+
name: 'Routes List',
|
|
739
|
+
description: 'Get a simple JSON list of all available API routes',
|
|
740
|
+
handler: async (_req, _res, _ctx) => {
|
|
741
|
+
return {
|
|
742
|
+
plugin: {
|
|
743
|
+
id: this.id,
|
|
744
|
+
name: this.name,
|
|
745
|
+
version: this.version
|
|
746
|
+
},
|
|
747
|
+
routes: this.apiRoutes.map(route => ({
|
|
748
|
+
method: route.method,
|
|
749
|
+
path: route.path,
|
|
750
|
+
name: route.name,
|
|
751
|
+
description: route.description,
|
|
752
|
+
deprecated: route.deprecated,
|
|
753
|
+
tags: route.tags,
|
|
754
|
+
requestSchema: route.requestSchema,
|
|
755
|
+
responseSchema: route.responseSchema
|
|
756
|
+
})),
|
|
757
|
+
discoveryUrl: `${this.context.http.baseUrl}/majk/plugin/api`
|
|
758
|
+
};
|
|
759
|
+
}
|
|
760
|
+
});
|
|
761
|
+
context.logger.info(`📝 Registered routes list: GET ${routesPath}`);
|
|
325
762
|
// Call onReady hook
|
|
326
763
|
if (this.onReadyFn) {
|
|
327
|
-
context.logger.info('');
|
|
328
764
|
context.logger.info('⚙️ Calling onReady hook...');
|
|
329
765
|
await this.onReadyFn(this.context, (fn) => this.cleanups.push(fn));
|
|
330
766
|
context.logger.info('✅ onReady hook completed');
|
|
331
767
|
}
|
|
332
|
-
// Print summary
|
|
768
|
+
// Print comprehensive summary
|
|
333
769
|
context.logger.info('');
|
|
334
770
|
context.logger.info('╔══════════════════════════════════════════════════════════╗');
|
|
335
771
|
context.logger.info('║ 🎉 PLUGIN LOADED SUCCESSFULLY ║');
|
|
772
|
+
context.logger.info('╟──────────────────────────────────────────────────────────╢');
|
|
773
|
+
context.logger.info('║ 🌐 API ENDPOINTS ║');
|
|
774
|
+
context.logger.info('╟──────────────────────────────────────────────────────────╢');
|
|
775
|
+
// API Discovery endpoints
|
|
776
|
+
context.logger.info(`║ 🔍 Discovery: ║`);
|
|
777
|
+
context.logger.info(`║ ${context.http.baseUrl}/majk/plugin/api`);
|
|
778
|
+
context.logger.info(`║ ${context.http.baseUrl}/majk/plugin/routes`);
|
|
779
|
+
// Function endpoints
|
|
780
|
+
if (this.functionRegistry && this.functionRegistry.getFunctionNames().length > 0) {
|
|
781
|
+
context.logger.info(`║ ║`);
|
|
782
|
+
context.logger.info(`║ 🚀 Function Endpoints: ║`);
|
|
783
|
+
for (const fname of this.functionRegistry.getFunctionNames()) {
|
|
784
|
+
context.logger.info(`║ POST ${context.http.baseUrl}/api/fn/${fname}`);
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
// Legacy API routes
|
|
788
|
+
if (this.apiRoutes.length > 0) {
|
|
789
|
+
context.logger.info(`║ ║`);
|
|
790
|
+
context.logger.info(`║ 📋 Legacy API Routes: ║`);
|
|
791
|
+
for (const route of this.apiRoutes) {
|
|
792
|
+
context.logger.info(`║ ${route.method} ${context.http.baseUrl}${route.path}`);
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
// UI Screens
|
|
796
|
+
const allScreens = [...this.reactScreens, ...this.htmlScreens];
|
|
797
|
+
if (allScreens.length > 0 || this.uiConfig) {
|
|
798
|
+
context.logger.info('╟──────────────────────────────────────────────────────────╢');
|
|
799
|
+
context.logger.info('║ 🕹 UI SCREENS ║');
|
|
800
|
+
context.logger.info('╟──────────────────────────────────────────────────────────╢');
|
|
801
|
+
for (const screen of allScreens) {
|
|
802
|
+
context.logger.info(`║ • ${screen.name || screen.id}`);
|
|
803
|
+
context.logger.info(`║ ${context.http.baseUrl}${screen.route}`);
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
// Config Wizard
|
|
807
|
+
if (this.wizard) {
|
|
808
|
+
context.logger.info('╟──────────────────────────────────────────────────────────╢');
|
|
809
|
+
context.logger.info('║ 🧙 CONFIG WIZARD ║');
|
|
810
|
+
context.logger.info('╟──────────────────────────────────────────────────────────╢');
|
|
811
|
+
context.logger.info(`║ ${this.wizard.title}`);
|
|
812
|
+
context.logger.info(`║ ${context.http.baseUrl}${this.wizard.path}`);
|
|
813
|
+
}
|
|
814
|
+
// Settings
|
|
815
|
+
if (this.settings) {
|
|
816
|
+
context.logger.info('╟──────────────────────────────────────────────────────────╢');
|
|
817
|
+
context.logger.info('║ ⚙️ SETTINGS ║');
|
|
818
|
+
context.logger.info('╟──────────────────────────────────────────────────────────╢');
|
|
819
|
+
context.logger.info(`║ ${this.settings.title}`);
|
|
820
|
+
context.logger.info(`║ ${context.http.baseUrl}${this.settings.path}`);
|
|
821
|
+
}
|
|
336
822
|
context.logger.info('╚══════════════════════════════════════════════════════════╝');
|
|
337
823
|
context.logger.info('');
|
|
338
|
-
context.logger.info('ℹ️ Note: HTTP serving is managed by MAJK, not plugin-kit');
|
|
339
|
-
context.logger.info('');
|
|
340
824
|
}
|
|
341
825
|
catch (error) {
|
|
342
826
|
context.logger.error(`❌ Failed to load plugin "${this.name}": ${error.message}`);
|
|
@@ -357,10 +841,17 @@ class BuiltPlugin {
|
|
|
357
841
|
this.context.logger.error(`Error during cleanup: ${error.message}`);
|
|
358
842
|
}
|
|
359
843
|
}
|
|
844
|
+
// Stop HTTP server
|
|
845
|
+
if (this.server) {
|
|
846
|
+
await new Promise((resolve) => {
|
|
847
|
+
this.server.close(() => {
|
|
848
|
+
this.context.logger.info('✅ HTTP server stopped');
|
|
849
|
+
resolve();
|
|
850
|
+
});
|
|
851
|
+
});
|
|
852
|
+
}
|
|
360
853
|
this.context.logger.info(`✅ Plugin "${this.name}" unloaded`);
|
|
361
854
|
}
|
|
362
|
-
// isHealthy() - implements both old and new interface
|
|
363
|
-
// Returns compatible format for both interfaces
|
|
364
855
|
async isHealthy() {
|
|
365
856
|
if (this.healthCheckFn) {
|
|
366
857
|
try {
|
|
@@ -369,25 +860,325 @@ class BuiltPlugin {
|
|
|
369
860
|
storage: this.context.storage,
|
|
370
861
|
logger: this.context.logger
|
|
371
862
|
});
|
|
372
|
-
return {
|
|
373
|
-
healthy: result.healthy,
|
|
374
|
-
checks: result.details,
|
|
375
|
-
errors: [],
|
|
376
|
-
warnings: []
|
|
377
|
-
};
|
|
863
|
+
return { ...result, errors: [], warnings: [] };
|
|
378
864
|
}
|
|
379
865
|
catch (error) {
|
|
380
866
|
return {
|
|
381
867
|
healthy: false,
|
|
382
|
-
|
|
383
|
-
errors: [`Health check failed: ${error.message}`],
|
|
384
|
-
warnings: []
|
|
868
|
+
errors: [`Health check failed: ${error.message}`]
|
|
385
869
|
};
|
|
386
870
|
}
|
|
387
871
|
}
|
|
388
|
-
return { healthy: true
|
|
872
|
+
return { healthy: true };
|
|
873
|
+
}
|
|
874
|
+
async startServer() {
|
|
875
|
+
const { port } = this.context.http;
|
|
876
|
+
this.server = http_1.default.createServer(async (req, res) => {
|
|
877
|
+
const startTime = Date.now();
|
|
878
|
+
const method = req.method || 'UNKNOWN';
|
|
879
|
+
const url = req.url || '/';
|
|
880
|
+
try {
|
|
881
|
+
// Log incoming request
|
|
882
|
+
this.context.logger.debug(`→ ${method} ${url}`);
|
|
883
|
+
// CORS preflight
|
|
884
|
+
if (req.method === 'OPTIONS') {
|
|
885
|
+
res.writeHead(204, corsHeaders());
|
|
886
|
+
res.end();
|
|
887
|
+
const duration = Date.now() - startTime;
|
|
888
|
+
this.context.logger.debug(`← ${method} ${url} 204 (${duration}ms)`);
|
|
889
|
+
return;
|
|
890
|
+
}
|
|
891
|
+
// Health check
|
|
892
|
+
if (req.method === 'GET' && req.url === '/health') {
|
|
893
|
+
const health = await this.isHealthy();
|
|
894
|
+
res.writeHead(200, corsHeaders({ 'Content-Type': 'application/json' }));
|
|
895
|
+
res.end(JSON.stringify({ ...health, plugin: this.name, version: this.version }));
|
|
896
|
+
const duration = Date.now() - startTime;
|
|
897
|
+
this.context.logger.debug(`← ${method} ${url} 200 (${duration}ms)`);
|
|
898
|
+
return;
|
|
899
|
+
}
|
|
900
|
+
// MCP Screenshot endpoint
|
|
901
|
+
if (req.method === 'POST' && req.url === '/api/mcp/screenshot') {
|
|
902
|
+
try {
|
|
903
|
+
const body = await readBody(req);
|
|
904
|
+
if (!body || !body.data) {
|
|
905
|
+
res.writeHead(400, corsHeaders({ 'Content-Type': 'application/json' }));
|
|
906
|
+
res.end(JSON.stringify({ error: 'Missing screenshot data' }));
|
|
907
|
+
return;
|
|
908
|
+
}
|
|
909
|
+
// Create screenshots directory if it doesn't exist
|
|
910
|
+
const screenshotsDir = path_1.default.join(this.context.pluginRoot, 'screenshots');
|
|
911
|
+
if (!fs_1.default.existsSync(screenshotsDir)) {
|
|
912
|
+
fs_1.default.mkdirSync(screenshotsDir, { recursive: true });
|
|
913
|
+
}
|
|
914
|
+
// Generate filename
|
|
915
|
+
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
916
|
+
const metadata = body.metadata || {};
|
|
917
|
+
const customName = metadata.filename || `screenshot-${timestamp}`;
|
|
918
|
+
const filename = `${customName}.png`;
|
|
919
|
+
const filepath = path_1.default.join(screenshotsDir, filename);
|
|
920
|
+
// Extract base64 data (remove data:image/png;base64, prefix if present)
|
|
921
|
+
const base64Data = body.data.replace(/^data:image\/\w+;base64,/, '');
|
|
922
|
+
const buffer = Buffer.from(base64Data, 'base64');
|
|
923
|
+
// Write file
|
|
924
|
+
fs_1.default.writeFileSync(filepath, buffer);
|
|
925
|
+
// Also save metadata
|
|
926
|
+
const metadataPath = path_1.default.join(screenshotsDir, `${customName}.json`);
|
|
927
|
+
fs_1.default.writeFileSync(metadataPath, JSON.stringify({
|
|
928
|
+
...metadata,
|
|
929
|
+
filename,
|
|
930
|
+
filepath,
|
|
931
|
+
timestamp: new Date().toISOString(),
|
|
932
|
+
size: buffer.length
|
|
933
|
+
}, null, 2));
|
|
934
|
+
// Log with full details
|
|
935
|
+
const absolutePath = path_1.default.resolve(filepath);
|
|
936
|
+
const absoluteMetadataPath = path_1.default.resolve(metadataPath);
|
|
937
|
+
this.context.logger.info(`📸 Screenshot saved successfully:`);
|
|
938
|
+
this.context.logger.info(` Image: ${absolutePath}`);
|
|
939
|
+
this.context.logger.info(` Metadata: ${absoluteMetadataPath}`);
|
|
940
|
+
this.context.logger.info(` Size: ${(buffer.length / 1024).toFixed(2)} KB`);
|
|
941
|
+
res.writeHead(200, corsHeaders({ 'Content-Type': 'application/json' }));
|
|
942
|
+
res.end(JSON.stringify({
|
|
943
|
+
success: true,
|
|
944
|
+
filename,
|
|
945
|
+
filepath,
|
|
946
|
+
size: buffer.length,
|
|
947
|
+
message: 'Screenshot saved successfully'
|
|
948
|
+
}));
|
|
949
|
+
const duration = Date.now() - startTime;
|
|
950
|
+
this.context.logger.debug(`← ${method} ${url} 200 (${duration}ms)`);
|
|
951
|
+
return;
|
|
952
|
+
}
|
|
953
|
+
catch (error) {
|
|
954
|
+
this.context.logger.error(`Failed to save screenshot: ${error.message}`);
|
|
955
|
+
res.writeHead(500, corsHeaders({ 'Content-Type': 'application/json' }));
|
|
956
|
+
res.end(JSON.stringify({
|
|
957
|
+
success: false,
|
|
958
|
+
error: error.message || 'Failed to save screenshot'
|
|
959
|
+
}));
|
|
960
|
+
return;
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
// HTML screens virtual route
|
|
964
|
+
if (req.method === 'GET' && req.url?.startsWith('/__html/')) {
|
|
965
|
+
const id = decodeURIComponent(req.url.substring('/__html/'.length)).split('?')[0];
|
|
966
|
+
const screen = this.htmlScreens.find(s => s.id === id);
|
|
967
|
+
if (!screen) {
|
|
968
|
+
res.writeHead(404, corsHeaders({ 'Content-Type': 'text/plain' }));
|
|
969
|
+
res.end('HTML screen not found');
|
|
970
|
+
const duration = Date.now() - startTime;
|
|
971
|
+
this.context.logger.debug(`← ${method} ${url} 404 (${duration}ms)`);
|
|
972
|
+
return;
|
|
973
|
+
}
|
|
974
|
+
res.writeHead(200, corsHeaders({ 'Content-Type': 'text/html; charset=utf-8' }));
|
|
975
|
+
let htmlContent;
|
|
976
|
+
if ('html' in screen) {
|
|
977
|
+
htmlContent = screen.html;
|
|
978
|
+
}
|
|
979
|
+
else {
|
|
980
|
+
const filePath = path_1.default.join(this.context.pluginRoot, screen.htmlFile);
|
|
981
|
+
// Security check
|
|
982
|
+
const normalized = path_1.default.resolve(filePath);
|
|
983
|
+
const pluginRoot = path_1.default.resolve(this.context.pluginRoot);
|
|
984
|
+
if (!normalized.startsWith(pluginRoot)) {
|
|
985
|
+
res.writeHead(403, corsHeaders({ 'Content-Type': 'text/plain' }));
|
|
986
|
+
res.end('Forbidden');
|
|
987
|
+
this.context.logger.warn(`Path traversal attempt: ${screen.htmlFile}`);
|
|
988
|
+
const duration = Date.now() - startTime;
|
|
989
|
+
this.context.logger.debug(`← ${method} ${url} 403 (${duration}ms)`);
|
|
990
|
+
return;
|
|
991
|
+
}
|
|
992
|
+
try {
|
|
993
|
+
htmlContent = fs_1.default.readFileSync(filePath, 'utf-8');
|
|
994
|
+
}
|
|
995
|
+
catch (error) {
|
|
996
|
+
this.context.logger.error(`Failed to read HTML file: ${error.message}`);
|
|
997
|
+
res.writeHead(500, corsHeaders({ 'Content-Type': 'text/html' }));
|
|
998
|
+
res.end('<!doctype html><html><body><h1>Error loading screen</h1></body></html>');
|
|
999
|
+
const duration = Date.now() - startTime;
|
|
1000
|
+
this.context.logger.debug(`← ${method} ${url} 500 (${duration}ms)`);
|
|
1001
|
+
return;
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
// Inject html2canvas and MCP DOM Agent script into HTML screens
|
|
1005
|
+
if (BuiltPlugin.mcpDomAgentScript) {
|
|
1006
|
+
const html2canvasScript = BuiltPlugin.html2canvasScript
|
|
1007
|
+
? `<script>${BuiltPlugin.html2canvasScript}</script>`
|
|
1008
|
+
: '';
|
|
1009
|
+
const mcpScript = `<script>${BuiltPlugin.mcpDomAgentScript}</script>`;
|
|
1010
|
+
const allScripts = html2canvasScript + mcpScript;
|
|
1011
|
+
// Debug logging
|
|
1012
|
+
if (html2canvasScript) {
|
|
1013
|
+
this.context.logger.debug(`[HTML Screen] Injecting html2canvas (${(BuiltPlugin.html2canvasScript?.length || 0) / 1024} KB)`);
|
|
1014
|
+
}
|
|
1015
|
+
else {
|
|
1016
|
+
this.context.logger.warn('[HTML Screen] html2canvas NOT injected - script not loaded');
|
|
1017
|
+
}
|
|
1018
|
+
this.context.logger.debug('[HTML Screen] Injecting MCP DOM Agent');
|
|
1019
|
+
// Try to inject before </body>, fallback to </head>, fallback to end of HTML
|
|
1020
|
+
if (htmlContent.includes('</body>')) {
|
|
1021
|
+
htmlContent = htmlContent.replace('</body>', `${allScripts}</body>`);
|
|
1022
|
+
}
|
|
1023
|
+
else if (htmlContent.includes('</head>')) {
|
|
1024
|
+
htmlContent = htmlContent.replace('</head>', `${allScripts}</head>`);
|
|
1025
|
+
}
|
|
1026
|
+
else {
|
|
1027
|
+
htmlContent += allScripts;
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
res.end(htmlContent);
|
|
1031
|
+
const duration = Date.now() - startTime;
|
|
1032
|
+
this.context.logger.debug(`← ${method} ${url} 200 (${duration}ms)`);
|
|
1033
|
+
return;
|
|
1034
|
+
}
|
|
1035
|
+
// React SPA
|
|
1036
|
+
if (req.method === 'GET' && this.uiConfig && req.url?.startsWith(this.uiConfig.base)) {
|
|
1037
|
+
serveSpa(this.uiConfig, req, res, this.context);
|
|
1038
|
+
const duration = Date.now() - startTime;
|
|
1039
|
+
this.context.logger.debug(`← ${method} ${url} ${res.statusCode || 200} (${duration}ms)`);
|
|
1040
|
+
return;
|
|
1041
|
+
}
|
|
1042
|
+
// Function calls (new function-first API)
|
|
1043
|
+
if (req.method === 'POST' && req.url?.startsWith('/api/fn/')) {
|
|
1044
|
+
const functionName = req.url.substring('/api/fn/'.length).split('?')[0];
|
|
1045
|
+
if (this.functionRegistry && this.transports.length > 0) {
|
|
1046
|
+
// Use the first HTTP transport to handle the function call
|
|
1047
|
+
const httpTransport = this.transports.find(t => t.name === 'http');
|
|
1048
|
+
if (httpTransport) {
|
|
1049
|
+
const body = await readBody(req);
|
|
1050
|
+
const request = {
|
|
1051
|
+
method: 'POST',
|
|
1052
|
+
url: req.url,
|
|
1053
|
+
pathname: req.url.split('?')[0],
|
|
1054
|
+
query: new URL(req.url, `http://localhost:${port}`).searchParams,
|
|
1055
|
+
params: {},
|
|
1056
|
+
body,
|
|
1057
|
+
headers: req.headers
|
|
1058
|
+
};
|
|
1059
|
+
const response = makeResponse(res);
|
|
1060
|
+
await httpTransport.handleFunctionCall(functionName, request, response, {
|
|
1061
|
+
majk: this.context.majk,
|
|
1062
|
+
storage: this.context.storage,
|
|
1063
|
+
logger: this.context.logger,
|
|
1064
|
+
http: this.context.http
|
|
1065
|
+
});
|
|
1066
|
+
const duration = Date.now() - startTime;
|
|
1067
|
+
this.context.logger.debug(`← POST /api/fn/${functionName} ${res.statusCode || 200} (${duration}ms)`);
|
|
1068
|
+
return;
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
// Function discovery endpoint
|
|
1073
|
+
if (req.method === 'GET' && req.url === '/api/fn/discovery') {
|
|
1074
|
+
if (this.functionRegistry && this.transports.length > 0) {
|
|
1075
|
+
const httpTransport = this.transports.find(t => t.name === 'http');
|
|
1076
|
+
if (httpTransport) {
|
|
1077
|
+
const discovery = httpTransport.getDiscovery();
|
|
1078
|
+
res.writeHead(200, corsHeaders({ 'Content-Type': 'application/json' }));
|
|
1079
|
+
res.end(JSON.stringify(discovery));
|
|
1080
|
+
const duration = Date.now() - startTime;
|
|
1081
|
+
this.context.logger.debug(`← GET /api/fn/discovery 200 (${duration}ms)`);
|
|
1082
|
+
return;
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
}
|
|
1086
|
+
// API routes (including discovery endpoints) - legacy
|
|
1087
|
+
if (req.url?.startsWith('/api/') || req.url?.startsWith('/majk/plugin/')) {
|
|
1088
|
+
const url = new URL(req.url, `http://localhost:${port}`);
|
|
1089
|
+
const pathname = url.pathname;
|
|
1090
|
+
const method = req.method;
|
|
1091
|
+
this.context.logger.info(`[ROUTE MATCHING] Incoming: ${method} ${pathname}`);
|
|
1092
|
+
this.context.logger.info(`[ROUTE MATCHING] Available routes: ${this.router.length}`);
|
|
1093
|
+
this.router.forEach((r, idx) => {
|
|
1094
|
+
const matches = r.regex.test(pathname);
|
|
1095
|
+
this.context.logger.info(`[ROUTE MATCHING] Route ${idx}: ${r.method} ${r.name} - regex: ${r.regex} - keys: [${r.keys.join(', ')}] - matches: ${matches}`);
|
|
1096
|
+
});
|
|
1097
|
+
const route = this.router.find(r => r.method === method && r.regex.test(pathname));
|
|
1098
|
+
if (!route) {
|
|
1099
|
+
res.writeHead(404, corsHeaders({ 'Content-Type': 'application/json' }));
|
|
1100
|
+
res.end(JSON.stringify({
|
|
1101
|
+
error: 'Route not found',
|
|
1102
|
+
path: pathname,
|
|
1103
|
+
method,
|
|
1104
|
+
hint: `Available routes: ${this.router.map(r => `${r.method} ${r.name}`).join(', ')}`
|
|
1105
|
+
}));
|
|
1106
|
+
const duration = Date.now() - startTime;
|
|
1107
|
+
this.context.logger.debug(`← ${method} ${pathname} 404 (${duration}ms)`);
|
|
1108
|
+
return;
|
|
1109
|
+
}
|
|
1110
|
+
const body = await readBody(req);
|
|
1111
|
+
const match = pathname.match(route.regex);
|
|
1112
|
+
const params = {};
|
|
1113
|
+
route.keys.forEach((key, i) => {
|
|
1114
|
+
params[key] = decodeURIComponent(match[i + 1]);
|
|
1115
|
+
});
|
|
1116
|
+
const request = {
|
|
1117
|
+
method,
|
|
1118
|
+
url: req.url,
|
|
1119
|
+
pathname,
|
|
1120
|
+
query: url.searchParams,
|
|
1121
|
+
params,
|
|
1122
|
+
body,
|
|
1123
|
+
headers: req.headers
|
|
1124
|
+
};
|
|
1125
|
+
const response = makeResponse(res);
|
|
1126
|
+
const routeContext = {
|
|
1127
|
+
majk: this.context.majk,
|
|
1128
|
+
storage: this.context.storage,
|
|
1129
|
+
logger: this.context.logger,
|
|
1130
|
+
http: this.context.http
|
|
1131
|
+
};
|
|
1132
|
+
try {
|
|
1133
|
+
const result = await route.handler(request, response, routeContext);
|
|
1134
|
+
if (!res.headersSent) {
|
|
1135
|
+
res.writeHead(200, corsHeaders({ 'Content-Type': 'application/json' }));
|
|
1136
|
+
res.end(JSON.stringify(result ?? { success: true }));
|
|
1137
|
+
}
|
|
1138
|
+
const duration = Date.now() - startTime;
|
|
1139
|
+
this.context.logger.debug(`← ${method} ${pathname} ${res.statusCode || 200} (${duration}ms)`);
|
|
1140
|
+
}
|
|
1141
|
+
catch (error) {
|
|
1142
|
+
this.context.logger.error(`API route error: ${method} ${pathname} - ${error.message}`);
|
|
1143
|
+
if (error.stack) {
|
|
1144
|
+
this.context.logger.error(error.stack);
|
|
1145
|
+
}
|
|
1146
|
+
if (!res.headersSent) {
|
|
1147
|
+
res.writeHead(500, corsHeaders({ 'Content-Type': 'application/json' }));
|
|
1148
|
+
res.end(JSON.stringify({
|
|
1149
|
+
error: error.message || 'Internal server error',
|
|
1150
|
+
route: route.name,
|
|
1151
|
+
path: pathname
|
|
1152
|
+
}));
|
|
1153
|
+
}
|
|
1154
|
+
const duration = Date.now() - startTime;
|
|
1155
|
+
this.context.logger.debug(`← ${method} ${pathname} 500 (${duration}ms)`);
|
|
1156
|
+
}
|
|
1157
|
+
return;
|
|
1158
|
+
}
|
|
1159
|
+
// 404
|
|
1160
|
+
res.writeHead(404, corsHeaders({ 'Content-Type': 'application/json' }));
|
|
1161
|
+
res.end(JSON.stringify({ error: 'Not found', path: req.url }));
|
|
1162
|
+
}
|
|
1163
|
+
catch (error) {
|
|
1164
|
+
this.context.logger.error(`Unhandled server error: ${error.message}`);
|
|
1165
|
+
if (!res.headersSent) {
|
|
1166
|
+
res.writeHead(500, corsHeaders({ 'Content-Type': 'text/plain' }));
|
|
1167
|
+
res.end('Internal server error');
|
|
1168
|
+
}
|
|
1169
|
+
}
|
|
1170
|
+
});
|
|
1171
|
+
await new Promise((resolve) => {
|
|
1172
|
+
this.server.listen(port, () => {
|
|
1173
|
+
this.context.logger.info(`✅ HTTP server listening on port ${port}`);
|
|
1174
|
+
resolve();
|
|
1175
|
+
});
|
|
1176
|
+
});
|
|
389
1177
|
}
|
|
390
1178
|
}
|
|
1179
|
+
// MCP DOM Agent script (loaded once at startup, accessible to serveSpa function)
|
|
1180
|
+
BuiltPlugin.mcpDomAgentScript = null;
|
|
1181
|
+
BuiltPlugin.html2canvasScript = null;
|
|
391
1182
|
/**
|
|
392
1183
|
* Group tools by scope
|
|
393
1184
|
*/
|
|
@@ -420,6 +1211,7 @@ function definePlugin(id, name, version) {
|
|
|
420
1211
|
const _secretProviders = [];
|
|
421
1212
|
// Function-first architecture state
|
|
422
1213
|
const _functionRegistry = new registry_1.FunctionRegistryImpl(id, name, version);
|
|
1214
|
+
const _transports = [];
|
|
423
1215
|
let _clientConfig = null;
|
|
424
1216
|
let _ui = { appDir: 'ui/dist', base: '/', history: 'browser' };
|
|
425
1217
|
let _uiConfigured = false;
|
|
@@ -523,16 +1315,17 @@ function definePlugin(id, name, version) {
|
|
|
523
1315
|
if (!screen.route.startsWith(`/plugin-screens/${id}/`)) {
|
|
524
1316
|
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 });
|
|
525
1317
|
}
|
|
1318
|
+
const iframeUrl = `http://localhost:{port}${_ui.base}${screen.reactPath}`;
|
|
526
1319
|
_reactScreens.push(screen);
|
|
527
1320
|
log(` 🕹 Registered React screen: ${screen.name} at ${screen.route}`);
|
|
528
1321
|
const capability = {
|
|
529
1322
|
type: 'screen',
|
|
530
1323
|
id: screen.id,
|
|
531
1324
|
path: screen.route,
|
|
532
|
-
|
|
1325
|
+
iframeUrl
|
|
533
1326
|
};
|
|
534
1327
|
if (screen.hash) {
|
|
535
|
-
capability.
|
|
1328
|
+
capability.hash = screen.hash;
|
|
536
1329
|
}
|
|
537
1330
|
_capabilities.push(capability);
|
|
538
1331
|
return this;
|
|
@@ -542,13 +1335,14 @@ function definePlugin(id, name, version) {
|
|
|
542
1335
|
if (!screen.route.startsWith(`/plugin-screens/${id}/`)) {
|
|
543
1336
|
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 });
|
|
544
1337
|
}
|
|
1338
|
+
const iframeUrl = `http://localhost:{port}/__html/${encodeURIComponent(screen.id)}`;
|
|
545
1339
|
_htmlScreens.push(screen);
|
|
546
1340
|
log(` 📋 Registered HTML screen: ${screen.name} at ${screen.route}`);
|
|
547
1341
|
_capabilities.push({
|
|
548
1342
|
type: 'screen',
|
|
549
1343
|
id: screen.id,
|
|
550
1344
|
path: screen.route,
|
|
551
|
-
|
|
1345
|
+
iframeUrl
|
|
552
1346
|
});
|
|
553
1347
|
return this;
|
|
554
1348
|
},
|
|
@@ -614,7 +1408,7 @@ function definePlugin(id, name, version) {
|
|
|
614
1408
|
return this;
|
|
615
1409
|
},
|
|
616
1410
|
transport(transport) {
|
|
617
|
-
|
|
1411
|
+
_transports.push(transport);
|
|
618
1412
|
return this;
|
|
619
1413
|
},
|
|
620
1414
|
generateClient(config) {
|
|
@@ -969,6 +1763,10 @@ function definePlugin(id, name, version) {
|
|
|
969
1763
|
// ========== Build Plugin Class ==========
|
|
970
1764
|
// MAJK expects a class/constructor, not an instance
|
|
971
1765
|
// Return a class that instantiates BuiltPlugin
|
|
1766
|
+
// If no transports specified but functions were defined, add default HTTP transport
|
|
1767
|
+
if (_functionRegistry.functions.size > 0 && _transports.length === 0) {
|
|
1768
|
+
_transports.push(new transports_1.HttpTransport());
|
|
1769
|
+
}
|
|
972
1770
|
// TODO: Implement client generation if configured
|
|
973
1771
|
if (_clientConfig) {
|
|
974
1772
|
// This would generate the client files based on the function registry
|
|
@@ -1017,8 +1815,8 @@ function definePlugin(id, name, version) {
|
|
|
1017
1815
|
return class extends BuiltPlugin {
|
|
1018
1816
|
constructor() {
|
|
1019
1817
|
super(id, name, version, _capabilities, _tools, _apiRoutes, _uiConfigured ? _ui : null, _reactScreens, _htmlScreens, _wizard, _settings, _onReady, _healthCheck, _pluginRoot,
|
|
1020
|
-
// Pass function registry if functions were defined
|
|
1021
|
-
_functionRegistry.functions.size > 0 ? _functionRegistry : undefined,
|
|
1818
|
+
// Pass function registry and transports if functions were defined
|
|
1819
|
+
_functionRegistry.functions.size > 0 ? _functionRegistry : undefined, _transports.length > 0 ? _transports : undefined,
|
|
1022
1820
|
// Pass configurable entities
|
|
1023
1821
|
_configurableEntities.length > 0 ? _configurableEntities : undefined,
|
|
1024
1822
|
// Pass secret providers
|