web_plsql 0.18.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/README.md +29 -9
  2. package/package.json +17 -7
  3. package/src/admin/client/charts.ts +598 -0
  4. package/src/admin/client/main.js +3 -0
  5. package/src/admin/client/tailwind.css +41 -0
  6. package/src/admin/favicon.svg +3 -0
  7. package/src/admin/index.html +529 -0
  8. package/src/admin/js/api.ts +170 -0
  9. package/src/admin/js/app.ts +614 -0
  10. package/src/admin/js/eslint.config.js +74 -0
  11. package/src/admin/js/schemas.ts +215 -0
  12. package/src/admin/js/templates/config.ts +147 -0
  13. package/src/admin/js/templates/errorRow.ts +21 -0
  14. package/src/admin/js/templates/index.ts +4 -0
  15. package/src/admin/js/templates/poolCard.ts +61 -0
  16. package/src/admin/js/templates/traceRow.ts +24 -0
  17. package/src/admin/js/tsconfig.json +24 -0
  18. package/src/admin/js/types.ts +336 -0
  19. package/src/admin/js/ui/components.ts +44 -0
  20. package/src/admin/js/ui/table.ts +173 -0
  21. package/src/admin/js/ui/theme.ts +93 -0
  22. package/src/admin/js/ui/views.ts +427 -0
  23. package/src/admin/js/util/format.ts +46 -0
  24. package/src/admin/js/util/metrics.ts +24 -0
  25. package/src/admin/lib/chart.bundle.css +1 -0
  26. package/src/admin/lib/chart.bundle.js +146 -0
  27. package/src/admin/style.css +1437 -0
  28. package/src/bin/load-test.js +202 -0
  29. package/src/handler/handlerAdmin.js +293 -0
  30. package/src/handler/plsql/cgi.js +1 -1
  31. package/src/handler/plsql/errorPage.js +31 -6
  32. package/src/handler/plsql/handlerPlSql.js +32 -6
  33. package/src/handler/plsql/owaPageStream.js +93 -0
  34. package/src/handler/plsql/parsePage.js +7 -3
  35. package/src/handler/plsql/procedure.js +221 -105
  36. package/src/handler/plsql/procedureNamed.js +34 -49
  37. package/src/handler/plsql/procedureSanitize.js +120 -45
  38. package/src/handler/plsql/procedureVariable.js +4 -2
  39. package/src/handler/plsql/request.js +8 -3
  40. package/src/handler/plsql/sendResponse.js +43 -5
  41. package/src/index.js +0 -1
  42. package/src/server/adminContext.js +23 -0
  43. package/src/server/config.js +1 -0
  44. package/src/server/server.js +130 -9
  45. package/src/types.js +7 -1
  46. package/src/util/cache.js +123 -0
  47. package/src/util/jsonLogger.js +47 -0
  48. package/src/util/shutdown.js +6 -2
  49. package/src/util/statsManager.js +368 -0
  50. package/src/util/trace.js +7 -6
  51. package/src/util/traceManager.js +68 -0
  52. package/src/version.js +1 -1
  53. package/types/admin/js/schemas.d.ts +384 -0
  54. package/types/admin/js/types.d.ts +312 -0
  55. package/types/handler/handlerAdmin.d.ts +79 -0
  56. package/types/handler/plsql/errorPage.d.ts +1 -0
  57. package/types/handler/plsql/handlerPlSql.d.ts +6 -1
  58. package/types/handler/plsql/owaPageStream.d.ts +28 -0
  59. package/types/handler/plsql/procedure.d.ts +4 -1
  60. package/types/handler/plsql/procedureNamed.d.ts +2 -5
  61. package/types/handler/plsql/procedureSanitize.d.ts +2 -5
  62. package/types/handler/plsql/procedureVariable.d.ts +1 -1
  63. package/types/handler/plsql/request.d.ts +3 -1
  64. package/types/handler/plsql/sendResponse.d.ts +1 -1
  65. package/types/index.d.ts +0 -1
  66. package/types/server/adminContext.d.ts +17 -0
  67. package/types/server/server.d.ts +5 -0
  68. package/types/types.d.ts +19 -1
  69. package/types/util/cache.d.ts +69 -0
  70. package/types/util/jsonLogger.d.ts +45 -0
  71. package/types/util/statsManager.d.ts +395 -0
  72. package/types/util/traceManager.d.ts +35 -0
  73. package/src/handler/handlerMetrics.js +0 -68
  74. package/types/handler/handlerMetrics.d.ts +0 -25
