diff --git a/src/globalVariables/helper.js b/src/globalVariables/helper.js new file mode 100644 index 0000000..79e7293 --- /dev/null +++ b/src/globalVariables/helper.js @@ -0,0 +1,25 @@ +/** + * Helper Functions Module for Proxy Script + * + * This module contains pure utility/helper functions extracted from proxy.js + * to improve code organization while maintaining vm.Script isolation pattern. + * + * ARCHITECTURE: + * - This file contains the LITERAL BODY of a function + * - server.js wraps this in a function: (function() { })() + * - Function returns a single object containing all helper functions + * - Injected into globalVariableContext for access by proxy.js + * - NO IMPORTS - All dependencies provided via VM context + * + * Globals expected (provided by server.js): + * - crypto: Web Crypto API (for randomUUID()) + * - console: Custom logger + * + * @returns {Object} Helpers object with all utility functions + */ +// ============================================================================= +// Return helpers object with all functions +// ============================================================================= + +return { +}; diff --git a/src/globalVariables/settings.json b/src/globalVariables/settings.json new file mode 100644 index 0000000..2c63c08 --- /dev/null +++ b/src/globalVariables/settings.json @@ -0,0 +1,2 @@ +{ +} diff --git a/src/logger.js b/src/logger.js new file mode 100644 index 0000000..a2287ed --- /dev/null +++ b/src/logger.js @@ -0,0 +1,93 @@ +/** + * Structured Logging Utility + * Provides severity-based logging with JSON output + * + * @module logger + */ + +// Save reference to original console.log before it gets replaced +const originalConsoleLog = globalThis.console.log.bind(globalThis.console); + +/** + * Log levels (in order of severity) + */ +const LOG_LEVELS = { + DEBUG: 0, + INFO: 1, + WARN: 2, + ERROR: 3 +}; + +/** + * Get configured log level from global config + * @returns {number} Log level threshold + */ +function getLogLevel() { + const configLevel = global.config?.logging?.level || 'INFO'; + return LOG_LEVELS[configLevel.toUpperCase()] ?? LOG_LEVELS.INFO; +} + +/** + * Format and pretty print an object + * @param {Object} obj - Object to format + * @returns {string} Formatted object string + */ +function formatObject(obj) { + return JSON.stringify(obj, null, 2); +} + +/** + * Log a message or object + * @param {string} level - Log level (DEBUG|INFO|WARN|ERROR) + * @param {string|Object} data - Log message (string) or structured data (object) + */ +function _log(level, data) { + const levelValue = LOG_LEVELS[level] ?? LOG_LEVELS.INFO; + const threshold = getLogLevel(); + + // Only log if level meets or exceeds threshold + if (levelValue >= threshold) { + let entry; + + if (typeof data === 'string') { + // String input: create structured log entry + entry = { + timestamp: new Date().toISOString(), + level, + message: data + }; + originalConsoleLog(JSON.stringify(entry)); + } else if (typeof data === 'object' && data !== null) { + // Object input: pretty print with timestamp and level + entry = { + timestamp: new Date().toISOString(), + level, + ...data + }; + originalConsoleLog(formatObject(entry)); + } else { + // Fallback: convert to string + entry = { + timestamp: new Date().toISOString(), + level, + message: String(data) + }; + originalConsoleLog(JSON.stringify(entry)); + } + } +} + +/** + * Console-like logging interface + * Exported as 'console' to match standard console API + * + * Usage: + * logger.info("Simple message") + * logger.info({ message: "Structured data", requestId: "123", status: 200 }) + */ +export const logger = { + log: (data) => _log('INFO', data), + debug: (data) => _log('DEBUG', data), + info: (data) => _log('INFO', data), + error: (data) => _log('ERROR', data) +}; diff --git a/src/proxyScripts/proxy.js b/src/proxyScripts/proxy.js new file mode 100644 index 0000000..26d4560 --- /dev/null +++ b/src/proxyScripts/proxy.js @@ -0,0 +1,43 @@ +/** + * + * MONOLITHIC HTTP request handler - ALL functionality in this single file. + * + * CONSTITUTION REQUIREMENT: ZERO export statements - pure IIFE pattern + * File is loaded by server.js using Function constructor + * + * Globals provided by server.js: + * - console: Custom logger + * - crypto: Web Crypto API (provides randomUUID()) + * - axios: HTTP client + * - uuidv4: UUID generator + * - jwt: JSON Web Token library + * - xmlBuilder: XML document builder + * - helper: Helper functions module (loaded from globalVariables/helper.js) + * - settings: Consolidated settings (from global/settings.json) + * - req: HTTP request object (includes req.params with routing info if proxy prefix configured) + * - res: HTTP response object + * + * Structure: + * + * @module proxy + */ + +/** + * Main HTTP request handler + */ +(async () => { + const startTime = Date.now(); + + console.info({ + message: "Request received", + requestId, + method: req.method, + url: req.url, + params: req.params, + query: req.query, + body: req.body + }); + + res.statusCode = 501; + res.end(); +})(); diff --git a/src/server.js b/src/server.js new file mode 100644 index 0000000..f2c13c8 --- /dev/null +++ b/src/server.js @@ -0,0 +1,227 @@ +import http from "node:http"; +import { join } from "node:path"; +import { readFileSync, readdirSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname } from "node:path"; +import vm from "node:vm"; +import axios from "axios"; +import { v4 as uuidv4 } from "uuid"; +import jwt from "jsonwebtoken"; +import { create as xmlBuilder } from "xmlbuilder2"; +import { logger } from "./logger.js"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +const globalVMContext = { + URLSearchParams, + URL, + console: logger, + crypto, + axios, + uuidv4, + jwt, + xmlBuilder, +}; + +let globalVariableContext = {}; + +/** + * Load all files from globalVariables/ directory into globalVariableContext + * Pattern: globalVariables/filename.{json|js} -> globalVariableContext['filename'] + */ +function loadGlobalVariables() { + const globalDir = join(__dirname, "globalVariables"); + const jsonFiles = []; + const jsFiles = []; + + // Scan and categorize files in one pass + readdirSync(globalDir).forEach((file) => { + if (file.includes(".example")) return; + if (file.endsWith(".json")) jsonFiles.push(file); + else if (file.endsWith(".js")) jsFiles.push(file); + }); + + // Load JSON files first (data) + jsonFiles.forEach((file) => { + const varName = file.replace(".json", ""); + const data = JSON.parse(readFileSync(join(globalDir, file), "utf-8")); + globalVariableContext[varName] = data; + logger.info({ + message: `Loaded global data: ${varName}`, + keys: Object.keys(data) + }); + }); + + // Load JS files second (functions can reference JSON data) + jsFiles.forEach((file) => { + const varName = file.replace(".js", ""); + const code = readFileSync(join(globalDir, file), "utf-8"); + + // Wrap the literal function body in a function and execute + const wrappedCode = `(function() {\n${code}\n})()`; + const script = new vm.Script(wrappedCode, { filename: file }); + const context = vm.createContext({ ...globalVMContext, ...globalVariableContext }); + + // Execute script and capture returned object + const returnedObject = script.runInContext(context); + globalVariableContext[varName] = returnedObject; + + logger.info({ + message: `Loaded global functions: ${varName}`, + type: typeof returnedObject, + isObject: typeof returnedObject === 'object' && returnedObject !== null, + keys: returnedObject ? Object.keys(returnedObject).length : 0 + }); + }); + + logger.info({ + message: `Loaded ${jsonFiles.length + jsFiles.length} global variables`, + json: jsonFiles.length, + js: jsFiles.length + }); +} + +/** + * Load configuration from config/default.json and merge with environment variables + */ +function loadConfig() { + const configPath = join(__dirname, "..", "config", "default.json"); + const configData = readFileSync(configPath, "utf-8"); + const config = JSON.parse(configData); + + // Merge environment variables (ENV takes precedence) + return { + ...config, + server: { + ...config.server, + port: process.env.PORT ? parseInt(process.env.PORT, 10) : config.server.port, + host: process.env.HOST || config.server.host, + }, + proxy: { + ...config.proxy, + }, + logging: { + ...config.logging, + level: process.env.LOG_LEVEL || config.logging.level, + }, + }; +} + +/** + * Validate configuration + */ +function validateConfig(config) { + if (!config.server.port || config.server.port < 1 || config.server.port > 65535) { + throw new Error("Invalid server.port (must be 1-65535)"); + } +} + +/** + * Start the HTTP server + */ +async function startServer() { + try { + // Load configuration into global.config + global.config = loadConfig(); + + // Load all global variables (JSON data + JS function modules) + loadGlobalVariables(); + + logger.info("Starting Proxy Script Server..."); + logger.info({ + message: "Configuration loaded", + port: global.config.server.port, + host: global.config.server.host, + logLevel: global.config.logging.level, + proxyPrefix: global.config.proxy ? + `${global.config.proxy.pathPrefix}${global.config.proxy.workspaceId}/${global.config.proxy.branch}/${global.config.proxy.routeName}` : + '(none)' + }); + + // Validate configuration + validateConfig(global.config); + logger.info("Configuration validated successfully"); + + const proxyPath = join(__dirname, "proxyScripts", "proxy.js"); + const proxyCode = readFileSync(proxyPath, "utf-8"); + const script = new vm.Script(proxyCode, { filename: "proxy.js" }); + + // Create HTTP server that delegates all requests to proxy + const server = http.createServer((req, res) => { + try { + // Extract proxy routing metadata from config (not parsed from URL) + // and attach to req.params if URL matches the configured prefix + if (global.config.proxy) { + const { pathPrefix, workspaceId, branch, routeName } = global.config.proxy; + const fullPrefix = `${pathPrefix.replace(/\/$/, '')}/${workspaceId}/${branch}/${routeName}`; + + // Check if URL starts with proxy prefix + if (req.url.startsWith(fullPrefix)) { + // Add routing metadata to request for proxy.js + // All values come from config, not parsed from URL + req.params = { + "0": req.url, // Original full path + workspaceId, // From config.proxy.workspaceId + branch, // From config.proxy.branch + route: routeName // From config.proxy.routeName (renamed to 'route' for consistency) + }; + } + } + + const context = vm.createContext({ + ...globalVMContext, + ...globalVariableContext, + req, + res, + }); + script.runInContext(context); + } catch (error) { + logger.error({ + message: "Request handling failed", + error: error.message, + stack: error.stack + }); + res.statusCode = 500; + res.end("Internal Server Error"); + } + }); + + // Graceful shutdown + const shutdown = () => { + logger.info("\nShutting down gracefully..."); + server.close(() => { + logger.info("Server closed"); + process.exit(0); + }); + + // Force shutdown after 10 seconds + setTimeout(() => { + logger.error("Forced shutdown after timeout"); + process.exit(1); + }, 10000); + }; + + process.on("SIGTERM", shutdown); + process.on("SIGINT", shutdown); + + // Start listening + server.listen(global.config.server.port, global.config.server.host, () => { + logger.info({ + message: "Server listening", + port: global.config.server.port, + host: global.config.server.host + }); + }); + } catch (error) { + logger.error({ + message: "Failed to start server", + error: error.message, + stack: error.stack + }); + process.exit(1); + } +} + +// Start the server +startServer();