UNPKG

signalk-server

Version:

An implementation of a [Signal K](http://signalk.org) server for boats.

520 lines 26.7 kB
"use strict"; /* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-require-imports */ /* eslint-disable @typescript-eslint/no-unused-vars */ /** * WASM Plugin HTTP Route Handlers * * Handles HTTP route registration and request handling for WASM plugins. * Includes custom endpoint routing, log streaming, and basic REST API endpoints. */ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || (function () { var ownKeys = function(o) { ownKeys = Object.getOwnPropertyNames || function (o) { var ar = []; for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; return ar; }; return ownKeys(o); }; return function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); __setModuleDefault(result, mod); return result; }; })(); var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.backwardsCompat = backwardsCompat; exports.handleLogViewerRequest = handleLogViewerRequest; exports.setupPluginSpecificRoutes = setupPluginSpecificRoutes; exports.setupWasmPluginRoutes = setupWasmPluginRoutes; const path = __importStar(require("path")); const express = __importStar(require("express")); const child_process_1 = require("child_process"); const readline = __importStar(require("readline")); const debug_1 = __importDefault(require("debug")); const wasm_runtime_1 = require("../wasm-runtime"); const wasm_storage_1 = require("../wasm-storage"); const constants_1 = require("../../constants"); const debug = (0, debug_1.default)('signalk:wasm:loader'); /** * Helper to support both prefixed and non-prefixed routes */ function backwardsCompat(url) { return [`${constants_1.SERVERROUTESPREFIX}${url}`, url]; } /** * Handle /api/logs request directly in Node.js (for signalk-logviewer plugin) * This avoids WASM memory buffer limitations (~64KB) when streaming large logs */ async function handleLogViewerRequest(req, res) { try { const lines = parseInt(req.query.lines) || 2000; const maxLines = Math.min(lines, 50000); // Cap at 50000 lines debug(`[logviewer] Fetching ${maxLines} log lines via Node.js streaming`); // Try journalctl first const p = (0, child_process_1.spawn)('journalctl', [ '-u', 'signalk', '-n', maxLines.toString(), '--output=short-iso', '--no-pager' ]); const logLines = []; let hasError = false; let errorOutput = ''; // Stream lines using readline const rl = readline.createInterface({ input: p.stdout, crlfDelay: Infinity }); rl.on('line', (line) => { if (line.trim().length > 0) { logLines.push(line); } }); p.stderr.on('data', (data) => { errorOutput += data.toString(); }); p.on('error', (err) => { debug(`[logviewer] journalctl spawn error: ${err.message}`); hasError = true; }); // Wait for process to complete await new Promise((resolve) => { p.on('close', (code) => { debug(`[logviewer] journalctl exited with code ${code}`); if (code !== 0) { hasError = true; } resolve(); }); }); if (hasError || logLines.length === 0) { debug(`[logviewer] journalctl failed, trying file-based logs`); // Fallback to reading from file try { const tailP = (0, child_process_1.spawn)('tail', [ '-n', maxLines.toString(), '/var/log/syslog' ]); logLines.length = 0; // Clear array const tailRl = readline.createInterface({ input: tailP.stdout, crlfDelay: Infinity }); tailRl.on('line', (line) => { if (line.trim().length > 0) { logLines.push(line); } }); await new Promise((resolve) => { tailP.on('close', () => resolve()); }); } catch (tailErr) { debug(`[logviewer] tail also failed: ${tailErr}`); } } if (logLines.length === 0) { res.status(404).json({ error: 'Could not find logs', message: 'Tried journalctl and file-based logs' }); return; } debug(`[logviewer] Retrieved ${logLines.length} log lines, sending response`); // Send response res.json({ lines: logLines, count: logLines.length, source: 'journalctl', format: 'short-iso' }); } catch (error) { const errorMsg = error instanceof Error ? error.message : String(error); debug(`[logviewer] Error handling request: ${errorMsg}`); res.status(500).json({ error: errorMsg }); } } /** * Add plugin-specific HTTP endpoints to an existing router * This is called when enabling a previously disabled plugin */ function setupPluginSpecificRoutes(plugin) { if (!plugin.router) { debug(`Warning: Cannot setup plugin-specific routes - no router found for ${plugin.id}`); return; } if (!plugin.instance) { debug(`No instance for ${plugin.id}`); return; } // Check for http_endpoints in either AssemblyScript loader or raw WASM exports const hasAsEndpoints = plugin.instance.asLoader && typeof plugin.instance.asLoader.exports.http_endpoints === 'function'; const hasRustEndpoints = plugin.instance.instance && typeof plugin.instance.instance.exports.http_endpoints === 'function'; if (!hasAsEndpoints && !hasRustEndpoints) { debug(`No custom HTTP endpoints for ${plugin.id}`); return; } const router = plugin.router; // Register custom HTTP endpoints try { let endpointsJson; // Check if this is an AssemblyScript or Rust plugin const asLoader = plugin.instance.asLoader; if (asLoader && typeof asLoader.exports.http_endpoints === 'function') { // AssemblyScript: http_endpoints() returns a string pointer const ptr = asLoader.exports.http_endpoints(); endpointsJson = asLoader.exports.__getString(ptr); debug(`Got http_endpoints from AssemblyScript: ${endpointsJson.substring(0, 200)}`); } else { // Rust: http_endpoints(out_ptr, out_max_len) -> written_len const rawExports = plugin.instance.instance.exports; if (typeof rawExports.allocate === 'function' && typeof rawExports.http_endpoints === 'function') { const maxLen = 8192; // 8KB should be plenty for endpoint definitions const outPtr = rawExports.allocate(maxLen); const writtenLen = rawExports.http_endpoints(outPtr, maxLen); // Read the string from WASM memory const memory = rawExports.memory; const bytes = new Uint8Array(memory.buffer, outPtr, writtenLen); endpointsJson = new TextDecoder('utf-8').decode(bytes); // Deallocate if (typeof rawExports.deallocate === 'function') { rawExports.deallocate(outPtr, maxLen); } debug(`Got http_endpoints from Rust (${writtenLen} bytes): ${endpointsJson.substring(0, 200)}`); } else { debug(`http_endpoints export exists but plugin type unknown for ${plugin.id}`); return; } } const endpoints = JSON.parse(endpointsJson); debug(`Registering ${endpoints.length} HTTP endpoints for ${plugin.id}`); for (const endpoint of endpoints) { const { method, path: endpointPath, handler } = endpoint; const routeMethod = method.toLowerCase(); if (!['get', 'post', 'put', 'delete'].includes(routeMethod)) { debug(`Skipping unsupported method: ${method}`); continue; } debug(`Registering ${method} ${endpointPath} -> ${handler}`); router[routeMethod](endpointPath, async (req, res) => { // Set a timeout to catch hangs (declare outside try so catch can access it) let timeout = null; try { debug(`HTTP ${method} ${endpointPath} called - req.path: ${req.path}, req.url: ${req.url}`); // SPECIAL CASE: Handle /api/logs directly in Node.js for signalk-logviewer // WASM cannot handle large data streams due to memory buffer limitations (~64KB) if (plugin.id === 'signalk-logviewer' && endpointPath === '/api/logs' && method === 'GET') { debug(`Intercepting /api/logs for logviewer - handling in Node.js`); return handleLogViewerRequest(req, res); } // Build request context for WASM plugin const requestContext = JSON.stringify({ method: req.method, path: req.path, query: req.query, params: req.params, body: req.body, headers: req.headers }); debug(`Calling WASM handler ${handler} with context: ${requestContext.substring(0, 200)}`); // Use AssemblyScript loader if available (handles strings automatically) const asLoader = plugin.instance.asLoader; let responseJson; // Set a timeout to catch hangs // Note: We cannot actually interrupt WASM execution, but we can detect hangs let handlerTimedOut = false; timeout = setTimeout(() => { handlerTimedOut = true; debug(`ERROR: Handler ${handler} exceeded 10 second timeout - responding with error`); debug(`WARNING: WASM execution cannot be interrupted, server may remain partially blocked`); // Send error response even though handler is still running if (!res.headersSent) { res.status(504).json({ error: 'Plugin handler timeout', message: 'The WASM plugin took too long to respond. This indicates a performance issue in the plugin code.' }); } }, 10000); // 10 second hard timeout if (asLoader) { // AssemblyScript plugin with loader - strings handled automatically! debug(`Using AssemblyScript loader for handler ${handler}`); const handlerFunc = asLoader.exports[handler]; if (typeof handlerFunc !== 'function') { debug(`Handler function ${handler} not found in WASM exports`); if (timeout) clearTimeout(timeout); return res .status(500) .json({ error: `Handler function ${handler} not found` }); } // Create an AssemblyScript string in WASM memory using __newString const requestPtr = asLoader.exports.__newString(requestContext); const requestLen = requestContext.length; debug(`Calling handler with string ptr=${requestPtr}, len=${requestLen}`); // Call handler - it returns an AssemblyScript string pointer let asStringPtr; try { debug(`About to call handler function...`); asStringPtr = handlerFunc(requestPtr, requestLen); debug(`Handler function call completed, returned pointer: ${asStringPtr}`); } catch (handlerError) { const handlerErrMsg = handlerError instanceof Error ? handlerError.message : String(handlerError); debug(`ERROR: Handler function threw exception: ${handlerErrMsg}`); debug(`Stack: ${handlerError instanceof Error ? handlerError.stack : 'N/A'}`); throw new Error(`WASM handler crashed: ${handlerErrMsg}`); } // Check if we already sent timeout response if (handlerTimedOut) { debug(`Handler completed after timeout - discarding result`); return; } // Use __getString to decode the AssemblyScript string try { debug(`About to decode string from pointer ${asStringPtr}...`); responseJson = asLoader.exports.__getString(asStringPtr); debug(`String decoded successfully, length: ${responseJson.length}`); debug(`WASM handler returned (via loader): ${responseJson.substring(0, 500)}`); } catch (decodeError) { const decodeErrMsg = decodeError instanceof Error ? decodeError.message : String(decodeError); debug(`ERROR: Failed to decode response string: ${decodeErrMsg}`); throw new Error(`Failed to decode WASM response: ${decodeErrMsg}`); } } else { // Rust plugins use buffer-based string passing debug(`Using raw exports for handler ${handler} (Rust buffer-based)`); const rawExports = plugin.instance.instance.exports; const handlerFunc = rawExports[handler]; if (typeof handlerFunc !== 'function') { debug(`Handler function ${handler} not found in WASM exports`); if (timeout) clearTimeout(timeout); return res .status(500) .json({ error: `Handler function ${handler} not found` }); } // Check if this is a Rust plugin with allocate/deallocate if (typeof rawExports.allocate === 'function') { // Rust buffer-based string passing (same pattern as PUT handlers) const requestBytes = Buffer.from(requestContext, 'utf8'); const requestPtr = rawExports.allocate(requestBytes.length); const responseMaxLen = 65536; // 64KB response buffer const responsePtr = rawExports.allocate(responseMaxLen); // Write request to WASM memory const memory = rawExports.memory; const memView = new Uint8Array(memory.buffer); memView.set(requestBytes, requestPtr); // Call handler: (request_ptr, request_len, response_ptr, response_max_len) -> written_len const writtenLen = handlerFunc(requestPtr, requestBytes.length, responsePtr, responseMaxLen); // Read response from WASM memory const responseBytes = new Uint8Array(memory.buffer, responsePtr, writtenLen); responseJson = new TextDecoder('utf-8').decode(responseBytes); // Deallocate buffers if (typeof rawExports.deallocate === 'function') { rawExports.deallocate(requestPtr, requestBytes.length); rawExports.deallocate(responsePtr, responseMaxLen); } debug(`Rust handler returned ${writtenLen} bytes: ${responseJson.substring(0, 200)}`); } else { // Fallback for unknown plugin types - try direct call responseJson = handlerFunc(requestContext); } } const response = JSON.parse(responseJson); // Set status code and headers res.status(response.statusCode || 200); if (response.headers) { Object.entries(response.headers).forEach(([key, value]) => { res.setHeader(key, value); }); } // Send body - try to parse as JSON if it's a string, otherwise send as-is let body = response.body; if (typeof body === 'string') { const contentType = res.getHeader('content-type'); if (typeof contentType === 'string' && contentType.includes('application/json')) { try { // Try to parse the string as JSON - if it's double-escaped, this will fix it body = JSON.parse(body); } catch (e) { // If parsing fails, send the string as-is (might be plain text) debug(`Warning: Could not parse body as JSON, sending as string: ${e}`); } } } if (timeout) clearTimeout(timeout); debug(`Handler completed successfully, sending response`); res.send(body); } catch (error) { if (timeout) clearTimeout(timeout); const errorMsg = error instanceof Error ? error.message : String(error); const stack = error instanceof Error ? error.stack : 'N/A'; debug(`Error in HTTP endpoint ${method} ${endpointPath}: ${errorMsg}`); debug(`Stack trace: ${stack}`); res.status(500).json({ error: errorMsg }); } }); } } catch (error) { const errorMsg = error instanceof Error ? error.message : String(error); debug(`Failed to register HTTP endpoints: ${errorMsg}`); } debug(`Added plugin-specific routes for ${plugin.id}`); } /** * Set up REST API routes for a WASM plugin */ function setupWasmPluginRoutes(app, plugin, configPath, updateWasmPluginConfig, startWasmPlugin, unloadWasmPlugin, stopWasmPlugin) { const router = express.Router(); // GET /plugins/:id - Get plugin info router.get('/', (req, res) => { res.json({ enabled: plugin.enabled, enabledByDefault: false, id: plugin.id, name: plugin.name, version: plugin.version }); }); // POST /plugins/:id/config - Save plugin configuration router.post('/config', async (req, res) => { try { debug(`POST /config received for WASM plugin: ${plugin.id}`); debug.enabled && debug(`Request body: ${JSON.stringify(req.body)}`); const newConfig = req.body; debug.enabled && debug(`Current plugin state - enabled: ${plugin.enabled}, enableDebug: ${plugin.enableDebug}, configuration: ${JSON.stringify(plugin.configuration)}`); // Update enableDebug FIRST (before saving config) if (typeof newConfig.enableDebug === 'boolean') { debug(`Updating enableDebug from ${plugin.enableDebug} to ${newConfig.enableDebug}`); plugin.enableDebug = newConfig.enableDebug; } // Update enabled state SECOND (before saving config) const enabledChanged = typeof newConfig.enabled === 'boolean' && newConfig.enabled !== plugin.enabled; if (enabledChanged) { debug(`Updating enabled from ${plugin.enabled} to ${newConfig.enabled}`); plugin.enabled = newConfig.enabled; } // Update plugin configuration and save everything to disk debug.enabled && debug(`Calling updateWasmPluginConfig with: ${JSON.stringify(newConfig.configuration)}`); await updateWasmPluginConfig(app, plugin.id, newConfig.configuration, configPath); debug(`updateWasmPluginConfig completed`); // Start or stop plugin if enabled state changed if (enabledChanged) { if (plugin.enabled && plugin.status !== 'running') { // If plugin was disabled at startup, instance will be null - need to load it first if (!plugin.instance) { debug(`Plugin was disabled at startup, loading WASM binary now...`); // Read package.json to get WASM path const packageJson = require(path.join(plugin.packageLocation, plugin.packageName, 'package.json')); const wasmPath = path.join(plugin.packageLocation, plugin.packageName, packageJson.wasmManifest); const capabilities = plugin.metadata.capabilities; // Create VFS root const storagePaths = (0, wasm_storage_1.getPluginStoragePaths)(configPath, plugin.id, plugin.packageName); // Load WASM module const runtime = (0, wasm_runtime_1.getWasmRuntime)(); const instance = await runtime.loadPlugin(plugin.packageName, wasmPath, storagePaths.vfsRoot, capabilities, app); plugin.instance = instance; // Get plugin metadata from WASM exports const pluginName = instance.exports.name(); const schemaJson = instance.exports.schema(); const schema = schemaJson ? JSON.parse(schemaJson) : {}; plugin.name = pluginName; plugin.schema = schema; // Add plugin-specific HTTP endpoints to existing router // (basic routes were already set up when plugin was registered as disabled) setupPluginSpecificRoutes(plugin); debug(`Successfully loaded WASM binary for ${plugin.id}`); } debug(`Plugin enabled, starting...`); await startWasmPlugin(app, plugin.id); } else if (!plugin.enabled && plugin.status === 'running') { debug(`Plugin disabled, stopping...`); await stopWasmPlugin(plugin.id); } } debug(`Final plugin state - enabled: ${plugin.enabled}, status: ${plugin.status}`); const response = `Saved configuration for plugin ${plugin.id}`; debug(`Sending response: ${response}`); res.json(response); } catch (error) { const errorMsg = error instanceof Error ? error.message : String(error); const stack = error instanceof Error ? error.stack : ''; debug(`ERROR saving WASM plugin config: ${errorMsg}`); debug(`Stack trace: ${stack}`); console.error(`Error saving WASM plugin config:`, error); res.status(500).json({ error: errorMsg }); } }); // GET /plugins/:id/config - Get plugin configuration router.get('/config', (req, res) => { const storagePaths = (0, wasm_storage_1.getPluginStoragePaths)(configPath, plugin.id, plugin.packageName); const config = (0, wasm_storage_1.readPluginConfig)(storagePaths.configFile); res.json({ enabled: plugin.enabled, enableDebug: plugin.enableDebug, configuration: plugin.configuration, ...config }); }); // Register the router for this plugin app.use(backwardsCompat(`/plugins/${plugin.id}`), router); // Store router in plugin object for later removal plugin.router = router; // Register custom HTTP endpoints if plugin instance is loaded setupPluginSpecificRoutes(plugin); debug(`Set up REST API routes for WASM plugin: ${plugin.id}`); } //# sourceMappingURL=plugin-routes.js.map