UNPKG

web_plsql

Version:

The Express Middleware for Oracle PL/SQL

1,684 lines (1,663 loc) 113 kB
import { t as __exportAll } from "./chunk-DQk6qfdC.mjs"; import { n as setExecuteCallback } from "./oracledb-mock-Dn8aHtd7.mjs"; import z$1, { z } from "zod"; import oracledb from "oracledb"; import debugModule from "debug"; import http from "node:http"; import https from "node:https"; import express, { Router } from "express"; import cors from "cors"; import cookieParser from "cookie-parser"; import compression from "compression"; import expressStaticGzip from "express-static-gzip"; import os from "node:os"; import url, { URL, fileURLToPath } from "node:url"; import util from "node:util"; import stream, { Readable } from "node:stream"; import fs, { existsSync, promises, readFileSync } from "node:fs"; import * as rotatingFileStream from "rotating-file-stream"; import path from "node:path"; import morgan from "morgan"; import multer from "multer"; import readline from "node:readline"; //#region src/common/configStaticSchema.ts /** * Configuration for serving static files */ const configStaticSchema = z$1.strictObject({ route: z$1.string(), directoryPath: z$1.string(), spaFallback: z$1.boolean().optional() }); //#endregion //#region src/common/procedureTraceEntry.ts const procedureTraceEntrySchema = z.strictObject({ id: z.string(), timestamp: z.string(), source: z.string(), url: z.string(), method: z.string(), status: z.string(), duration: z.number(), procedure: z.string().optional(), parameters: z.union([z.record(z.string(), z.unknown()), z.array(z.unknown())]).optional(), uploads: z.array(z.strictObject({ originalname: z.string(), mimetype: z.string(), size: z.number() })).optional(), downloads: z.strictObject({ fileType: z.string(), fileSize: z.number() }).optional(), html: z.string().optional(), cookies: z.record(z.string(), z.string()).optional(), headers: z.record(z.string(), z.string()).optional(), cgi: z.record(z.string(), z.string()).optional(), error: z.string().optional() }); //#endregion //#region src/common/logEntrySchema.ts /** * Error log entry schema. */ const logEntryTypeSchema = z.union([ z.literal("error"), z.literal("info"), z.literal("warning") ]); const logEntrySchema = z.strictObject({ timestamp: z.string(), type: logEntryTypeSchema, message: z.string(), req: z.strictObject({ method: z.string().optional(), url: z.string().optional(), ip: z.string().optional(), userAgent: z.string().optional() }).optional(), details: z.strictObject({ fullMessage: z.string().optional(), sql: z.string().optional(), bind: z.unknown().optional(), environment: z.record(z.string(), z.string()).optional() }).optional() }); //#endregion //#region src/backend/types.ts /** * Defines the style of error reporting * 'basic': standard error messages * 'debug': detailed error messages including database context */ const z$errorStyleType = z$1.enum(["basic", "debug"]); /** * Defines how transactions are handled after procedure execution * 'commit': automatically commit * 'rollback': automatically rollback * callback: custom function for manual handling */ const transactionModeSchema = z$1.union([ z$1.custom((val) => typeof val === "function", { message: "Invalid transaction callback" }), z$1.literal("commit"), z$1.literal("rollback"), z$1.undefined(), z$1.null() ]); /** * Authentication configuration for a PL/SQL route */ const z$authSchema = z$1.strictObject({ type: z$1.literal("basic"), callback: z$1.custom((val) => typeof val === "function", { message: "Invalid auth callback" }), realm: z$1.string().optional() }); /** * PL/SQL handler behavior configuration */ const z$configPlSqlHandlerType = z$1.strictObject({ defaultPage: z$1.string(), pathAlias: z$1.string().optional(), pathAliasProcedure: z$1.string().optional(), documentTable: z$1.string(), exclusionList: z$1.array(z$1.string()).optional(), requestValidationFunction: z$1.string().optional(), transactionMode: transactionModeSchema.optional(), errorStyle: z$errorStyleType, cgi: z$1.record(z$1.string(), z$1.string()).optional(), auth: z$authSchema.optional() }); /** * Database connection configuration for a PL/SQL route */ const z$configPlSqlConfigType = z$1.strictObject({ route: z$1.string(), user: z$1.string(), password: z$1.string(), connectString: z$1.string() }); const z$configPlSqlType = z$1.strictObject({ ...z$configPlSqlHandlerType.shape, ...z$configPlSqlConfigType.shape }); /** * Root application configuration */ const z$configType = z$1.strictObject({ port: z$1.number(), routeStatic: z$1.array(configStaticSchema), routePlSql: z$1.array(z$configPlSqlType), uploadFileSizeLimit: z$1.number().optional(), loggerFilename: z$1.string(), adminRoute: z$1.string().optional(), adminUser: z$1.string().optional(), adminPassword: z$1.string().optional(), devMode: z$1.boolean().optional() }); //#endregion //#region src/backend/util/oracledb-provider.ts var oracledb_provider_exports = /* @__PURE__ */ __exportAll({ BIND_IN: () => BIND_IN, BIND_INOUT: () => BIND_INOUT, BIND_OUT: () => BIND_OUT, BLOB: () => BLOB, BUFFER: () => BUFFER, CLOB: () => CLOB, CURSOR: () => CURSOR, DATE: () => DATE, DB_TYPE_CLOB: () => DB_TYPE_CLOB, DB_TYPE_DATE: () => DB_TYPE_DATE, DB_TYPE_NUMBER: () => DB_TYPE_NUMBER, DB_TYPE_VARCHAR: () => DB_TYPE_VARCHAR, NUMBER: () => NUMBER, STRING: () => STRING, createPool: () => createPool, setExecuteCallback: () => setExecuteCallback }); const USE_MOCK = process.env.MOCK_ORACLE === "true"; /** * Create a database pool. * @param config - The pool attributes. * @returns The pool. */ async function createPool(config) { if (USE_MOCK) return (await import("./oracledb-mock-Dn8aHtd7.mjs").then((n) => n.t)).createPool(config); return await oracledb.createPool(config); } const BIND_IN = oracledb.BIND_IN; const BIND_OUT = oracledb.BIND_OUT; const BIND_INOUT = oracledb.BIND_INOUT; const STRING = oracledb.STRING; const NUMBER = oracledb.NUMBER; const DATE = oracledb.DATE; const CURSOR = oracledb.CURSOR; const BUFFER = oracledb.BUFFER; const CLOB = oracledb.CLOB; const BLOB = oracledb.BLOB; const DB_TYPE_VARCHAR = oracledb.DB_TYPE_VARCHAR; const DB_TYPE_CLOB = oracledb.DB_TYPE_CLOB; const DB_TYPE_NUMBER = oracledb.DB_TYPE_NUMBER; const DB_TYPE_DATE = oracledb.DB_TYPE_DATE; //#endregion //#region src/backend/version.ts globalThis.__VERSION__ ??= "**development**"; /** * Returns the current library version * @returns {string} - Version. */ const getVersion = () => "1.3.1"; //#endregion //#region src/backend/server/config.ts const paddedLine = (title, value) => { console.log(`${(title + ":").padEnd(30)} ${value}`); }; /** * Show configuration. * @param config - The config. */ const showConfig = (config) => { const LINE = "-".repeat(80); console.log(LINE); console.log(`NODE PL/SQL SERVER version ${getVersion()}`); console.log(LINE); paddedLine("Server port", config.port); paddedLine("Admin route", `${config.adminRoute ?? "/admin"}${config.adminUser ? " (authenticated)" : ""}`); paddedLine("Access log", config.loggerFilename.length > 0 ? config.loggerFilename : "-"); paddedLine("Upload file size limit", typeof config.uploadFileSizeLimit === "number" ? `${config.uploadFileSizeLimit} bytes` : "-"); if (config.routeStatic.length > 0) config.routeStatic.forEach((e) => { paddedLine("Static route", e.route); paddedLine("Directory path", e.directoryPath); }); if (config.routePlSql.length > 0) config.routePlSql.forEach((e) => { let transactionMode = ""; if (typeof e.transactionMode === "string") transactionMode = e.transactionMode; else if (typeof e.transactionMode === "function") transactionMode = "custom callback"; paddedLine("Route", `http://localhost:${config.port}${e.route}`); paddedLine("Oracle user", e.user); paddedLine("Oracle server", e.connectString); paddedLine("Oracle document table", e.documentTable); paddedLine("Default page", e.defaultPage.length > 0 ? e.defaultPage : "-"); paddedLine("Path alias", e.pathAlias ?? "-"); paddedLine("Path alias procedure", e.pathAliasProcedure ?? "-"); paddedLine("Exclution list", e.exclusionList ? e.exclusionList.join(", ") : "-"); paddedLine("Validation function", e.requestValidationFunction ?? "-"); paddedLine("After request handler", transactionMode.length > 0 ? transactionMode : "-"); paddedLine("Error style", e.errorStyle); }); console.log(LINE); const baseUrl = `http://localhost:${config.port}`; paddedLine("🏠 Admin Console", `${baseUrl}${config.adminRoute ?? "/admin"}`); if (config.routePlSql.length > 0) { console.log(""); console.log("⚙️ PL/SQL Gateways:"); config.routePlSql.forEach((e) => { console.log(` ${e.route.padEnd(28)} ${baseUrl}${e.route}`); }); } console.log(LINE); }; //#endregion //#region src/backend/server/server.ts const debug$14 = debugModule("webplsql:server"); /** * Close multiple pools. * @param pools - The pools to close. */ const poolsClose = async (pools) => { await Promise.all(pools.map((pool) => pool.close(0))); }; /** * Create HTTPS server. * @param app - express application * @param ssl - ssl configuration. * @returns server */ const createServer = (app, ssl) => { if (ssl) { const key = readFileSyncUtf8(ssl.keyFilename); const cert = readFileSyncUtf8(ssl.certFilename); return https.createServer({ key, cert }, app); } else return http.createServer(app); }; /** * Start server. * @param config - The config. * @param ssl - ssl configuration. * @returns Promise resolving to the web server object. */ const startServer = async (config, ssl) => { debug$14("startServer: BEGIN", config, ssl); const internalConfig = z$configType.parse(config); showConfig(internalConfig); const app = express(); if (internalConfig.devMode) app.use(cors({ origin: "http://localhost:5173", credentials: true })); app.use(handlerUpload(internalConfig.uploadFileSizeLimit)); app.use(express.json({ limit: "50mb" })); app.use(express.urlencoded({ limit: "50mb", extended: true })); app.use(cookieParser()); app.use(compression()); const adminContext = new AdminContext(internalConfig); app.use(handlerAdminConsole({ adminRoute: internalConfig.adminRoute, user: internalConfig.adminUser, password: internalConfig.adminPassword, devMode: internalConfig.devMode }, adminContext)); for (const i of internalConfig.routePlSql) { const handler = handlerWebPlSql(await createPool({ user: i.user, password: i.password, connectString: i.connectString }), i, adminContext); app.use([`${i.route}/:name`, i.route], (req, res, next) => { const start = process.hrtime(); res.on("finish", () => { const diff = process.hrtime(start); const duration = diff[0] * 1e3 + diff[1] / 1e6; adminContext.statsManager.recordRequest(duration, res.statusCode >= 400); }); handler(req, res, next); }); } if (internalConfig.loggerFilename.length > 0) app.use(handlerLogger(internalConfig.loggerFilename)); for (const i of internalConfig.routeStatic) { app.use(i.route, expressStaticGzip(i.directoryPath, { enableBrotli: true, orderPreference: ["br"] })); if (i.spaFallback) app.use(i.route, createSpaFallback(i.directoryPath, i.route)); } debug$14("startServer: createServer"); const server = createServer(app, ssl); const connections = /* @__PURE__ */ new Set(); server.on("connection", (socket) => { connections.add(socket); socket.on("close", () => { connections.delete(socket); }); }); const closeAllConnections = () => { for (const socket of connections) { socket.destroy(); connections.delete(socket); } }; const shutdown = async () => { debug$14("startServer: onShutdown"); adminContext.statsManager.stop(); await poolsClose(adminContext.pools); server.close(() => { console.log("Server has closed"); process.exit(0); }); closeAllConnections(); }; installShutdown(shutdown); debug$14("startServer: start listener"); await new Promise((resolve, reject) => { server.listen(internalConfig.port).on("listening", () => { debug$14("startServer: listener running"); resolve(); }).on("error", (err) => { if ("code" in err) { if (err.code === "EADDRINUSE") err.message = `Port ${internalConfig.port} is already in use`; else if (err.code === "EACCES") err.message = `Port ${internalConfig.port} requires elevated privileges`; } reject(err); }); }); debug$14("startServer: END"); return { config: internalConfig, connectionPools: adminContext.pools, app, server, adminContext, shutdown }; }; /** * Load configuration. * @param filename - The configuration filename. * @returns Promise. */ const loadConfig = (filename = "config.json") => z$configType.parse(getJsonFile(filename)); /** * Start server from config file. * @param filename - The configuration filename. * @param ssl - ssl configuration. * @returns Promise resolving to the web server object. */ const startServerConfig = async (filename = "config.json", ssl) => startServer(loadConfig(filename), ssl); //#endregion //#region src/backend/util/shutdown.ts const debug$13 = debugModule("webplsql:shutdown"); /** * Install a shutdown handler. * @param handler - Shutdown handler */ const installShutdown = (handler) => { debug$13("installShutdown"); let isShuttingDown = false; process.on("unhandledRejection", (reason) => { if (isShuttingDown) return; isShuttingDown = true; if (reason instanceof Error) console.error(`\n${reason.message}. Graceful shutdown...`); else console.error("\nUnhandled promise rejection. Graceful shutdown...", reason); handler().catch((err) => { console.error("Error during shutdown:", err); process.exit(1); }); }); process.on("SIGTERM", function sigterm() { if (isShuttingDown) return; isShuttingDown = true; console.log("\nGot SIGTERM (aka docker container stop). Graceful shutdown..."); handler().catch((err) => { console.error("Error during shutdown:", err); process.exit(1); }); }); process.on("SIGINT", function sigint() { if (isShuttingDown) return; isShuttingDown = true; console.log("\nGot SIGINT (aka ctrl-c in docker). Graceful shutdown..."); handler().catch((err) => { console.error("Error during shutdown:", err); process.exit(1); }); }); }; /** * Force a shutdown. */ const forceShutdown = () => { debug$13("forceShutdown"); process.kill(process.pid, "SIGTERM"); }; //#endregion //#region src/common/constants.ts /** * Web PL/SQL Gateway - Common Shared Constants * * This file centralizes all hardcoded numeric and string constants used throughout * the application. Constants are organized by functional category. */ /** * DEFAULT_CACHE_MAX_SIZE = 10000 * * Purpose: Maximum number of entries in the generic LFU (Least Frequently Used) cache. * * Used By: * - procedureNameCache: Caches resolved Oracle procedure names (e.g., "HR.EMPLOYEES") * - argumentCache: Caches procedure argument introspection results from all_arguments view * * Related Values: * - CACHE_PRUNE_PERCENT (0.1): When cache is full, removes 10% = 1000 entries * - Cache instantiation in src/handler/plsql/handlerPlSql.js creates new Cache() without params * * Implications: * - Memory footprint: ~1-2MB at max capacity (strings + hitCount metadata) * - Pruning: Removes least-frequently-used entries when full * - Higher values = better cache hit rates but more memory */ const DEFAULT_CACHE_MAX_SIZE = 1e4; /** * CACHE_PRUNE_PERCENT = 0.1 * * Purpose: Fraction of cache entries to remove during pruning (10%). * * Used By: Cache.prune() method only * * Related Values: * - DEFAULT_CACHE_MAX_SIZE (10000): Applied to this value to calculate removeCount = 1000 * * Implications: * - Balances between removing too few entries (frequent pruning) vs too many (evicting useful data) * - 10% is a common pattern for cache eviction */ const CACHE_PRUNE_PERCENT = .1; /** * MAX_PROCEDURE_PARAMETERS = 1000 * * Purpose: Maximum number of procedure arguments that can be introspected from Oracle's * all_arguments view using BULK COLLECT with dbms_utility.name_resolve. * * Used By: * - src/handler/plsql/procedureNamed.js * bind.names = {maxArraySize: MAX_PARAMETER_NUMBER} * bind.types = {maxArraySize: MAX_PARAMETER_NUMBER} * * Related Values: * - Procedure introspection SQL: SQL_GET_ARGUMENT block at procedureNamed.js:27-43 * - oracledb.BIND_OUT direction for array fetches * * Implications: * - This is an Oracle driver limitation for array binding, not an arbitrary choice * - Procedures with >1000 arguments will have introspection truncated * - No error handling exists for this edge case * - Practical limit: most procedures have <50 arguments */ const MAX_PROCEDURE_PARAMETERS = 1e3; /** * OWA_STREAM_CHUNK_SIZE = 1000 * * Purpose: Number of lines fetched per Oracle OWA call when streaming page content. * * Used By: * - owaPageStream.js: maxArraySize for :lines bind variable * - owaPageStream.js: :irows INOUT parameter value * - owaPageStream.js: Determines when streaming is complete (lines.length < chunkSize) * * Related Values: * - OWA_GET_PAGE_SQL: 'BEGIN owa.get_page(thepage=>:lines, irows=>:irows); END;' * - OWAPageStream class constructor at line 20 * * Implications: * - Controls round-trip frequency to Oracle database * - Higher = fewer round-trips but larger memory buffers per fetch * - Lower = more responsive streaming but more database calls * - Each line is a PL/SQL varchar2; total data per chunk depends on htp.htbuf_len (63 chars) * - Estimated max data per chunk: 1000 lines × 63 chars = 63KB */ const OWA_STREAM_CHUNK_SIZE = 1e3; /** * OWA_STREAM_BUFFER_SIZE = 16384 * * Purpose: Node.js Readable stream highWaterMark in bytes (16KB). * * Used By: OWAPageStream class extends Readable stream * * Related Values: * - OWA_STREAM_CHUNK_SIZE (1000): Lines per fetch * - Default Node.js highWaterMark is 64KB (Readable stream default) * - OWAPageStream.push() converts lines to string buffer * * Implications: * - Smaller than default (64KB) = more frequent _read() callbacks * - Reduces memory footprint for large responses * - Improves backpressure handling responsiveness * - Trade-off: More CPU for _read() calls vs memory efficiency */ const OWA_STREAM_BUFFER_SIZE = 16384; /** * OWA_RESOLVED_NAME_MAX_LEN = 400 * * Purpose: Maximum string length for resolved Oracle procedure canonical names. * Canonical format: SCHEMA.PACKAGE.PROCEDURE or SCHEMA.PROCEDURE. * * Used By: * - resolveProcedureName() function for dbms_utility.name_resolve output * - Procedure name resolution SQL at procedureSanitize.js:46-76 * * Related Values: * - dbms_utility.name_resolve context = 1 (procedure/function resolution) * - Oracle identifier limits: Schema (128) + Package (128) + Procedure (128) + 2 dots = ~386 * - 400 provides comfortable headroom * * Implications: * - Oracle object names: 30 bytes for most, extended to 128 in some contexts * - Canonical name: schema.package.procedure (max ~128+1+128+1+128 = 386) * - 400 is safe upper bound with margin */ const OWA_RESOLVED_NAME_MAX_LEN = 400; /** * STATS_INTERVAL_MS = 5000 * * Purpose: Duration of each statistical bucket in milliseconds. * * Used By: * - src/util/statsManager.js:165: setInterval(this.rotateBucket, this.config.intervalMs) * - src/handler/handlerAdmin.js:123: Exposed as intervalMs in /api/status response * - src/frontend/main.ts * - src/frontend/charts.ts * * Related Values: * - MAX_HISTORY_BUCKETS (1000): At 5s per bucket = ~83 minutes of history * - MAX_PERCENTILE_SAMPLES (1000): Samples per bucket for P95/P99 * * Implications: * - Bucket aggregation: request counts, durations, errors, system metrics * - Affects granularity of performance monitoring * - Lower values = more granular but more history entries * - Higher values = smoother averages but less detail */ const STATS_INTERVAL_MS = 5e3; /** * MAX_HISTORY_BUCKETS = 1000 * * Purpose: Maximum number of statistical buckets retained in StatsManager ring buffer. * * Used By: * - src/util/statsManager.js: Ring buffer limit check * if (this.history.length > this.config.maxHistoryPoints) { this.history.shift(); } * - Exposed via /api/stats/history endpoint * * Related Values: * - STATS_INTERVAL_MS (5000): 5s per bucket = ~83 minutes total history * - Each bucket contains: timestamp, requestCount, errors, durations, system metrics * - Bucket memory estimate: ~100 bytes × 1000 = ~100KB * * Implications: * - Ring buffer: oldest bucket is removed when new one exceeds limit * - Affects admin console chart history display * - Higher = more historical context but more memory */ const MAX_HISTORY_BUCKETS = 1e3; /** * MAX_PERCENTILE_SAMPLES = 1000 * * Purpose: Maximum number of request duration samples collected per bucket * for calculating P95/P99 percentiles. * * Used By: * - src/util/statsManager.js: Array length check * if (b.durations.length < this.config.percentilePrecision) * - src/util/statsManager.js: P95/P99 calculation * * Related Values: * - Percentile calculation: floor(length × 0.95) and floor(length × 0.99) * - FIFO replacement: When exceeded, new samples replace oldest * * Implications: * - With 1000 samples, P95/P99 are statistically meaningful * - Higher = more accurate percentiles but more memory per bucket * - With STATS_INTERVAL_MS = 5000, 1000 samples ≈ 5 req/sec sustained * - Under heavy load, older samples are discarded (FIFO) */ const MAX_PERCENTILE_SAMPLES = 1e3; /** * SHUTDOWN_GRACE_DELAY_MS = 100 * * Purpose: Delay between initiating server shutdown and forced termination. * * Used By: POST /api/server/stop handler only * * Related Values: * - forceShutdown() at src/util/shutdown.js * - SIGTERM/SIGINT handlers at shutdown.js * * Implications: * - Allows graceful completion of in-flight requests * - Gives Express middleware time to send final responses * - 100ms is short; may be insufficient under heavy load * - Consider making configurable for high-traffic deployments */ const SHUTDOWN_GRACE_DELAY_MS = 100; /** * TRACE_LOG_ROTATION_SIZE = '10M' * * Purpose: Log file size threshold triggering trace log rotation (10 Megabytes). * * Used By: rotating-file-stream library for 'trace.log' * * Related Values: * - TRACE_LOG_ROTATION_INTERVAL ('1d'): Also triggers rotation * - TRACE_LOG_MAX_ROTATED_FILES (10): Maximum retained files * * Implications: * - When either size OR time threshold is reached, rotation occurs * - Combined with daily rotation: ~10MB/day minimum * - gzip compression reduces rotated file size by ~70-90% */ const TRACE_LOG_ROTATION_SIZE = "10M"; /** * TRACE_LOG_ROTATION_INTERVAL = '1d' * * Purpose: Time-based trace log rotation trigger (daily). * * Used By: rotating-file-stream library for 'trace.log' * * Implications: * - Guarantees at least one rotation per day * - Midnight-based or 24h from first write */ const TRACE_LOG_ROTATION_INTERVAL = "1d"; /** * TRACE_LOG_MAX_ROTATED_FILES = 10 * * Purpose: Maximum number of rotated trace log files to retain. * * Used By: rotating-file-stream library for 'trace.log' * * Implications: * - When exceeded, oldest rotated file is deleted * - Maximum: ~10 files × 10MB = ~100MB (compressed: ~10-30MB) */ const TRACE_LOG_MAX_ROTATED_FILES = 10; /** * JSON_LOG_ROTATION_SIZE = '10M' * * Purpose: Log file size threshold triggering JSON error log rotation (10 Megabytes). * * Used By: rotating-file-stream library for 'error.json.log' * * Related Values: * - JSON_LOG_ROTATION_INTERVAL ('1d'): Also triggers rotation * - JSON_LOG_MAX_ROTATED_FILES (10): Maximum retained files * * Implications: * - When either size OR time threshold is reached, rotation occurs * - Combined with daily rotation: ~10MB/day minimum * - gzip compression reduces rotated file size by ~70-90% */ const JSON_LOG_ROTATION_SIZE = "10M"; /** * JSON_LOG_ROTATION_INTERVAL = '1d' * * Purpose: Time-based JSON error log rotation trigger (daily). * * Used By: rotating-file-stream library for 'error.json.log' * * Implications: * - Guarantees at least one rotation per day * - Midnight-based or 24h from first write */ const JSON_LOG_ROTATION_INTERVAL = "1d"; /** * JSON_LOG_MAX_ROTATED_FILES = 10 * * Purpose: Maximum number of rotated JSON error log files to retain. * * Used By: rotating-file-stream library for 'error.json.log' * * Implications: * - When exceeded, oldest rotated file is deleted * - Maximum: ~10 files × 10MB = ~100MB (compressed: ~10-30MB) */ const JSON_LOG_MAX_ROTATED_FILES = 10; //#endregion //#region src/backend/util/statsManager.ts const debug$12 = debugModule("webplsql:statsManager"); /** * Manager for statistical data collection and temporal bucketing. */ var StatsManager = class { config; startTime; history; lifetime; _currentBucket; _lastCpuTimes; _timer; /** * @param config - Configuration. */ constructor(config = {}) { this.config = { intervalMs: STATS_INTERVAL_MS, maxHistoryPoints: MAX_HISTORY_BUCKETS, sampleSystem: true, samplePools: true, percentilePrecision: MAX_PERCENTILE_SAMPLES, ...config }; this.startTime = /* @__PURE__ */ new Date(); this.history = []; this.lifetime = { totalRequests: 0, totalErrors: 0, minDuration: -1, maxDuration: -1, totalDuration: 0, maxRequestsPerSecond: 0, memory: { heapUsedMax: 0, heapTotalMax: 0, rssMax: 0, externalMax: 0 }, cpu: { max: 0, userMax: 0, systemMax: 0 } }; this._currentBucket = { count: 0, errors: 0, durations: [], durationSum: 0, durationMin: -1, durationMax: -1 }; this._lastCpuTimes = this._getSystemCpuTimes(); this._timer = void 0; if (this.config.sampleSystem) { this._timer = setInterval(() => { this.rotateBucket(); }, this.config.intervalMs); if (this._timer && typeof this._timer.unref === "function") this._timer.unref(); } } /** * Reset the current bucket accumulator. */ _resetBucket() { this._currentBucket = { count: 0, errors: 0, durations: [], durationSum: 0, durationMin: -1, durationMax: -1 }; } /** * Record a request event. * @param duration - Duration in milliseconds. * @param isError - Whether the request was an error. */ recordRequest(duration, isError = false) { this.lifetime.totalRequests++; if (isError) this.lifetime.totalErrors++; this.lifetime.totalDuration += duration; if (this.lifetime.minDuration < 0 || duration < this.lifetime.minDuration) this.lifetime.minDuration = duration; if (this.lifetime.maxDuration < 0 || duration > this.lifetime.maxDuration) this.lifetime.maxDuration = duration; const b = this._currentBucket; b.count++; if (isError) b.errors++; b.durationSum += duration; if (b.durationMin < 0 || duration < b.durationMin) b.durationMin = duration; if (b.durationMax < 0 || duration > b.durationMax) b.durationMax = duration; if (b.durations.length < this.config.percentilePrecision) b.durations.push(duration); } /** * Get system CPU times. * @returns System CPU times. */ _getSystemCpuTimes() { const cpus = os.cpus(); let user = 0; let nice = 0; let sys = 0; let idle = 0; let irq = 0; for (const cpu of cpus) { user += cpu.times.user; nice += cpu.times.nice; sys += cpu.times.sys; idle += cpu.times.idle; irq += cpu.times.irq; } const total = user + nice + sys + idle + irq; return { user, nice, sys, idle, irq, total }; } /** * Calculate CPU usage percentage since last call. * @returns CPU usage percentage (0-100). */ _calculateCpuUsage() { const current = this._getSystemCpuTimes(); const last = this._lastCpuTimes || { user: 0, nice: 0, sys: 0, idle: 0, irq: 0, total: 0 }; const deltaTotal = current.total - last.total; const deltaIdle = current.idle - last.idle; this._lastCpuTimes = current; if (deltaTotal <= 0) return 0; const percent = (deltaTotal - deltaIdle) / deltaTotal * 100; return Math.min(100, Math.max(0, percent)); } /** * Rotate the current bucket into history and start a new one. * @param poolSnapshots - Optional pool snapshots to include. */ rotateBucket(poolSnapshots = []) { const b = this._currentBucket; const memUsage = process.memoryUsage(); const systemMemoryUsed = os.totalmem() - os.freemem(); const cpuUsage = process.cpuUsage(); const cpu = this._calculateCpuUsage(); const reqPerSec = b.count / (this.config.intervalMs / 1e3); this.lifetime.maxRequestsPerSecond = Math.max(this.lifetime.maxRequestsPerSecond, reqPerSec); this.lifetime.memory.heapUsedMax = Math.max(this.lifetime.memory.heapUsedMax, memUsage.heapUsed); this.lifetime.memory.heapTotalMax = Math.max(this.lifetime.memory.heapTotalMax, memUsage.heapTotal); this.lifetime.memory.rssMax = Math.max(this.lifetime.memory.rssMax, systemMemoryUsed); this.lifetime.memory.externalMax = Math.max(this.lifetime.memory.externalMax, memUsage.external); this.lifetime.cpu.max = Math.max(this.lifetime.cpu.max, cpu); this.lifetime.cpu.userMax = Math.max(this.lifetime.cpu.userMax, cpuUsage.user); this.lifetime.cpu.systemMax = Math.max(this.lifetime.cpu.systemMax, cpuUsage.system); let p95 = 0; let p99 = 0; if (b.durations.length > 0) { const sorted = b.durations.toSorted((x, y) => x - y); const p95Idx = Math.floor(sorted.length * .95); const p99Idx = Math.floor(sorted.length * .99); const lastIdx = sorted.length - 1; p95 = sorted[p95Idx] ?? sorted[lastIdx] ?? 0; p99 = sorted[p99Idx] ?? sorted[lastIdx] ?? 0; } const bucket = { timestamp: Date.now(), requests: b.count, errors: b.errors, durationMin: Math.max(b.durationMin, 0), durationMax: Math.max(b.durationMax, 0), durationAvg: b.count > 0 ? b.durationSum / b.count : 0, durationP95: p95, durationP99: p99, system: { cpu, heapUsed: memUsage.heapUsed, heapTotal: memUsage.heapTotal, rss: systemMemoryUsed, external: memUsage.external }, pools: poolSnapshots }; this.history.push(bucket); if (this.history.length > this.config.maxHistoryPoints) this.history.shift(); this._resetBucket(); debug$12("Bucket rotated: %j", bucket); } /** * Stop the background timer. */ stop() { if (this._timer) { clearInterval(this._timer); this._timer = void 0; } } /** * Get lifetime summary. * @returns Summary. */ getSummary() { return { startTime: this.startTime, totalRequests: this.lifetime.totalRequests, totalErrors: this.lifetime.totalErrors, avgResponseTime: this.lifetime.totalRequests > 0 ? this.lifetime.totalDuration / this.lifetime.totalRequests : 0, minResponseTime: this.lifetime.minDuration, maxResponseTime: this.lifetime.maxDuration, maxRequestsPerSecond: this.lifetime.maxRequestsPerSecond, maxMemory: this.lifetime.memory, cpu: this.lifetime.cpu }; } /** * Get history buffer. * @returns The history buffer. */ getHistory() { return this.history; } }; //#endregion //#region src/backend/server/adminContext.ts /** * Admin Context Class */ var AdminContext = class { startTime; config; pools; caches; statsManager; _paused; constructor(config) { this.startTime = /* @__PURE__ */ new Date(); this.config = config; this.pools = []; this.caches = []; this.statsManager = new StatsManager(); this._paused = false; } /** * Register a PL/SQL handler with the admin context. * @param route - The route for the handler. * @param pool - The connection pool. * @param procedureNameCache - The procedure name cache. * @param argumentCache - The argument cache. */ registerHandler(route, pool, procedureNameCache, argumentCache) { this.pools.push(pool); this.caches.push({ poolName: route, procedureNameCache, argumentCache }); } get paused() { return this._paused; } setPaused(value) { this._paused = value; } }; //#endregion //#region src/backend/util/file.ts /** * Read file. * * @param filePath - File name. * @returns The string. */ const readFileSyncUtf8 = (filePath) => { try { return readFileSync(filePath, "utf8"); } catch { throw new Error(`Unable to read file "${filePath}"`); } }; /** * Read file. * * @param filePath - File name. * @returns The buffer. */ const readFile = async (filePath) => { try { return await promises.readFile(filePath); } catch { throw new Error(`Unable to read file "${filePath}"`); } }; /** * Remove file. * * @param filePath - File name. */ const removeFile = async (filePath) => { try { await promises.unlink(filePath); } catch { throw new Error(`Unable to remove file "${filePath}"`); } }; /** * Load a json file. * * @param filePath - File name. * @returns The json object. */ const getJsonFile = (filePath) => { try { const fileContent = readFileSync(filePath, "utf8"); return JSON.parse(fileContent); } catch { throw new Error(`Unable to load file "${filePath}"`); } }; /** * Is this a directory. * @param directoryPath - Directory name. * @returns Return true if it is a directory. */ const isDirectory = async (directoryPath) => { if (typeof directoryPath !== "string") return false; return (await promises.stat(directoryPath)).isDirectory(); }; /** * Is this a file. * @param filePath - File name. * @returns Return true if it is a file. */ const isFile = async (filePath) => { if (typeof filePath !== "string") return false; try { return (await promises.stat(filePath)).isFile(); } catch { return false; } }; //#endregion //#region src/backend/util/html.ts /** * Escape html string. * * @param value - The value. * @returns The escaped value. */ const escapeHtml = (value) => value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll("\"", "&quot;").replaceAll("'", "&#39;"); /** * Convert LF and/or CR to <br> * @param text - The text to convert. * @returns The converted text. */ const convertAsciiToHtml = (text) => { let html = escapeHtml(text); html = html.replaceAll(/\r\n|\r|\n/g, "<br />"); html = html.replaceAll(" ", "&nbsp;&nbsp;&nbsp;"); return html; }; /** * get a minimal html page. * @param body - The body. * @returns The html page. */ const getHtmlPage = (body) => `<!DOCTYPE html> <html lang="en"> <head> <meta charset="utf-8"> <title>web_plsql error page</title> <style type="text/css"> html { font-family: monospace, sans-serif; font-size: 12px; } h1 { font-size: 16px; padding: 2px; background-color: #cc0000; } </style> </head> <body> ${body} </body> </html> `; //#endregion //#region src/backend/util/trace.ts /** * Type guard for BindParameter * @param row - The row to check * @returns True if row is a BindParameter */ const isBindParameter = (row) => { if (typeof row !== "object" || row === null) return false; return "dir" in row || "type" in row || "val" in row || "maxSize" in row || "maxArraySize" in row; }; const SEPARATOR_H1 = "=".repeat(100); const SEPARATOR_H2 = "-".repeat(30); /** * Return a string representation of the value. * * @param value - Any value. * @param depth - Specifies the number of times to recurse while formatting object. * @returns The string representation. */ const inspect = (value, depth = null) => { try { return util.inspect(value, { showHidden: false, depth, colors: false }); } catch {} try { return JSON.stringify(value); } catch {} return "Unable to convert value to string"; }; /** * @param cell - The string * @param width - The width * @returns The result */ const padCell = (cell, width) => cell.padEnd(width, " "); /** * Return a tabular representation of the values. * * @param head - The header values. * @param body - The row values. * @returns The output. */ const toTable = (head, body) => { if (head.length === 0) throw new Error("head cannot be empty"); const widths = head.map((h, i) => { const bodyMax = Math.max(0, ...body.map((row) => (row[i] ?? "").length)); return Math.max(h.length, bodyMax); }); /** * @param i - The index * @returns The width */ const getWidth = (i) => widths[i] ?? 0; return { text: [ head.map((h, i) => padCell(h, getWidth(i))).join(" | "), widths.map((w) => "-".repeat(w ?? 0)).join("-+-"), ...body.map((row) => head.map((_, i) => padCell(row[i] ?? "", getWidth(i))).join(" | ")) ].join("\n"), html: `<table><thead><tr>${head.map((h) => `<th>${escapeHtml(h)}</th>`).join("")}</tr></thead><tbody>${body.map((row) => `<tr>${head.map((_, i) => `<td>${escapeHtml(row[i] ?? "")}</td>`).join("")}</tr>`).join("")}</tbody></table>` }; }; /** * Log text to the console and to a file. * * @param text - Text to log. */ const logToFile = (text) => { const fs = rotatingFileStream.createStream("trace.log", { size: TRACE_LOG_ROTATION_SIZE, interval: TRACE_LOG_ROTATION_INTERVAL, maxFiles: TRACE_LOG_MAX_ROTATED_FILES, compress: "gzip" }); fs.write(text); fs.end(); }; /** * Return a string representation of the request. * * @param req - express.Request. * @returns The string representation. */ const inspectRequest = (req) => { const requestData = {}; Object.keys(req).filter((prop) => [ "originalUrl", "params", "query", "url", "method", "body", "files", "secret", "cookies" ].includes(prop)).forEach((prop) => { requestData[prop] = req[prop]; }); return inspect(requestData); }; /** * Return a string representation of the bind parameter. * @param dir - The direction. * @returns The string. */ const dirToString = (dir) => { switch (dir) { case oracledb.BIND_IN: return "IN"; case oracledb.BIND_OUT: return "OUT"; case oracledb.BIND_INOUT: return "INOUT"; default: return ""; } }; /** * Return a string representation of the bind type. * @param type - The type. * @returns The string. */ const bindTypeToString = (type) => { if (typeof type === "object" && type !== null && "name" in type) return type.name; if (typeof type === "string") return type; if (typeof type === "number") return type.toString(); return ""; }; /** * Return a string representation of the bind parameter. * @param output - The output. * @param bind - The bind parameters. */ const inspectBindParameter = (output, bind) => { const rows = Object.entries(bind); if (rows.length === 0) return; const { html, text } = toTable([ "id", "dir", "maxArraySize", "maxSize", "bind type", "value", "value type" ], rows.map(([id, row]) => { let dir = ""; let maxArraySize = ""; let maxSize = ""; let bindType = ""; let value = ""; let valueType = ""; if (isBindParameter(row)) { dir = dirToString(row.dir); maxArraySize = row.maxArraySize ? row.maxArraySize.toString() : ""; maxSize = row.maxSize ? row.maxSize.toString() : ""; bindType = bindTypeToString(row.type); value = inspect(row.val); valueType = typeof row.val; } else { value = inspect(row); valueType = typeof row; } return [ id, dir, maxArraySize, maxSize, bindType, value, valueType ]; })); output.html += html; output.text += text; }; /** * Add environment * @param output - The output. * @param environment - The environment. */ const inspectEnvironment = (output, environment) => { const rows = Object.entries(environment); if (rows.length === 0) return; const { html, text } = toTable(["key", "value"], rows); output.html += html; output.text += text; }; /** * Get a block. * @param title - The name. * @param body - The name. * @returns The text. */ const getBlock = (title, body) => `\n${SEPARATOR_H2}${title.toUpperCase()}${SEPARATOR_H2}\n${body}`; /** * Get line html * @param text - The text. * @returns The line. */ const getLineHtml = (text) => `<p>${convertAsciiToHtml(text)}</p>`; /** * Get line text * @param text - The text. * @returns The line. */ const getLineText = (text) => `${text}\n`; /** * Add line * @param output - The output. * @param text - The text to convert. */ const addLine = (output, text) => { output.html += getLineHtml(text); output.text += getLineText(text); }; /** * Add header * @param output - The output. * @param text - The text to convert. */ const addHeader = (output, text) => { output.html += `<h2>${text}</h2>`; output.text += `\n${text}\n${"-".repeat(text.length)}\n`; }; /** * Add procedure * @param output - The output. * @param sql - The SQL to execute. * @param bind - The bind parameters. */ const addProcedure = (output, sql, bind) => { output.html += `${sql}<br><br>`; output.text += `${sql}\n\n`; try { inspectBindParameter(output, bind); } catch (err) { addLine(output, `Unable to inspect bind parameter: ${errorToString(err)}`); } output.html += `<br>`; output.text += `\n`; }; /** * Get a formatted message. * @param para - The req object represents the HTTP request. * @returns The output. */ const getFormattedMessage = (para) => { const timestamp = para.timestamp ?? /* @__PURE__ */ new Date(); const url = typeof para.req?.originalUrl === "string" && para.req.originalUrl.length > 0 ? ` on ${para.req.originalUrl}` : ""; const header = `${(para.type ?? "trace").toUpperCase()} at ${timestamp.toUTCString()}${url}`; const output = { html: `<h1>${header}</h1>`, text: `\n\n${SEPARATOR_H1}\n== ${header}\n${SEPARATOR_H1}\n` }; addHeader(output, "ERROR"); addLine(output, para.message); if (para.req) { addHeader(output, "REQUEST"); addLine(output, inspectRequest(para.req)); } if (para.sql && para.bind) { addHeader(output, "PROCEDURE"); addProcedure(output, para.sql, para.bind); } if (para.environment) { addHeader(output, "ENVIRONMENT"); inspectEnvironment(output, para.environment); } return output; }; /** * Log a warning message. * @param para - The req object represents the HTTP request. */ const warningMessage = (para) => { const { text } = getFormattedMessage(para); logToFile(text); console.warn(text); }; //#endregion //#region src/backend/util/errorToString.ts /** * Convert Error to a string. * * @param error - The error. * @returns The string representation. */ const errorToString = (error) => { if (typeof error === "string") return error; else if (error instanceof Error) { const parts = [error.name]; if (typeof error.message === "string" && error.message.length > 0) parts.push(error.message); if (typeof error.stack === "string" && error.stack.length > 0) parts.push(error.stack); return parts.join("\n"); } else return inspect(error); }; //#endregion //#region src/backend/handler/plsql/upload.ts const debug$11 = debugModule("webplsql:fileUpload"); const z$reqFiles = z$1.array(z$1.strictObject({ fieldname: z$1.string(), originalname: z$1.string(), encoding: z$1.string(), mimetype: z$1.string(), destination: z$1.string(), filename: z$1.string(), path: z$1.string(), size: z$1.number() })); /** * Get the files * * @param req - The req object represents the HTTP request. * @returns Promise that resolves with an array of files to be uploaded. */ const getFiles = (req) => { if (!("files" in req)) { debug$11("getFiles: no files"); return []; } if (typeof req.files === "object" && req.files !== null && Object.keys(req.files).length === 0) { debug$11("getFiles: no files"); return []; } const files = z$reqFiles.parse(req.files); for (const file of files) file.filename += `/${file.originalname}`; debug$11("getFiles", files); return files; }; /** * Upload the given file and return a promise. * * @param file - The file to upload. * @param doctable - The file to upload. * @param databaseConnection - The file to upload. * @returns Promise that resolves when uploaded. */ const uploadFile = async (file, doctable, databaseConnection) => { debug$11(`uploadFile`, file, doctable); /* v8 ignore next - defensive validation */ if (typeof doctable !== "string" || doctable.length === 0) throw new Error(`Unable to upload file "${file.filename}" because the option ""doctable" has not been defined`); let blobContent; try { blobContent = await readFile(file.path); } catch (err) { throw new Error(`Unable to load file "${file.path}".\n${errorToString(err)}`); } const sql = `INSERT INTO ${doctable} (name, mime_type, doc_size, dad_charset, last_updated, content_type, blob_content) VALUES (:name, :mime_type, :doc_size, 'ascii', SYSDATE, 'BLOB', :blob_content)`; const bind = { name: file.filename, mime_type: file.mimetype, doc_size: file.size, blob_content: { val: blobContent, type: BUFFER } }; try { await databaseConnection.execute(sql, bind, { autoCommit: true }); } catch (err) { throw new Error(`Unable to insert file "${file.filename}".\n${errorToString(err)}`); } try { await removeFile(file.path); } catch (err) { throw new Error(`Unable to remove file "${file.filename}".\n${errorToString(err)}`); } }; //#endregion //#region src/backend/handler/plsql/procedureVariable.ts const debug$10 = debugModule("webplsql:procedureVariable"); /** * Get the sql statement and bindings for the procedure to execute for a variable number of arguments * @param _req - The req object represents the HTTP request. (only used for debugging) * @param procName - The procedure to execute * @param argObj - The arguments to pass to the procedure * @returns The SQL statement and bindings for the procedure to execute */ const getProcedureVariable = (_req, procName, argObj) => { /* v8 ignore start */ if (debug$10.enabled) debug$10(`getProcedureVariable: ${procName} arguments=`, argObj); /* v8 ignore stop */ const names = []; const values = []; for (const key in argObj) { const value = argObj[key]; if (typeof value === "string") { names.push(key); values.push(value); } else if (Array.isArray(value)) value.forEach((item) => { names.push(key); values.push(item); }); } return { sql: `${procName}(:argnames, :argvalues)`, bind: { argnames: { dir: BIND_IN, type: STRING, val: names }, argvalues: { dir: BIND_IN, type: STRING, val: values } } }; }; //#endregion //#region src/backend/handler/plsql/requestError.ts var RequestError = class RequestError extends Error { timestamp; /** * @param message - The error message. */ constructor(message) { super(message); if (Error.captureStackTrace) Error.captureStackTrace(this, RequestError); this.timestamp = /* @__PURE__ */ new Date(); } }; //#endregion //#region src/backend/util/util.ts /** * Convert a string to a number * * @param value - The string to convert * @returns The number or null if the string could not be converted */ const stringToNumber = (value) => { if (typeof value === "number") return !Number.isNaN(value) && Number.isFinite(value) ? value : null; if (typeof value !== "string" || !/^[+-]?(?:\d+(?:\.\d+)?|\.\d+)(?:E[+-]?\d+)?$/i.test(value)) return null; return Number(value); }; //#endregion //#region src/backend/handler/plsql/procedureNamed.ts const debug$9 = debugModule("webplsql:procedureNamed"); const SQL_GET_ARGUMENT = [ "DECLARE", " schemaName VARCHAR2(32767);", " part1 VARCHAR2(32767);", " part2 VARCHAR2(32767);", " dblink VARCHAR2(32767);", " objectType NUMBER;", " objectID NUMBER;", "BEGIN", " dbms_utility.name_resolve(name=>UPPER(:name), context=>1, schema=>schemaName, part1=>part1, part2=>part2, dblink=>dblink, part1_type=>objectType, object_number=>objectID);", " IF (part1 IS NOT NULL) THEN", " SELECT argument_name, data_type BULK COLLECT INTO :names, :types FROM all_arguments WHERE owner = schemaName AND package_name = part1 AND object_name = part2 AND argument_name IS NOT NULL ORDER BY overload, sequence;", " ELSE", " SELECT argument_name, data_type BULK COLLECT INTO :names, :types FROM all_arguments WHERE owner = schemaName AND package_name IS NULL AND object_name = part2 AND argument_name IS NOT NULL ORDER BY overload, sequence;", " END IF;", "END;" ].join("\n"); const DATA_TYPES = Object.freeze({ VARCHAR2: "VARCHAR2", CHAR: "CHAR", BINARY_INTEGER: "BINARY_INTEGER", NUMBER: "NUMBER", DATE: "DATE", CLOB: "CLOB", PL_SQL_TABLE: "PL/SQL TABLE" }); /** * Retrieve the argument types for a given procedure to be executed. * This is important because if the procedure is defined to take a PL/SQL indexed table, * we must provise a table, even if there is only one argument to be submitted. * @param procedure - The procedure * @param databaseConnection - The database connection * @returns The argument types */ const loadArguments = async (procedure, databaseConnection) => { const bind = { name: { dir: BIND_IN, type: STRING, val: procedure }, names: { dir: BIND_OUT, type: STRING, maxSize: 60, maxArraySize: MAX_PROCEDURE_PARAMETERS }, types: { dir: BIND_OUT, type: STRING, maxSize: 60, maxArraySize: MAX_PROCEDURE_PARAMETERS } }; let result = {}; try { result = await databaseConnection.execute(SQL_GET_ARGUMENT, bind); } catch (err) { /* v8 ignore start */ debug$9("result", result); throw new RequestError(`Error when retrieving arguments\n${SQL_GET_ARGUMENT}\n${errorToString(err)}`); } let data; try { data = z$1.object({ names: z$1.array(z$1.string().nullable()), types: z$1.array(z$1.string().nullable()) }).parse(result.outBinds); } catch (err) { /* v8 ignore start */ debug$9("result.outBinds", result.outBinds); throw new RequestError(`Error when decoding arguments\n${SQL_GET_ARGUMENT}\n${errorToString(err)}`); } if (data.names.length !== data.types.length) throw new RequestError("Error when decoding arguments. The number of names and types does not match"); const argTypes = {}; for (let i = 0; i < data.names.length; i++) { const name = data.names[i]; const type = data.types[i]; if (name && type) argTypes[name.toLowerCase()] = type; } return argTypes; }; /** * Find the argument types for a given procedure to be executed. * As the arguments are cached, we first look up the cache and only if not yet available we load them. * @param procedure - The procedure * @param databaseConnection - The database connection * @param argumentCache - The argument cache. * @returns The argument types */ const findArguments = async (procedure, databaseConnection, argu