@majkapp/majk-plugin-server
v1.4.0
Published
Lightweight HTTP server for serving MAJK plugins in single-server architecture
Readme
@majkapp/majk-plugin-server
Lightweight HTTP server for serving MAJK plugins in single-server architecture.
Table of Contents
- Features
- Installation
- Quick Start
- API
- Function Middleware
- Authentication
- CORS Configuration
- Auto Port Selection
- Cookie Configuration
- CLI Tool - Auto Discovery
- Practical Examples
- Client-Side Usage Examples
- Troubleshooting
- TypeScript Types
- 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.htmlfor 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()andregisterPlugin()return complete URL information for each plugin - Event emission for monitoring and debugging
- TypeScript with full type definitions
- Single-file implementation for simplicity
Installation
npm install @majkapp/majk-plugin-serverQuick Start
Basic Server
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:
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)
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 informationregisterPlugin(plugin: PluginConfig): Promise<PluginRegistrationResult>- Register a plugin at runtime and get its URLsunregisterPlugin(org: string, id: string): Promise<void>- Unregister a plugin at runtimegetPlugins(): PluginConfig[]- Get all registered pluginsgetPort(): number | undefined- Get the actual port the server is running ongetRequestLog(): RequestLogEntry[]- Get request log (includes ALL requests, even 404s)
Routes
GET /health- Server health checkGET /api/request-log- Get request logPOST /plugins/{org}/{id}/api/{functionName}- Invoke plugin functionGET /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 startedplugin.registered- Plugin registered at runtimeplugin.unregistered- Plugin unregistered at runtimeplugin.register.duplicate- Attempted to register duplicate pluginplugin.unregister.not_found- Attempted to unregister non-existent pluginplugin.not_found- Plugin not found for requestfunction.invoke.start- Function invocation startedfunction.invoke.success- Function invocation succeededfunction.invoke.error- Function invocation failedstatic.serve- Static asset servedstatic.serve.fallback- Fallback to index.html for SPA routingstatic.not_found- Static asset not foundstatic.dist_missing- Plugin dist directory missingauth.authorization.header.valid- Bearer token validated successfullyauth.authorization.header.invalid- Bearer token validation failedauth.token.checking- Validating token from query parameterauth.token.valid- Token validated successfully, cookie issuedauth.token.invalid- Token validation failedauth.cookie.present- Request authenticated via cookieauth.missing- No authentication credentials providedauth.unauthorized- Unauthorized access attempt blockedfunction.middleware.before- Before-invoke middleware executedfunction.middleware.after- After-invoke middleware executedfunction.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
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:
// 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
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
- Authentication: Token/cookie validation (if auth enabled)
- beforeInvoke: Modify params, validate permissions, add context
- Function Invocation: Call the actual plugin function
- afterInvoke: Modify result, log, filter data
- 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.errorevent - Other errors return 500 status code
Authentication
The server supports multiple authentication methods:
- Bearer Token Authentication (recommended for APIs and cross-origin requests)
- Cookie-based Authentication (for browser-based clients)
- Query Parameter Token (for initial authentication)
Authentication Priority
When a request is received, the server checks authentication in this order:
- Authorization Bearer Token - Checked first, ideal for API clients and CORS scenarios
- Cookie - Checked second, for browser-based sessions
- Query Parameter Token - Checked last, for initial authentication and cookie setup
Bearer Token Authentication
Perfect for API clients, mobile apps, and cross-origin requests:
// 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:
// 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 UnauthorizedAuthentication Examples
API Client with Bearer Token:
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:
// 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:
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:
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:
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
assetPathPrefixis 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:
// 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-logendpoint
Example: Monitoring 404s
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:
// 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
// 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:
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).
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:
majk-plugin-server -d ./my-plugin --asset-path-prefix /plugin-screensCLI Tool
The server includes a CLI tool with two modes: Standalone Mode (single plugin development) and Auto-Discovery Mode (multiple plugins).
Installation
npm install -g @majkapp/majk-plugin-serverStandalone Mode (Development)
Serve a single plugin from its directory - perfect for development:
# 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 -vHow it works:
- Reads
package.jsonto get plugin name and configuration - Extracts org/id from package name (e.g.,
@myorg/my-plugin→myorg/my-plugin) - Serves UI from
dist/directory - 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:
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:
# 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 3456Options
-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
// 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
// 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:
# 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 -vExample files available in examples/:
functions-handler.js- Object-based handlersfunctions-handler-advanced.js- Routing-based handler
Middleware
Add pre/post processing to function invocations:
// 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:
majk-plugin-server -d ./my-plugin \
--functions-handler ./handlers.js \
--middleware ./middleware.jsExample file available: examples/middleware-example.js
Authorization Errors:
UnauthorizedError- Returns 401ForbiddenError- Returns 403InsufficientPermissionsError- Returns 403
Plugin Discovery
Installed Plugins (--plugins-dir):
- Scans for directories with
package.json - Requires
majk.type: "in-process"in package.json - Looks for
distdirectory for built assets - Parses org/id from package name
Local Plugins (--local-plugins-dir):
- Scans for directories with
package.jsoncontaininglocalPluginconfig - Follows
targetDirectoryto actual plugin source - Supports development plugins via symbolic links
- Example local plugin package.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
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
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
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
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
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
// 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
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
# 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
// 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)
// 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
// Enable CORS for development
const server = createServer({
cors: true,
// ... other config
});Issue: Function middleware errors not caught
// 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:
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
npm run buildExamples
Working code examples are available in the examples/ directory:
example-full.js/example-full.ts- Complete server with all featuresexample-auto-port.js- Auto port selection demonstrationexample-middleware.js- Function middleware with authorizationexample.ts- Basic TypeScript server setup
Run examples with:
node examples/example-full.jsTesting
The project uses Jest with TypeScript for testing:
# Run all tests
npm test
# Run tests in watch mode
npm run test:watch
# Generate coverage report
npm run test:coverageTest 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