@@ -17,6 +17,7 @@ export const showConfig = (config) => {
17
17
  console.log(LINE);
18
18
 
19
19
  console.log(`Server port: ${config.port}`);
20
+ console.log(`Admin route: ${config.adminRoute ?? '/admin'}${config.adminUser ? ' (authenticated)' : ''}`);
20
21
  console.log(`Access log: ${config.loggerFilename.length > 0 ? config.loggerFilename : ''}`);
21
22
  console.log(`Upload file size limit: ${typeof config.uploadFileSizeLimit === 'number' ? `${config.uploadFileSizeLimit} bytes` : 'any'}`);
22
23
 
@@ -2,6 +2,8 @@ import debugModule from 'debug';
2
2
  const debug = debugModule('webplsql:server');
3
3
  import http from 'node:http';
4
4
  import https from 'node:https';
5
+ import path from 'node:path';
6
+ import {fileURLToPath} from 'node:url';
5
7
  import express from 'express';
6
8
  import cookieParser from 'cookie-parser';
7
9
  import compression from 'compression';
@@ -11,8 +13,13 @@ import {poolCreate, poolsClose} from '../util/oracle.js';
11
13
  import {handlerUpload} from '../handler/handlerUpload.js';
12
14
  import {handlerLogger} from '../handler/handlerLogger.js';
13
15
  import {handlerWebPlSql} from '../handler/plsql/handlerPlSql.js';
16
+ import {handlerAdmin} from '../handler/handlerAdmin.js';
14
17
  import {readFileSyncUtf8, getJsonFile} from '../util/file.js';
15
18
  import {showConfig} from './config.js';
19
+ import {AdminContext} from './adminContext.js';
20
+
21
+ const __filename = fileURLToPath(import.meta.url);
22
+ const __dirname = path.dirname(__filename);
16
23
 
17
24
  /**
18
25
  * @typedef {import('node:net').Socket} Socket
@@ -23,6 +30,14 @@ import {showConfig} from './config.js';
23
30
  * @typedef {import('oracledb').Pool} Pool
24
31
  * @typedef {import('../types.js').environmentType} environmentType
25
32
  * @typedef {import('../types.js').configType} configType
33
+ * @typedef {import('../handler/plsql/procedureNamed.js').argsType} argsType
34
+ */
35
+
36
+ /**
37
+ * @typedef {import('express').RequestHandler & {
38
+ * procedureNameCache: import('../util/cache.js').Cache<string>;
39
+ * argumentCache: import('../util/cache.js').Cache<argsType>;
40
+ * }} ExtendedRequestHandler
26
41
  */
27
42
 
28
43
  /**
@@ -67,31 +82,82 @@ export const startServer = async (config, ssl) => {
67
82
  debug('startServer: BEGIN', config, ssl);
68
83
 
69
84
  const internalConfig = /** @type {configType} */ (z$configType.parse(config));
85
+ AdminContext.config = internalConfig;
70
86
 
71
87
  showConfig(internalConfig);
72
88
 
73
89
  // Create express app
74
90
  const app = express();
75
91
 
92
+ // Default middleware
93
+ app.use(handlerUpload(internalConfig.uploadFileSizeLimit));
94
+ app.use(express.json({limit: '50mb'}));
95
+ app.use(express.urlencoded({limit: '50mb', extended: true}));
96
+ app.use(cookieParser());
97
+ app.use(compression());
98
+
99
+ // Pause & Admin Auth middleware
100
+ app.use((req, res, next) => {
101
+ const adminRoute = internalConfig.adminRoute ?? '/admin';
102
+
103
+ // Simple pause check for all PL/SQL routes (not admin)
104
+ if (AdminContext.paused && !req.path.startsWith(adminRoute)) {
105
+ res.status(503).send('Server Paused');
106
+ return;
107
+ }
108
+
109
+ // Basic Auth for Admin Route
110
+ if (req.path.startsWith(adminRoute)) {
111
+ const user = internalConfig.adminUser;
112
+ const pass = internalConfig.adminPassword;
113
+
114
+ if (user && pass) {
115
+ const auth = {login: user, password: pass};
116
+ const b64auth = (req.headers.authorization ?? '').split(' ')[1] ?? '';
117
+ const [login, password] = Buffer.from(b64auth, 'base64').toString().split(':');
118
+
119
+ if (login !== auth.login || password !== auth.password) {
120
+ res.set('WWW-Authenticate', 'Basic realm="Admin Console"');
121
+ res.status(401).send('Authentication required.');
122
+ return;
123
+ }
124
+ }
125
+ }
126
+
127
+ next();
128
+ });
129
+
76
130
  // Access log
77
131
  if (internalConfig.loggerFilename.length > 0) {
78
132
  app.use(handlerLogger(internalConfig.loggerFilename));
79
133
  }
80
134
 
135
+ // Admin console
136
+ const adminRoute = internalConfig.adminRoute ?? '/admin';
137
+ const adminDirectory = path.resolve(__dirname, '../admin');
138
+
139
+ // Ensure trailing slash for admin route to support relative paths
140
+ app.get(adminRoute, (req, res, next) => {
141
+ const [path] = req.originalUrl.split('?');
142
+ if (path === adminRoute) {
143
+ const query = req.originalUrl.split('?')[1];
144
+ return res.redirect(adminRoute + '/' + (query ? '?' + query : ''));
145
+ }
146
+ next();
147
+ });
148
+
149
+ app.use(adminRoute, handlerAdmin);
150
+ app.use(adminRoute, express.static(adminDirectory));
151
+
81
152
  // Serving static files
82
153
  for (const i of internalConfig.routeStatic) {
83
154
  app.use(i.route, express.static(i.directoryPath));
84
155
  }
85
156
 
86
- // Default middleware
87
- app.use(handlerUpload(internalConfig.uploadFileSizeLimit));
88
- app.use(express.json({limit: '50mb'}));
89
- app.use(express.urlencoded({limit: '50mb', extended: true}));
90
- app.use(cookieParser());
91
- app.use(compression());
92
-
93
157
  /** @type {Pool[]} */
94
158
  const connectionPools = [];
159
+ AdminContext.pools = connectionPools;
160
+ AdminContext.caches = [];
95
161
 
96
162
  // Oracle pl/sql express middleware
97
163
  for (const i of internalConfig.routePlSql) {
@@ -99,9 +165,56 @@ export const startServer = async (config, ssl) => {
99
165
  const pool = await poolCreate(i.user, i.password, i.connectString);
100
166
  connectionPools.push(pool);
101
167
 
102
- app.use([`${i.route}/:name`, i.route], handlerWebPlSql(pool, i));
168
+ const handler = handlerWebPlSql(pool, i);
169
+
170
+ // Capture caches for admin console
171
+ AdminContext.caches.push({
172
+ poolName: i.route,
173
+ procedureNameCache: handler.procedureNameCache,
174
+ argumentCache: handler.argumentCache,
175
+ });
176
+
177
+ app.use([`${i.route}/:name`, i.route], (req, res, next) => {
178
+ const start = process.hrtime();
179
+ res.on('finish', () => {
180
+ const diff = process.hrtime(start);
181
+ const duration = diff[0] * 1000 + diff[1] / 1_000_000;
182
+ AdminContext.statsManager.recordRequest(duration, res.statusCode >= 400);
183
+ });
184
+ handler(req, res, next);
185
+ });
103
186
  }
104
187
 
188
+ // Update pools in StatsManager on each rotation
189
+ const originalRotate = AdminContext.statsManager.rotateBucket.bind(AdminContext.statsManager);
190
+ AdminContext.statsManager.rotateBucket = () => {
191
+ const poolSnapshots = AdminContext.pools.map((pool, index) => {
192
+ const cache = AdminContext.caches[index];
193
+ const name = cache?.poolName ?? `pool-${index}`;
194
+ const procStats = cache?.procedureNameCache.getStats();
195
+ const argStats = cache?.argumentCache.getStats();
196
+
197
+ return {
198
+ name,
199
+ connectionsOpen: pool.connectionsOpen,
200
+ connectionsInUse: pool.connectionsInUse,
201
+ cache: {
202
+ procedureName: {
203
+ size: cache?.procedureNameCache.keys().length ?? 0,
204
+ hits: procStats?.hits ?? 0,
205
+ misses: procStats?.misses ?? 0,
206
+ },
207
+ argument: {
208
+ size: cache?.argumentCache.keys().length ?? 0,
209
+ hits: argStats?.hits ?? 0,
210
+ misses: argStats?.misses ?? 0,
211
+ },
212
+ },
213
+ };
214
+ });
215
+ originalRotate(poolSnapshots);
216
+ };
217
+
105
218
  // create server
106
219
  debug('startServer: createServer');
107
220
  const server = createServer(app, ssl);
@@ -126,6 +239,8 @@ export const startServer = async (config, ssl) => {
126
239
  const shutdown = async () => {
127
240
  debug('startServer: onShutdown');
128
241
 
242
+ AdminContext.statsManager.stop();
243
+
129
244
  await poolsClose(connectionPools);
130
245
 
131
246
  server.close(() => {
@@ -150,7 +265,13 @@ export const startServer = async (config, ssl) => {
150
265
  resolve();
151
266
  })
152
267
  .on('error', (err) => {
153
- console.error(err);
268
+ if ('code' in err) {
269
+ if (err.code === 'EADDRINUSE') {
270
+ err.message = `Port ${internalConfig.port} is already in use`;
271
+ } else if (err.code === 'EACCES') {
272
+ err.message = `Port ${internalConfig.port} requires elevated privileges`;
273
+ }
274
+ }
154
275
  reject(err);
155
276
  });
156
277
  })
package/src/types.js CHANGED
@@ -82,6 +82,9 @@ export const z$configPlSqlType = z.strictObject({
82
82
  * @property {configPlSqlType[]} routePlSql - The PL/SQL routes.
83
83
  * @property {number} [uploadFileSizeLimit] - Maximum size of each uploaded file in bytes or no limit if omitted.
84
84
  * @property {string} loggerFilename - name of the request logger filename or '' if not required.
85
+ * @property {string} [adminRoute] - Optional route for the admin console (defaults to /admin).
86
+ * @property {string} [adminUser] - Optional username for admin console basic auth.
87
+ * @property {string} [adminPassword] - Optional password for admin console basic auth.
85
88
  */
86
89
  export const z$configType = z.strictObject({
87
90
  port: z.number(),
@@ -89,6 +92,9 @@ export const z$configType = z.strictObject({
89
92
  routePlSql: z.array(z$configPlSqlType),
90
93
  uploadFileSizeLimit: z.number().optional(),
91
94
  loggerFilename: z.string(),
95
+ adminRoute: z.string().optional(),
96
+ adminUser: z.string().optional(),
97
+ adminPassword: z.string().optional(),
92
98
  });
93
99
 
94
100
  /**
@@ -127,7 +133,7 @@ export const z$configType = z.strictObject({
127
133
 
128
134
  /**
129
135
  * @typedef {object} pageType - The page.
130
- * @property {string} body - The body of the page.
136
+ * @property {string | import('node:stream').Readable} body - The body of the page.
131
137
  * @property {object} head - The head of the page.
132
138
  * @property {cookieType[]} head.cookies - The cookies.
133
139
  * @property {string} [head.contentType] - The content type.
@@ -0,0 +1,123 @@
1
+ /**
2
+ * @template T
3
+ * @typedef {{hitCount: number, value: T}} cacheEntryType
4
+ */
5
+
6
+ /**
7
+ * Generic Cache class with LFU (Least Frequently Used) eviction policy.
8
+ * @template T
9
+ */
10
+ export class Cache {
11
+ /**
12
+ * @param {number} maxSize - Maximum number of entries in the cache.
13
+ */
14
+ constructor(maxSize = 10000) {
15
+ /** @type {Map<string, cacheEntryType<T>>} */
16
+ this.cache = new Map();
17
+ this.maxSize = maxSize;
18
+ this.hits = 0;
19
+ this.misses = 0;
20
+ }
21
+
22
+ /**
23
+ * Get an entry from the cache.
24
+ * @param {string} key - The key.
25
+ * @returns {T | undefined} - The value or undefined if not found.
26
+ */
27
+ get(key) {
28
+ const entry = this.cache.get(key);
29
+ if (entry) {
30
+ entry.hitCount++;
31
+ this.hits++;
32
+ return entry.value;
33
+ }
34
+ this.misses++;
35
+ return undefined;
36
+ }
37
+
38
+ /**
39
+ * Set an entry in the cache.
40
+ * @param {string} key - The key.
41
+ * @param {T} value - The value.
42
+ */
43
+ set(key, value) {
44
+ // If updating an existing key, preserve its hitCount?
45
+ // Typically LFU implies resetting or keeping.
46
+ // For simplicity and avoiding complex aging, if we set it again, we reset hitCount or keep it?
47
+ // The requirement is "cache invalidation" (delete) or "cache loading" (set).
48
+ // If we overwrite, it's usually a new value. Let's reset hitCount to 0 for a fresh start or 1.
49
+
50
+ // Ensure we have space
51
+ if (this.cache.size >= this.maxSize && !this.cache.has(key)) {
52
+ this.prune();
53
+ }
54
+
55
+ this.cache.set(key, {hitCount: 0, value});
56
+ }
57
+
58
+ /**
59
+ * Delete an entry from the cache.
60
+ * @param {string} key - The key.
61
+ */
62
+ delete(key) {
63
+ this.cache.delete(key);
64
+ }
65
+
66
+ /**
67
+ * Clear the cache.
68
+ */
69
+ clear() {
70
+ this.cache.clear();
71
+ this.hits = 0;
72
+ this.misses = 0;
73
+ }
74
+
75
+ /**
76
+ * Prune the cache by removing the least frequently used entries.
77
+ * Removes 10% of the cache size.
78
+ */
79
+ prune() {
80
+ // Convert cache entries to an array
81
+ const entries = Array.from(this.cache.entries());
82
+
83
+ // Sort entries by hitCount in ascending order
84
+ entries.sort((a, b) => a[1].hitCount - b[1].hitCount);
85
+
86
+ // Remove the bottom 10%
87
+ const removeCount = Math.max(1, Math.floor(this.maxSize * 0.1));
88
+ const keysToRemove = entries.slice(0, removeCount).map(([key]) => key);
89
+
90
+ for (const key of keysToRemove) {
91
+ this.cache.delete(key);
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Get the size of the cache.
97
+ * @returns {number} - The size.
98
+ */
99
+ get size() {
100
+ return this.cache.size;
101
+ }
102
+
103
+ /**
104
+ * Get all keys in the cache.
105
+ * @returns {string[]} - The keys.
106
+ */
107
+ keys() {
108
+ return Array.from(this.cache.keys());
109
+ }
110
+
111
+ /**
112
+ * Get cache statistics.
113
+ * @returns {{size: number, maxSize: number, hits: number, misses: number}} - The statistics.
114
+ */
115
+ getStats() {
116
+ return {
117
+ size: this.cache.size,
118
+ maxSize: this.maxSize,
119
+ hits: this.hits,
120
+ misses: this.misses,
121
+ };
122
+ }
123
+ }
@@ -0,0 +1,47 @@
1
+ import * as rotatingFileStream from 'rotating-file-stream';
2
+
3
+ /**
4
+ * @typedef {object} LogEntry
5
+ * @property {string} timestamp - ISO string
6
+ * @property {'error'|'info'|'warning'} type - Log level
7
+ * @property {string} message - Error message
8
+ * @property {object} [req] - Request details
9
+ * @property {object} [details] - Additional details (stack, sql, etc)
10
+ */
11
+
12
+ export class JsonLogger {
13
+ constructor(filename = 'error.json.log') {
14
+ this.stream = rotatingFileStream.createStream(filename, {
15
+ size: '10M', // rotate every 10 MegaBytes written
16
+ interval: '1d', // rotate daily
17
+ maxFiles: 10, // maximum number of rotated files to keep
18
+ compress: 'gzip', // compress rotated files
19
+ });
20
+ }
21
+
22
+ /**
23
+ * Log an entry as NDJSON.
24
+ * @param {LogEntry} entry - The entry to log.
25
+ */
26
+ log(entry) {
27
+ try {
28
+ // Ensure timestamp exists
29
+ if (!entry.timestamp) {
30
+ entry.timestamp = new Date().toISOString();
31
+ }
32
+ const line = JSON.stringify(entry);
33
+ this.stream.write(line + '\n');
34
+ } catch (err) {
35
+ console.error('JsonLogger: Failed to write log', err);
36
+ }
37
+ }
38
+
39
+ /**
40
+ * Close the stream.
41
+ */
42
+ close() {
43
+ this.stream.end();
44
+ }
45
+ }
46
+
47
+ export const jsonLogger = new JsonLogger();
@@ -12,8 +12,12 @@ export const installShutdown = (handler) => {
12
12
  /*
13
13
  * The 'unhandledRejection' event is emitted whenever a Promise is rejected and no error handler is attached to the promise within a turn of the event loop.
14
14
  */
15
- process.on('unhandledRejection', (reason, p) => {
16
- console.log('\nUnhandled promise rejection. Graceful shutdown...', reason, p);
15
+ process.on('unhandledRejection', (reason) => {
16
+ if (reason instanceof Error) {
17
+ console.error(`\n${reason.message}. Graceful shutdown...`);
18
+ } else {
19
+ console.error('\nUnhandled promise rejection. Graceful shutdown...', reason);
20
+ }
17
21
  void handler();
18
22
  });
19
23