@majkapp/plugin-kit 2.0.0 → 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.
@@ -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
- // Function registry
159
- functionRegistry,
160
- // Configurable entities
477
+ // New parameters for function-first architecture
478
+ functionRegistry, transports,
479
+ // New parameter for configurable entities
161
480
  configurableEntities,
162
- // Secret providers
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
- // NOTE: context.resources is now provided by MAJK (ResourceProvider abstraction)
300
- // Plugin-kit no longer manages HTTP servers or file paths directly
301
- this.context = context;
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@2.0.0`);
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
- // Log registered functions
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
- error: `Health check failed: ${error.message}`,
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, errors: [], warnings: [] };
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;
@@ -616,7 +1408,7 @@ function definePlugin(id, name, version) {
616
1408
  return this;
617
1409
  },
618
1410
  transport(transport) {
619
- log('⚠️ WARNING: .transport() is deprecated in v2.0.0. HTTP serving is now managed by MAJK. This call does nothing.');
1411
+ _transports.push(transport);
620
1412
  return this;
621
1413
  },
622
1414
  generateClient(config) {
@@ -971,6 +1763,10 @@ function definePlugin(id, name, version) {
971
1763
  // ========== Build Plugin Class ==========
972
1764
  // MAJK expects a class/constructor, not an instance
973
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
+ }
974
1770
  // TODO: Implement client generation if configured
975
1771
  if (_clientConfig) {
976
1772
  // This would generate the client files based on the function registry
@@ -1019,8 +1815,8 @@ function definePlugin(id, name, version) {
1019
1815
  return class extends BuiltPlugin {
1020
1816
  constructor() {
1021
1817
  super(id, name, version, _capabilities, _tools, _apiRoutes, _uiConfigured ? _ui : null, _reactScreens, _htmlScreens, _wizard, _settings, _onReady, _healthCheck, _pluginRoot,
1022
- // Pass function registry if functions were defined
1023
- _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,
1024
1820
  // Pass configurable entities
1025
1821
  _configurableEntities.length > 0 ? _configurableEntities : undefined,
1026
1822
  // Pass secret providers