signalk-server
Version:
An implementation of a [Signal K](http://signalk.org) server for boats.
520 lines • 26.7 kB
JavaScript
;
/* 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