npm package: @majkapp/majk-plugin-server
Description: Lightweight HTTP server for serving MAJK plugins in single-server architecture
Author: Majk Contributors
Latest version: 1.4.0

# @majkapp/majk-plugin-server

Lightweight HTTP server for serving MAJK plugins in single-server architecture.

## Table of Contents

- [Features](#features)
- [Installation](#installation)
- [Quick Start](#quick-start)
  - [Basic Server](#basic-server)
  - [Production-Ready Server](#production-ready-server)
  - [Minimal Server (Development)](#minimal-server-development)
- [API](#api)
  - [Server Methods](#server-methods)
  - [Routes](#routes)
  - [Events](#events)
- [Function Middleware](#function-middleware)
  - [Middleware Interface](#middleware-interface)
  - [Authorization Errors](#authorization-errors)
  - [Middleware Example](#middleware-example)
  - [Middleware Flow](#middleware-flow)
  - [Error Handling](#error-handling)
- [Authentication](#authentication)
  - [Authentication Flow](#authentication-flow)
  - [Security Features](#security-features)
- [CORS Configuration](#cors-configuration)
- [Auto Port Selection](#auto-port-selection)
  - [Getting the Port](#getting-the-port)
- [Cookie Configuration](#cookie-configuration)
- [CLI Tool - Auto Discovery](#cli-tool---auto-discovery)
  - [Installation](#installation-1)
  - [Usage](#usage)
  - [Options](#options)
  - [Plugin Discovery](#plugin-discovery)
  - [Error Handling](#error-handling-1)
  - [Limitations in CLI Mode](#limitations-in-cli-mode)
- [Practical Examples](#practical-examples)
  - [Example 1: Multi-Tenant SaaS Application](#example-1-multi-tenant-saas-application)
  - [Example 2: Development Server with Hot Reload](#example-2-development-server-with-hot-reload)
  - [Example 3: API Gateway with Rate Limiting](#example-3-api-gateway-with-rate-limiting)
  - [Example 4: Microservices Architecture](#example-4-microservices-architecture)
  - [Example 5: Testing & Mock Server](#example-5-testing--mock-server)
- [Client-Side Usage Examples](#client-side-usage-examples)
  - [JavaScript/TypeScript Client](#javascripttypescript-client)
  - [React Hook](#react-hook)
  - [cURL Examples](#curl-examples)
- [Troubleshooting](#troubleshooting)
  - [Common Issues](#common-issues)
- [TypeScript Types](#typescript-types)
- [License](#license)

## Features

- Serves multiple plugins from a single Express server
- Routes: `/plugins/{org}/{id}/ui/*` for static assets, `/plugins/{org}/{id}/api/{functionName}` for function calls
- **Configurable asset path prefix**: Optionally serve assets via custom prefix (e.g., `/plugin-screens/{pluginId}/*`)
- **SPA routing support**: Falls back to `index.html` for non-existent UI routes
- **Runtime plugin management**: Register/unregister plugins without restarting server with full URL information
- **Token-based authentication**: Optional auth with token validation and HTTP-only cookies
- **Function middleware**: Pre/post-processing hooks for function invocations with authorization errors
- **CORS configuration**: CORS disabled by default, opt-in when needed
- **Comprehensive request logging**: Logs ALL requests including 404s with status codes and durations
- **Rich response objects**: `start()` and `registerPlugin()` return complete URL information for each plugin
- Event emission for monitoring and debugging
- TypeScript with full type definitions
- Single-file implementation for simplicity

## Installation

```bash
npm install @majkapp/majk-plugin-server
```

## Quick Start

### Basic Server

```typescript
import { createServer } from '@majkapp/majk-plugin-server';

const server = createServer({
  port: 3000,
  plugins: [
    {
      org: 'myorg',
      id: 'my-plugin',
      distPath: './plugins/my-plugin/dist',
      capabilities: {}
    }
  ],
  functionInvoker: async (org, id, functionName, params) => {
    console.log(`Invoking ${org}/${id}.${functionName}`);
    return { success: true, data: 'result' };
  }
});

const result = await server.start();
console.log(`Server running on ${result.baseUrl}`);
console.log('Plugins:', result.plugins);
```

### Production-Ready Server

Complete example with all features enabled:

```typescript
import {
  createServer,
  ForbiddenError,
  UnauthorizedError,
  InsufficientPermissionsError
} from '@majkapp/majk-plugin-server';
import jwt from 'jsonwebtoken';

const server = createServer({
  // Auto-find free port
  port: 0,

  // Configure plugins
  plugins: [
    {
      org: 'myorg',
      id: 'dashboard',
      distPath: './plugins/dashboard/dist',
      capabilities: { screens: ['main'], functions: ['getData'] }
    },
    {
      org: 'myorg',
      id: 'admin',
      distPath: './plugins/admin/dist',
      capabilities: { screens: ['settings'], functions: ['deleteData'] }
    }
  ],

  // Function invocation handler
  functionInvoker: async (org, id, functionName, params) => {
    // Route to actual plugin implementation
    const plugin = await import(`./plugins/${id}/index.js`);
    return await plugin[functionName](params);
  },

  // Authentication with JWT
  auth: {
    enabled: true,
    tokenValidator: async (token) => {
      try {
        const decoded = jwt.verify(token, process.env.JWT_SECRET);
        return decoded !== null;
      } catch (error) {
        return false;
      }
    },
    cookieHttpOnly: true,
    cookieSameSite: 'strict',
    cookieMaxAge: 24 * 60 * 60 * 1000 // 24 hours
  },

  // Function middleware for authorization
  functionMiddleware: {
    beforeInvoke: async (context) => {
      // Extract user from JWT in Authorization header
      const authHeader = context.request.headers.authorization;
      if (!authHeader?.startsWith('Bearer ')) {
        throw new UnauthorizedError('Missing authorization header');
      }

      const token = authHeader.substring(7);
      const user = jwt.verify(token, process.env.JWT_SECRET);

      // Check role-based permissions
      if (context.functionName.startsWith('delete') && user.role !== 'admin') {
        throw new ForbiddenError('Only admins can delete data');
      }

      // Add user context to params
      return {
        params: { ...context.params, userId: user.id, userRole: user.role },
        metadata: { user, startTime: Date.now() }
      };
    },

    afterInvoke: async (context, result, metadata) => {
      // Log execution time
      const duration = Date.now() - metadata.startTime;
      console.log(`${context.functionName} executed in ${duration}ms`);

      // Filter sensitive data for non-admins
      if (metadata.user.role !== 'admin' && result.data) {
        delete result.data.internalFields;
      }

      return result;
    }
  },

  // Event monitoring
  eventEmitter: async (event) => {
    // Log important events
    if (event.type.includes('error') || event.type.includes('unauthorized')) {
      console.error(`❌ ${event.type}:`, event.data);
    } else if (event.type.includes('success')) {
      console.log(`✅ ${event.type}:`, event.data);
    }

    // Send to monitoring service
    if (process.env.MONITORING_ENABLED) {
      await fetch(process.env.MONITORING_URL, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ timestamp: new Date(), ...event })
      });
    }
  },

  // Request logging
  maxRequestLogSize: 1000,

  // CORS disabled for security
  cors: false
});

// Start server and get complete server information
const result = await server.start();
console.log(`🚀 Server running on ${result.baseUrl}`);
console.log(`   Serving ${result.plugins.length} plugin(s):`);
result.plugins.forEach(p => {
  console.log(`   - ${p.org}/${p.id}: ${p.urls.ui}`);
});

// Runtime plugin management
export async function hotReloadPlugin(org: string, id: string, distPath: string) {
  await server.unregisterPlugin(org, id);
  await server.registerPlugin({ org, id, distPath, capabilities: {} });
  console.log(`Plugin ${org}/${id} reloaded`);
}

// Access request logs
setInterval(() => {
  const logs = server.getRequestLog();
  console.log(`Recent requests: ${logs.length}`);
}, 60000);
```

### Minimal Server (Development)

```typescript
import { createServer } from '@majkapp/majk-plugin-server';

const server = createServer({
  plugins: [
    {
      org: 'dev',
      id: 'test-plugin',
      distPath: './dist',
      capabilities: {}
    }
  ],
  functionInvoker: async (org, id, fn, params) => {
    return { success: true, data: `Called ${fn}` };
  }
});

const result = await server.start();
console.log(`Dev server: ${result.plugins[0].urls.ui}`);
```

## API

### Server Methods

- `start(): Promise<ServerStartResult>` - Start the server and get complete server information
- `registerPlugin(plugin: PluginConfig): Promise<PluginRegistrationResult>` - Register a plugin at runtime and get its URLs
- `unregisterPlugin(org: string, id: string): Promise<void>` - Unregister a plugin at runtime
- `getPlugins(): PluginConfig[]` - Get all registered plugins
- `getPort(): number | undefined` - Get the actual port the server is running on
- `getRequestLog(): RequestLogEntry[]` - Get request log (includes ALL requests, even 404s)

### Routes

- `GET /health` - Server health check
- `GET /api/request-log` - Get request log
- `POST /plugins/{org}/{id}/api/{functionName}` - Invoke plugin function
- `GET /plugins/{org}/{id}/ui/*` - Serve static assets (with SPA fallback to index.html)

### Events

The server emits the following events via the `eventEmitter`:

- `server.start` - Server started
- `plugin.registered` - Plugin registered at runtime
- `plugin.unregistered` - Plugin unregistered at runtime
- `plugin.register.duplicate` - Attempted to register duplicate plugin
- `plugin.unregister.not_found` - Attempted to unregister non-existent plugin
- `plugin.not_found` - Plugin not found for request
- `function.invoke.start` - Function invocation started
- `function.invoke.success` - Function invocation succeeded
- `function.invoke.error` - Function invocation failed
- `static.serve` - Static asset served
- `static.serve.fallback` - Fallback to index.html for SPA routing
- `static.not_found` - Static asset not found
- `static.dist_missing` - Plugin dist directory missing
- `auth.authorization.header.valid` - Bearer token validated successfully
- `auth.authorization.header.invalid` - Bearer token validation failed
- `auth.token.checking` - Validating token from query parameter
- `auth.token.valid` - Token validated successfully, cookie issued
- `auth.token.invalid` - Token validation failed
- `auth.cookie.present` - Request authenticated via cookie
- `auth.missing` - No authentication credentials provided
- `auth.unauthorized` - Unauthorized access attempt blocked
- `function.middleware.before` - Before-invoke middleware executed
- `function.middleware.after` - After-invoke middleware executed
- `function.authorization.error` - Authorization error thrown from middleware

## Function Middleware

The server supports middleware for pre/post-processing function invocations. Middleware runs **after** authentication checks but **before/after** the function invocation.

### Middleware Interface

```typescript
export interface FunctionMiddlewareContext {
  org: string;
  pluginId: string;
  functionName: string;
  params: any;
  request: express.Request;
}

export interface FunctionMiddlewareResult {
  params?: any;  // Modified params to pass to function
  metadata?: any; // Metadata to pass through to afterInvoke
}

export interface FunctionMiddleware {
  // Pre-process: Called before function invocation
  // Can modify params, throw authorization errors, or return modified params
  beforeInvoke?: (context: FunctionMiddlewareContext) => Promise<FunctionMiddlewareResult>;

  // Post-process: Called after function invocation
  // Can modify result, throw errors, or return modified result
  afterInvoke?: (context: FunctionMiddlewareContext, result: any, metadata?: any) => Promise<any>;
}
```

### Authorization Errors

Middleware can throw specialized errors for authorization issues:

```typescript
// Base class
export class AuthorizationError extends Error {
  constructor(message: string, public statusCode: number = 403);
}

// Specific error types
export class ForbiddenError extends AuthorizationError {
  constructor(message: string = 'Forbidden'); // statusCode = 403
}

export class UnauthorizedError extends AuthorizationError {
  constructor(message: string = 'Unauthorized'); // statusCode = 401
}

export class InsufficientPermissionsError extends AuthorizationError {
  constructor(message: string = 'Insufficient permissions'); // statusCode = 403
}
```

### Middleware Example

```typescript
import {
  createServer,
  ForbiddenError,
  UnauthorizedError,
  InsufficientPermissionsError
} from '@majkapp/majk-plugin-server';

const server = createServer({
  plugins: [...],
  functionInvoker: async (org, id, fn, params) => { /* ... */ },

  functionMiddleware: {
    // Pre-process: Add user context and validate permissions
    beforeInvoke: async (context) => {
      // Extract user from request (e.g., JWT token)
      const user = getUserFromRequest(context.request);

      if (!user) {
        throw new UnauthorizedError('Not authenticated');
      }

      // Check permissions
      if (!user.hasPermission(context.functionName)) {
        throw new InsufficientPermissionsError(`Need permission: ${context.functionName}`);
      }

      // Add user context to params
      return {
        params: {
          ...context.params,
          userId: user.id,
          userRole: user.role
        },
        metadata: {
          user,
          requestTime: Date.now()
        }
      };
    },

    // Post-process: Log and filter response
    afterInvoke: async (context, result, metadata) => {
      // Log invocation
      await logFunctionCall({
        user: metadata.user,
        function: context.functionName,
        duration: Date.now() - metadata.requestTime
      });

      // Filter sensitive data based on user role
      if (metadata.user.role !== 'admin') {
        delete result.data?.internalFields;
      }

      return result;
    }
  }
});
```

### Middleware Flow

1. **Authentication**: Token/cookie validation (if auth enabled)
2. **beforeInvoke**: Modify params, validate permissions, add context
3. **Function Invocation**: Call the actual plugin function
4. **afterInvoke**: Modify result, log, filter data
5. **Response**: Return final result to client

### Error Handling

- Authorization errors return appropriate HTTP status codes (401/403)
- Errors are logged and emitted via `function.authorization.error` event
- Other errors return 500 status code

## Authentication

The server supports multiple authentication methods:

1. **Bearer Token Authentication** (recommended for APIs and cross-origin requests)
2. **Cookie-based Authentication** (for browser-based clients)
3. **Query Parameter Token** (for initial authentication)

### Authentication Priority

When a request is received, the server checks authentication in this order:

1. **Authorization Bearer Token** - Checked first, ideal for API clients and CORS scenarios
2. **Cookie** - Checked second, for browser-based sessions
3. **Query Parameter Token** - Checked last, for initial authentication and cookie setup

### Bearer Token Authentication

Perfect for API clients, mobile apps, and cross-origin requests:

```typescript
// Server configuration
const server = createServer({
  cors: true, // Enable CORS for cross-origin requests
  auth: {
    enabled: true,
    tokenValidator: async (token) => {
      // Your validation logic (JWT verify, database lookup, etc.)
      const isValid = await validateToken(token);
      return isValid;
    }
  }
});

// Client usage with Bearer token:
// 1. Add Authorization header to every request:
fetch('/plugins/myorg/my-plugin/ui/', {
  headers: {
    'Authorization': 'Bearer your-token-here'
  }
});

// 2. No cookies are set or required
// 3. Token is validated on every request (stateless)
```

### Cookie-based Authentication Flow

Traditional browser-based authentication with sessions:

```typescript
// Server configuration
const server = createServer({
  auth: {
    enabled: true,
    tokenValidator: async (token) => {
      const isValid = await validateToken(token);
      return isValid;
    },
    cookieName: 'majk_auth',
    cookieMaxAge: 24 * 60 * 60 * 1000 // 24 hours
  }
});

// Client usage:
// 1. First request with token:
//    GET /plugins/myorg/my-plugin/ui/?token=abc123
//    -> Server validates token, sets cookie, returns 200
//
// 2. Subsequent requests (cookie automatically sent):
//    GET /plugins/myorg/my-plugin/ui/dashboard
//    -> Server validates cookie, returns 200
//
// 3. Request without auth:
//    GET /plugins/myorg/my-plugin/ui/
//    -> Server returns 401 Unauthorized
```

### Authentication Examples

**API Client with Bearer Token:**
```typescript
const response = await fetch(
  'http://localhost:3000/plugins/myorg/my-plugin/api/getData',
  {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'Authorization': 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...'
    },
    body: JSON.stringify({ filter: 'active' })
  }
);
```

**Browser with Cookie:**
```typescript
// Initial authentication
window.location.href = '/plugins/myorg/my-plugin/ui/?token=abc123';

// Subsequent requests automatically include cookie
fetch('/plugins/myorg/my-plugin/api/getData', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ filter: 'active' })
});
```

### Security Features

- **Bearer token support**: Stateless authentication for APIs and CORS scenarios
- **HTTP-only cookies**: Not accessible via JavaScript (XSS protection)
- **SameSite strict**: Cookies only sent to same origin (CSRF protection)
- **Configurable expiration**: Control cookie lifetime
- **Token validation**: Custom logic for token verification
- **Priority-based checking**: Bearer tokens checked first for optimal performance

## CORS Configuration

CORS is **disabled by default** for security. Enable only when needed:

```typescript
const server = createServer({
  cors: true, // Enable CORS for all routes
  // ... other config
});
```

## Enhanced Server Response Objects

Starting with v1.2.0, the server returns rich response objects that include complete URL information for easy access to plugins.

### Server Start Response

When you start the server, you get a `ServerStartResult` object:

```typescript
const result = await server.start();
console.log(result);
// {
//   port: 56371,
//   baseUrl: "http://localhost:56371",
//   plugins: [
//     {
//       org: "myorg",
//       id: "my-plugin",
//       distPath: "/path/to/plugin/dist",
//       urls: {
//         ui: "http://localhost:56371/plugins/myorg/my-plugin/ui",
//         api: "http://localhost:56371/plugins/myorg/my-plugin/api",
//         assets: "http://localhost:56371/plugin-screens/my-plugin"  // if assetPathPrefix configured
//       }
//     }
//   ]
// }

// Access specific plugin URLs
const myPlugin = result.plugins.find(p => p.id === 'my-plugin');
console.log(`Open UI at: ${myPlugin.urls.ui}`);
console.log(`API endpoint: ${myPlugin.urls.api}/functionName`);
```

### Plugin Registration Response

When you register a plugin at runtime, you get a `PluginRegistrationResult`:

```typescript
const info = await server.registerPlugin({
  org: 'myorg',
  id: 'new-plugin',
  distPath: './plugins/new-plugin/dist',
  capabilities: {}
});

console.log(info);
// {
//   org: "myorg",
//   id: "new-plugin",
//   distPath: "./plugins/new-plugin/dist",
//   urls: {
//     ui: "http://localhost:56371/plugins/myorg/new-plugin/ui",
//     api: "http://localhost:56371/plugins/myorg/new-plugin/api",
//     assets: "http://localhost:56371/plugin-screens/new-plugin"
//   }
// }

// Immediately use the URLs
console.log(`Plugin registered! Access at: ${info.urls.ui}`);
```

**Benefits:**
- No need to manually construct URLs
- Assets URL included when `assetPathPrefix` is configured
- Perfect for dynamic plugin loading
- Easy integration with monitoring dashboards
- Simplified testing and automation

## Request Logging

The server automatically logs **ALL requests**, including 404s, with detailed information:

```typescript
// After some requests have been made
const logs = server.getRequestLog();
console.log(logs);
// [
//   {
//     id: "1762896053497-6tdvzl1yf",
//     timestamp: "2025-11-11T21:20:53.497Z",
//     method: "POST",
//     url: "/plugins/wrong/wrong/api/test",
//     org: "wrong",
//     pluginId: "wrong",
//     statusCode: 404,
//     duration: 0,
//     error: "Plugin not found"
//   },
//   {
//     id: "1762896053495-vzyvs2nuv",
//     timestamp: "2025-11-11T21:20:53.495Z",
//     method: "POST",
//     url: "/plugins/test/test-plugin/api/hello",
//     org: "test",
//     pluginId: "test-plugin",
//     statusCode: 200,
//     duration: 0
//   }
// ]
```

**Request Log Features:**
- Captures ALL requests (200s, 404s, 500s, etc.)
- Includes status code, duration, and error messages
- Configurable size limit via `maxRequestLogSize`
- Useful for debugging, monitoring, and analytics
- Accessible via `getRequestLog()` or `/api/request-log` endpoint

**Example: Monitoring 404s**

```typescript
setInterval(() => {
  const logs = server.getRequestLog();
  const notFound = logs.filter(log => log.statusCode === 404);

  if (notFound.length > 0) {
    console.warn(`⚠️  Found ${notFound.length} 404 errors in recent requests`);
    notFound.forEach(log => {
      console.warn(`   ${log.method} ${log.url}`);
    });
  }
}, 60000);
```

## Auto Port Selection

The server can automatically find and use a free port:

```typescript
// Option 1: Omit port (will auto-find)
const server = createServer({
  // No port specified
  plugins: [...],
  functionInvoker: async () => { /* ... */ }
});

const result = await server.start();
console.log(`Server running on port ${result.port}`);
console.log(`Base URL: ${result.baseUrl}`);

// Option 2: Explicit port = 0 (auto-find)
const server = createServer({
  port: 0, // 0 = find free port
  plugins: [...]
});

const result = await server.start();
console.log(`Auto-selected port: ${result.port}`);

// Option 3: Specific port
const server = createServer({
  port: 3000, // Try this specific port
  plugins: [...]
});

const result = await server.start(); // result.port = 3000 (or error if taken)
```

### Getting the Port

```typescript
// Method 1: From start() result
const result = await server.start();
const port = result.port;

// Method 2: getPort() after starting
await server.start();
const port = server.getPort();
```

## Cookie Configuration

Cookie options are configurable with secure defaults:

```typescript
const server = createServer({
  auth: {
    enabled: true,
    tokenValidator: async (token) => validateToken(token),
    cookieHttpOnly: true,      // Default: true (XSS protection)
    cookieSameSite: 'strict',   // Default: 'strict' (CSRF protection)
    cookieName: 'majk_auth',    // Default: 'majk_auth'
    cookieMaxAge: 24 * 60 * 60 * 1000  // Default: 24 hours
  }
});
```

## Asset Path Prefix

By default, the server serves plugin static assets via `/plugins/{org}/{id}/ui/*`. You can optionally configure an additional asset path prefix to serve assets via a custom route (e.g., `/plugin-screens/{pluginId}/*`). This is useful when plugins are built with specific base paths (like Vite's `base` option).

```typescript
const server = createServer({
  plugins: [{ org: 'myorg', id: 'my-plugin', distPath: './dist', capabilities: {} }],
  functionInvoker: async () => ({ success: true }),
  assetPathPrefix: '/plugin-screens'  // Enables /plugin-screens/{pluginId}/* routes
});
```

With this configuration, assets will be served from both:
- `/plugins/myorg/my-plugin/ui/*` (default route)
- `/plugin-screens/my-plugin/*` (custom prefix route)

**CLI Usage:**
```bash
majk-plugin-server -d ./my-plugin --asset-path-prefix /plugin-screens
```

## CLI Tool

The server includes a CLI tool with two modes: **Standalone Mode** (single plugin development) and **Auto-Discovery Mode** (multiple plugins).

### Installation

```bash
npm install -g @majkapp/majk-plugin-server
```

### Standalone Mode (Development)

Serve a single plugin from its directory - perfect for development:

```bash
# Serve a plugin from its directory
majk-plugin-server --plugin-dir ./my-plugin

# With custom port
majk-plugin-server -d ./my-plugin -p 3456

# With verbose output
majk-plugin-server -d ./my-plugin -v
```

**How it works:**
1. Reads `package.json` to get plugin name and configuration
2. Extracts org/id from package name (e.g., `@myorg/my-plugin` → `myorg/my-plugin`)
3. Serves UI from `dist/` directory
4. Accessible at: `http://localhost:PORT/plugins/ORG/ID/ui/`

**Requirements:**
- Plugin directory must contain `package.json`
- Plugin must be built (dist/ directory must exist)
- Plugin will be served at `/plugins/{org}/{id}/ui/`

**Example:**
```bash
cd my-plugin
npm run build  # Build your plugin first
cd ..
majk-plugin-server --plugin-dir ./my-plugin

# Output:
# ╔═══════════════════════════════════════════════════╗
# ║       MAJK Plugin Server - Standalone Mode       ║
# ╚═══════════════════════════════════════════════════╝
#
# 🔍 Loading plugin from: ./my-plugin
#   ✅ Loaded: @myorg/my-plugin (myorg/my-plugin)
#   📂 Serving from: ./my-plugin/dist
#
# Plugin routes:
#   • http://localhost:3000/plugins/myorg/my-plugin/ui/
```

### Auto-Discovery Mode (Multiple Plugins)

Discover and serve multiple plugins from directories:

```bash
# Serve installed plugins
majk-plugin-server --plugins-dir ~/.majk-dev/plugins

# Serve local development plugins
majk-plugin-server --local-plugins-dir ~/.majk-dev/local-plugins

# Serve both with custom port
majk-plugin-server \
  --plugins-dir ~/.majk-dev/plugins \
  --local-plugins-dir ~/.majk-dev/local-plugins \
  -p 3456
```

### Options

- `-p, --port <port>` - Port to listen on (default: 3000)
- `-d, --plugin-dir <path>` - **Standalone mode**: single plugin directory
- `--plugins-dir <path>` - Directory containing multiple plugins
- `--local-plugins-dir <path>` - Directory containing local plugin links
- `--functions-handler <path>` - JavaScript file with function handlers
- `--middleware <path>` - JavaScript file with middleware
- `-v, --verbose` - Verbose output
- `-h, --help` - Show help

**Note:** Cannot mix standalone mode (`-d`) with auto-discovery mode (`--plugins-dir`/`--local-plugins-dir`)

### Function Handlers

Enable function invocation by providing a JavaScript file that exports handlers:

**Option 1: Object with named functions**
```javascript
// handlers.js
module.exports = {
  async getData(params) {
    return { success: true, data: params };
  },

  async saveData(params) {
    if (!params.data) {
      return { success: false, error: 'No data provided' };
    }
    return { success: true, id: Date.now() };
  },

  async deleteData(params) {
    return { success: true, deleted: true };
  }
};
```

**Option 2: Single function with routing**
```javascript
// handlers-advanced.js
module.exports = async function(org, id, functionName, params) {
  console.log(`Calling ${org}/${id}.${functionName}`);

  switch (functionName) {
    case 'getData':
      return { success: true, data: params };
    case 'saveData':
      return { success: true, saved: true };
    default:
      return { success: false, error: 'Unknown function' };
  }
};
```

**Usage:**
```bash
# With object-based handlers
majk-plugin-server -d ./my-plugin --functions-handler ./handlers.js

# With routing-based handlers
majk-plugin-server -d ./my-plugin --functions-handler ./handlers-advanced.js -v
```

**Example files available in `examples/`:**
- `functions-handler.js` - Object-based handlers
- `functions-handler-advanced.js` - Routing-based handler

### Middleware

Add pre/post processing to function invocations:

```javascript
// middleware.js
const { UnauthorizedError, ForbiddenError } = require('@majkapp/plugin-server');

module.exports = {
  // Before function invocation
  async beforeInvoke(context) {
    // context: { org, pluginId, functionName, params, request }

    // Authentication check
    const user = getUserFromRequest(context.request);
    if (!user) {
      throw new UnauthorizedError('Authentication required');
    }

    // Authorization check
    if (context.functionName === 'deleteData' && user.role !== 'admin') {
      throw new ForbiddenError('Admin access required');
    }

    // Add user context to params
    return {
      params: { ...context.params, userId: user.id },
      metadata: { user, startTime: Date.now() }
    };
  },

  // After function invocation
  async afterInvoke(context, result, metadata) {
    const duration = Date.now() - metadata.startTime;
    console.log(`Function executed in ${duration}ms`);

    // Add metadata to response
    return {
      ...result,
      _metadata: { executionTime: duration }
    };
  }
};
```

**Usage:**
```bash
majk-plugin-server -d ./my-plugin \
  --functions-handler ./handlers.js \
  --middleware ./middleware.js
```

**Example file available:** `examples/middleware-example.js`

**Authorization Errors:**
- `UnauthorizedError` - Returns 401
- `ForbiddenError` - Returns 403
- `InsufficientPermissionsError` - Returns 403

### Plugin Discovery

**Installed Plugins** (`--plugins-dir`):
- Scans for directories with `package.json`
- Requires `majk.type: "in-process"` in package.json
- Looks for `dist` directory for built assets
- Parses org/id from package name

**Local Plugins** (`--local-plugins-dir`):
- Scans for directories with `package.json` containing `localPlugin` config
- Follows `targetDirectory` to actual plugin source
- Supports development plugins via symbolic links
- Example local plugin package.json:
  ```json
  {
    "name": "@myorg/my-plugin-local",
    "localPlugin": {
      "targetDirectory": "/path/to/plugin/source",
      "packageName": "@myorg/my-plugin",
      "type": "in-process"
    }
  }
  ```

### Error Handling

The CLI is error-tolerant:
- Skips plugins with missing files/directories
- Logs errors prominently in output
- Emits error events for monitoring
- Continues serving valid plugins despite individual failures

### Limitations in CLI Mode

- **Function invocation disabled**: API endpoints return "not enabled" error
- **Static assets only**: UI routes work normally
- **No auth by default**: Add auth config for production use

## Practical Examples

### Example 1: Multi-Tenant SaaS Application

```typescript
import { createServer, UnauthorizedError, ForbiddenError } from '@majkapp/majk-plugin-server';
import { verifyJWT, getTenantForUser } from './auth';

const server = createServer({
  plugins: [
    {
      org: 'saas',
      id: 'crm',
      distPath: './plugins/crm/dist',
      capabilities: { screens: ['contacts', 'deals'], functions: ['getContacts', 'createDeal'] }
    },
    {
      org: 'saas',
      id: 'analytics',
      distPath: './plugins/analytics/dist',
      capabilities: { screens: ['dashboard'], functions: ['getMetrics'] }
    }
  ],

  functionInvoker: async (org, id, functionName, params) => {
    const plugin = await import(`./plugins/${id}/index.js`);
    return await plugin[functionName](params);
  },

  auth: {
    enabled: true,
    tokenValidator: async (token) => {
      try {
        await verifyJWT(token);
        return true;
      } catch {
        return false;
      }
    }
  },

  functionMiddleware: {
    beforeInvoke: async (context) => {
      const token = context.request.headers.authorization?.substring(7);
      const user = await verifyJWT(token);

      if (!user) {
        throw new UnauthorizedError('Invalid token');
      }

      const tenant = await getTenantForUser(user.id);

      if (!tenant.hasAccess(context.pluginId)) {
        throw new ForbiddenError(`Tenant does not have access to ${context.pluginId}`);
      }

      // Add tenant context to all function calls
      return {
        params: {
          ...context.params,
          tenantId: tenant.id,
          userId: user.id
        },
        metadata: { tenant, user }
      };
    },

    afterInvoke: async (context, result, metadata) => {
      // Log usage for billing
      await logUsage({
        tenantId: metadata.tenant.id,
        function: context.functionName,
        plugin: context.pluginId,
        timestamp: new Date()
      });

      return result;
    }
  },

  eventEmitter: async (event) => {
    // Real-time monitoring dashboard
    await publishToWebSocket('monitoring-dashboard', event);
  }
});

await server.start();
```

### Example 2: Development Server with Hot Reload

```typescript
import { createServer } from '@majkapp/majk-plugin-server';
import chokidar from 'chokidar';
import path from 'path';

const pluginRegistry = new Map();

const server = createServer({
  plugins: [],
  functionInvoker: async (org, id, functionName, params) => {
    const plugin = pluginRegistry.get(`${org}/${id}`);
    if (!plugin) throw new Error('Plugin not found');

    // Clear require cache for hot reload
    const modulePath = require.resolve(plugin.entryPoint);
    delete require.cache[modulePath];

    const pluginModule = require(modulePath);
    return await pluginModule[functionName](params);
  },
  cors: true // Enable CORS for local development
});

// Auto-discover plugins
async function discoverPlugins() {
  const pluginsDir = './plugins';
  const plugins = await fs.readdir(pluginsDir);

  for (const pluginName of plugins) {
    const distPath = path.join(pluginsDir, pluginName, 'dist');
    if (fs.existsSync(distPath)) {
      await server.registerPlugin({
        org: 'dev',
        id: pluginName,
        distPath,
        capabilities: {}
      });

      pluginRegistry.set(`dev/${pluginName}`, {
        entryPoint: path.join(pluginsDir, pluginName, 'index.js')
      });

      console.log(`📦 Registered: ${pluginName}`);
    }
  }
}

// Watch for changes and hot reload
chokidar.watch('./plugins/**/dist/**/*').on('change', async (filePath) => {
  const pluginName = filePath.split('/')[2];
  console.log(`🔄 Reloading: ${pluginName}`);

  await server.unregisterPlugin('dev', pluginName);
  await server.registerPlugin({
    org: 'dev',
    id: pluginName,
    distPath: `./plugins/${pluginName}/dist`,
    capabilities: {}
  });

  console.log(`✅ Reloaded: ${pluginName}`);
});

await discoverPlugins();
const port = await server.start();
console.log(`🚀 Dev server running on http://localhost:${port}`);
console.log('   Hot reload enabled - changes will be reflected automatically');
```

### Example 3: API Gateway with Rate Limiting

```typescript
import { createServer, UnauthorizedError, ForbiddenError } from '@majkapp/majk-plugin-server';
import rateLimit from 'express-rate-limit';

// Rate limit tracking
const rateLimitMap = new Map();

function checkRateLimit(userId: string, functionName: string): boolean {
  const key = `${userId}:${functionName}`;
  const now = Date.now();
  const windowMs = 60000; // 1 minute
  const maxRequests = 100;

  const userLimits = rateLimitMap.get(key) || { count: 0, resetTime: now + windowMs };

  if (now > userLimits.resetTime) {
    userLimits.count = 0;
    userLimits.resetTime = now + windowMs;
  }

  userLimits.count++;
  rateLimitMap.set(key, userLimits);

  return userLimits.count <= maxRequests;
}

const server = createServer({
  plugins: [
    { org: 'api', id: 'users', distPath: './plugins/users/dist', capabilities: {} },
    { org: 'api', id: 'payments', distPath: './plugins/payments/dist', capabilities: {} },
    { org: 'api', id: 'notifications', distPath: './plugins/notifications/dist', capabilities: {} }
  ],

  functionInvoker: async (org, id, functionName, params) => {
    const plugin = await import(`./plugins/${id}/index.js`);
    return await plugin[functionName](params);
  },

  auth: {
    enabled: true,
    tokenValidator: async (token) => {
      return await validateApiKey(token);
    }
  },

  functionMiddleware: {
    beforeInvoke: async (context) => {
      const apiKey = context.request.headers.authorization?.substring(7);
      const user = await getUserFromApiKey(apiKey);

      if (!user) {
        throw new UnauthorizedError('Invalid API key');
      }

      // Check rate limits
      if (!checkRateLimit(user.id, context.functionName)) {
        throw new ForbiddenError('Rate limit exceeded - max 100 requests per minute');
      }

      // Track API usage for billing
      return {
        params: { ...context.params, apiKeyId: user.apiKeyId },
        metadata: { user, startTime: Date.now() }
      };
    },

    afterInvoke: async (context, result, metadata) => {
      const duration = Date.now() - metadata.startTime;

      // Log API usage
      await logApiCall({
        userId: metadata.user.id,
        apiKeyId: metadata.user.apiKeyId,
        function: context.functionName,
        plugin: context.pluginId,
        duration,
        timestamp: new Date(),
        statusCode: result.success ? 200 : 500
      });

      // Add rate limit headers to response
      return {
        ...result,
        headers: {
          'X-RateLimit-Limit': '100',
          'X-RateLimit-Remaining': getRemainingRequests(metadata.user.id, context.functionName)
        }
      };
    }
  },

  eventEmitter: async (event) => {
    // Alert on errors
    if (event.type.includes('error')) {
      await sendAlert({
        severity: 'error',
        message: `API Error: ${event.type}`,
        data: event.data
      });
    }
  }
});

await server.start();
```

### Example 4: Microservices Architecture

```typescript
import { createServer } from '@majkapp/majk-plugin-server';
import { connectToServiceBus } from './messaging';

const serviceBus = await connectToServiceBus();

const server = createServer({
  plugins: [
    { org: 'services', id: 'orders', distPath: './services/orders/dist', capabilities: {} },
    { org: 'services', id: 'inventory', distPath: './services/inventory/dist', capabilities: {} },
    { org: 'services', id: 'shipping', distPath: './services/shipping/dist', capabilities: {} }
  ],

  functionInvoker: async (org, id, functionName, params) => {
    // Route function calls through service bus for distributed processing
    return await serviceBus.request(`${id}.${functionName}`, params, {
      timeout: 30000
    });
  },

  functionMiddleware: {
    beforeInvoke: async (context) => {
      // Add distributed tracing
      const traceId = generateTraceId();
      const spanId = generateSpanId();

      return {
        params: {
          ...context.params,
          tracing: { traceId, spanId, parentSpanId: null }
        },
        metadata: { traceId, spanId, startTime: Date.now() }
      };
    },

    afterInvoke: async (context, result, metadata) => {
      const duration = Date.now() - metadata.startTime;

      // Record span in distributed tracing
      await recordSpan({
        traceId: metadata.traceId,
        spanId: metadata.spanId,
        service: context.pluginId,
        operation: context.functionName,
        duration,
        status: result.success ? 'ok' : 'error'
      });

      return result;
    }
  },

  eventEmitter: async (event) => {
    // Publish events to service bus for other microservices
    await serviceBus.publish('plugin-server.events', event);
  }
});

await server.start();
```

### Example 5: Testing & Mock Server

```typescript
import { createServer } from '@majkapp/majk-plugin-server';

// Mock data store
const mockData = {
  users: [
    { id: '1', name: 'Alice', role: 'admin' },
    { id: '2', name: 'Bob', role: 'user' }
  ],
  products: [
    { id: '1', name: 'Widget', price: 29.99 },
    { id: '2', name: 'Gadget', price: 49.99 }
  ]
};

const mockServer = createServer({
  plugins: [
    { org: 'test', id: 'api', distPath: './test-fixtures/dist', capabilities: {} }
  ],

  functionInvoker: async (org, id, functionName, params) => {
    console.log(`[MOCK] ${functionName}(${JSON.stringify(params)})`);

    // Simulate different responses based on function name
    if (functionName === 'getUsers') {
      return { success: true, data: mockData.users };
    }

    if (functionName === 'getUser') {
      const user = mockData.users.find(u => u.id === params.id);
      return user
        ? { success: true, data: user }
        : { success: false, error: 'User not found' };
    }

    if (functionName === 'createUser') {
      const newUser = { id: String(mockData.users.length + 1), ...params };
      mockData.users.push(newUser);
      return { success: true, data: newUser };
    }

    // Simulate slow responses
    if (params.slow) {
      await new Promise(resolve => setTimeout(resolve, 2000));
    }

    // Simulate errors
    if (params.error) {
      throw new Error('Simulated error');
    }

    return { success: true, data: 'Mock response' };
  },

  eventEmitter: async (event) => {
    console.log(`[EVENT] ${event.type}`, event.data);
  },

  cors: true // Enable CORS for testing from browser
});

const port = await server.start();
console.log(`🧪 Mock server running on http://localhost:${port}`);
console.log('   Available mock functions: getUsers, getUser, createUser');
console.log('   Add ?slow=true to simulate slow responses');
console.log('   Add ?error=true to simulate errors');

export default mockServer;
```

## Client-Side Usage Examples

### JavaScript/TypeScript Client

```typescript
// Calling plugin functions
async function callPluginFunction(org: string, pluginId: string, functionName: string, params: any) {
  const response = await fetch(
    `http://localhost:3000/plugins/${org}/${pluginId}/api/${functionName}`,
    {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'Authorization': `Bearer ${token}`
      },
      body: JSON.stringify(params)
    }
  );

  return await response.json();
}

// Usage
const result = await callPluginFunction('myorg', 'dashboard', 'getData', {
  filter: 'active',
  limit: 10
});

console.log(result.data);
```

### React Hook

```typescript
import { useState, useEffect } from 'react';

function usePluginFunction(org: string, pluginId: string, functionName: string) {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);

  const execute = async (params: any) => {
    setLoading(true);
    setError(null);

    try {
      const response = await fetch(
        `http://localhost:3000/plugins/${org}/${pluginId}/api/${functionName}`,
        {
          method: 'POST',
          headers: {
            'Content-Type': 'application/json',
            'Authorization': `Bearer ${localStorage.getItem('token')}`
          },
          body: JSON.stringify(params)
        }
      );

      const result = await response.json();

      if (!result.success) {
        throw new Error(result.error);
      }

      setData(result.data);
      return result.data;
    } catch (err) {
      setError(err);
      throw err;
    } finally {
      setLoading(false);
    }
  };

  return { data, loading, error, execute };
}

// Usage in component
function DashboardComponent() {
  const { data, loading, error, execute } = usePluginFunction(
    'myorg',
    'dashboard',
    'getData'
  );

  useEffect(() => {
    execute({ filter: 'active' });
  }, []);

  if (loading) return <div>Loading...</div>;
  if (error) return <div>Error: {error.message}</div>;

  return <div>{JSON.stringify(data)}</div>;
}
```

### cURL Examples

```bash
# Health check
curl http://localhost:3000/health

# Get request logs
curl http://localhost:3000/api/request-log

# Access plugin UI (with token)
curl "http://localhost:3000/plugins/myorg/my-plugin/ui/?token=abc123"

# Call plugin function
curl -X POST http://localhost:3000/plugins/myorg/my-plugin/api/getData \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer your-token-here" \
  -d '{"filter": "active", "limit": 10}'

# With cookie authentication (after initial token)
curl http://localhost:3000/plugins/myorg/my-plugin/ui/dashboard \
  -H "Cookie: majk_auth=authenticated"
```

## Troubleshooting

### Common Issues

**Issue: 401 Unauthorized on all requests**

```typescript
// Solution: Check that auth is properly configured
const server = createServer({
  auth: {
    enabled: true,
    tokenValidator: async (token) => {
      console.log('Validating token:', token); // Debug log
      return await yourValidationLogic(token);
    }
  }
});

// Or disable auth for testing
const server = createServer({
  auth: { enabled: false } // or omit auth entirely
});
```

**Issue: Plugin not found (404)**

```typescript
// Check plugin is registered
const plugins = server.getPlugins();
console.log('Registered plugins:', plugins);

// Ensure distPath exists and contains index.html
const distPath = './plugins/my-plugin/dist';
console.log('Files in distPath:', fs.readdirSync(distPath));
```

**Issue: CORS errors in browser**

```typescript
// Enable CORS for development
const server = createServer({
  cors: true,
  // ... other config
});
```

**Issue: Function middleware errors not caught**

```typescript
// Always import error classes
import {
  UnauthorizedError,
  ForbiddenError,
  InsufficientPermissionsError
} from '@majkapp/majk-plugin-server';

// Throw proper error types
throw new UnauthorizedError('Custom message');
```

## TypeScript Types

Full TypeScript type definitions:

```typescript
export interface PluginConfig {
  org: string;
  id: string;
  distPath: string;
  capabilities: any;
}

export interface AuthConfig {
  enabled: boolean;
  tokenValidator: (token: string) => Promise<boolean>;
  cookieName?: string;
  cookieMaxAge?: number;
  cookieHttpOnly?: boolean;
  cookieSameSite?: 'strict' | 'lax' | 'none';
}

export interface FunctionMiddlewareContext {
  org: string;
  pluginId: string;
  functionName: string;
  params: any;
  request: express.Request;
}

export interface FunctionMiddlewareResult {
  params?: any;
  metadata?: any;
}

export interface FunctionMiddleware {
  beforeInvoke?: (context: FunctionMiddlewareContext) => Promise<FunctionMiddlewareResult>;
  afterInvoke?: (context: FunctionMiddlewareContext, result: any, metadata?: any) => Promise<any>;
}

export interface PluginServerConfig {
  port?: number;
  plugins: PluginConfig[];
  functionInvoker: (org: string, id: string, functionName: string, params: any) => Promise<any>;
  eventEmitter?: (event: ServerEvent) => Promise<void>;
  maxRequestLogSize?: number;
  cors?: boolean;
  auth?: AuthConfig;
  functionMiddleware?: FunctionMiddleware;
  assetPathPrefix?: string;  // Optional asset path prefix (e.g., '/plugin-screens')
}

export interface RequestLogEntry {
  id: string;
  timestamp: string;
  method: string;
  url: string;
  org: string;
  pluginId: string;
  statusCode?: number;
  duration?: number;
  error?: string;
}

export interface ServerEvent {
  type: string;
  timestamp: string;
  data: any;
}

export interface PluginUrls {
  ui: string;
  api: string;
  assets?: string;
}

export interface PluginInfo {
  org: string;
  id: string;
  distPath: string;
  urls: PluginUrls;
}

export interface ServerStartResult {
  port: number;
  baseUrl: string;
  plugins: PluginInfo[];
}

export interface PluginRegistrationResult {
  org: string;
  id: string;
  distPath: string;
  urls: PluginUrls;
}

export class AuthorizationError extends Error {
  constructor(message: string, public statusCode: number = 403);
}

export class ForbiddenError extends AuthorizationError {}
export class UnauthorizedError extends AuthorizationError {}
export class InsufficientPermissionsError extends AuthorizationError {}

export interface PluginServer {
  start(): Promise<ServerStartResult>;
  getPort(): number | undefined;
  registerPlugin(plugin: PluginConfig): Promise<PluginRegistrationResult>;
  unregisterPlugin(org: string, id: string): Promise<void>;
  getPlugins(): PluginConfig[];
  getRequestLog(): RequestLogEntry[];
  stop(): Promise<void>;
}

export function createServer(config: PluginServerConfig): PluginServer;
```

## Development

### Building

```bash
npm run build
```

### Examples

Working code examples are available in the `examples/` directory:
- `example-full.js` / `example-full.ts` - Complete server with all features
- `example-auto-port.js` - Auto port selection demonstration
- `example-middleware.js` - Function middleware with authorization
- `example.ts` - Basic TypeScript server setup

Run examples with:
```bash
node examples/example-full.js
```

### Testing

The project uses Jest with TypeScript for testing:

```bash
# Run all tests
npm test

# Run tests in watch mode
npm run test:watch

# Generate coverage report
npm run test:coverage
```

**Test Structure:**
- `tests/auth.test.ts` - Authentication and CORS tests (13 tests)
- `tests/middleware.test.ts` - Function middleware tests (6 tests)
- `tests/auto-port.test.ts` - Auto port selection tests (7 tests)
- `tests/runtime-plugin-management.test.ts` - Plugin management tests (8 tests)

**Total: 34 tests, all passing ✅**

### Test Coverage

Tests cover:
- Bearer token authentication (Authorization header)
- Token-based authentication with cookies
- Query parameter token authentication
- Authentication priority (bearer > cookie > query)
- Function middleware (beforeInvoke, afterInvoke)
- Authorization error handling
- Auto port selection
- Runtime plugin registration/unregistration
- CORS configuration
- Cookie security (HttpOnly, SameSite)

## License

MIT