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
@@ -0,0 +1,93 @@
1
+ import {Readable} from 'node:stream';
2
+ import oracledb from 'oracledb';
3
+ import z from 'zod';
4
+ import debugModule from 'debug';
5
+ import {ProcedureError} from './procedureError.js';
6
+ import {errorToString} from '../../util/errorToString.js';
7
+
8
+ const debug = debugModule('webplsql:owaPageStream');
9
+
10
+ /**
11
+ * @typedef {import('oracledb').Connection} Connection
12
+ * @typedef {import('../../types.js').BindParameterConfig} BindParameterConfig
13
+ */
14
+
15
+ export class OWAPageStream extends Readable {
16
+ /**
17
+ * @param {Connection} databaseConnection - The database connection.
18
+ */
19
+ constructor(databaseConnection) {
20
+ super({highWaterMark: 16384}); // 16KB buffer
21
+ this.databaseConnection = databaseConnection;
22
+ this.chunkSize = 1000;
23
+ this.isDone = false;
24
+ }
25
+
26
+ /**
27
+ * Fetch a chunk of the page from the database.
28
+ * @returns {Promise<string[]>} The array of lines fetched.
29
+ */
30
+ async fetchChunk() {
31
+ if (this.isDone) return [];
32
+
33
+ /** @type {BindParameterConfig} */
34
+ const bindParameter = {
35
+ lines: {dir: oracledb.BIND_OUT, type: oracledb.STRING, maxArraySize: this.chunkSize},
36
+ irows: {dir: oracledb.BIND_INOUT, type: oracledb.NUMBER, val: this.chunkSize},
37
+ };
38
+
39
+ const sqlStatement = 'BEGIN owa.get_page(thepage=>:lines, irows=>:irows); END;';
40
+
41
+ try {
42
+ const result = await this.databaseConnection.execute(sqlStatement, bindParameter);
43
+ const {lines, irows} = z.object({irows: z.number(), lines: z.array(z.string())}).parse(result.outBinds);
44
+
45
+ debug(`fetched ${lines.length} lines (irows=${irows})`);
46
+
47
+ // If we got fewer lines than requested, OR if we got 0 lines, we are done
48
+ if (lines.length < this.chunkSize) {
49
+ this.isDone = true;
50
+ }
51
+
52
+ return lines;
53
+ } catch (err) {
54
+ if (err instanceof ProcedureError) {
55
+ throw err;
56
+ }
57
+ throw new ProcedureError(`OWAPageStream: error when getting page\n${errorToString(err)}`, {}, sqlStatement, bindParameter);
58
+ }
59
+ }
60
+
61
+ /**
62
+ * @override
63
+ * @param {number} _size - The size hint (unused).
64
+ * @returns {void}
65
+ */
66
+ _read(_size) {
67
+ this.fetchChunk()
68
+ .then((lines) => {
69
+ if (lines.length > 0) {
70
+ this.push(lines.join(''));
71
+ }
72
+
73
+ // After fetching, check if we're done
74
+ if (this.isDone) {
75
+ this.push(null);
76
+ }
77
+ })
78
+ .catch((/** @type {unknown} */ err) => {
79
+ this.destroy(err instanceof Error ? err : new Error(String(err)));
80
+ });
81
+ }
82
+
83
+ /**
84
+ * Add initial body content to the stream.
85
+ * @param {string} content - The initial content to prepend.
86
+ * @returns {void}
87
+ */
88
+ addBody(content) {
89
+ if (content && content.length > 0) {
90
+ this.push(content);
91
+ }
92
+ }
93
+ }
@@ -155,7 +155,11 @@ const parseCookie = (text) => {
155
155
  cookieElements = cookieElements.map((element) => element.trim());
156
156
 
157
157
  // get name and value
158
- const index = cookieElements[0].indexOf('=');
158
+ const firstElement = cookieElements[0];
159
+ if (!firstElement) {
160
+ return null;
161
+ }
162
+ const index = firstElement.indexOf('=');
159
163
  /* v8 ignore next - cookie format validation */
160
164
  if (index <= 0) {
161
165
  // if the index is -1, there is no equal sign and if it's 0 the name is empty
@@ -164,8 +168,8 @@ const parseCookie = (text) => {
164
168
 
165
169
  /** @type {cookieType} */
166
170
  const cookie = {
167
- name: cookieElements[0].substring(0, index).trim(),
168
- value: cookieElements[0].substring(index + 1).trim(),
171
+ name: firstElement.substring(0, index).trim(),
172
+ value: firstElement.substring(index + 1).trim(),
169
173
  options: {},
170
174
  };
171
175
 
@@ -15,9 +15,16 @@ import {getProcedureNamed} from './procedureNamed.js';
15
15
  import {parsePage} from './parsePage.js';
16
16
  import {sendResponse} from './sendResponse.js';
17
17
  import {ProcedureError} from './procedureError.js';
18
+ import {RequestError} from './requestError.js';
18
19
  import {inspect, getBlock} from '../../util/trace.js';
19
20
  import {errorToString} from '../../util/errorToString.js';
20
21
  import {sanitizeProcName} from './procedureSanitize.js';
22
+ import {OWAPageStream} from './owaPageStream.js';
23
+ import {traceManager} from '../../util/traceManager.js';
24
+
25
+ /**
26
+ * @typedef {import('../../admin/js/types.js').TraceEntry} TraceEntry
27
+ */
21
28
 
22
29
  /**
23
30
  * @typedef {import('express').Request} Request
@@ -29,6 +36,8 @@ import {sanitizeProcName} from './procedureSanitize.js';
29
36
  * @typedef {import('../../types.js').environmentType} environmentType
30
37
  * @typedef {import('../../types.js').configPlSqlHandlerType} configPlSqlHandlerType
31
38
  * @typedef {import('../../types.js').BindParameterConfig} BindParameterConfig
39
+ * @typedef {import('../../util/cache.js').Cache<string>} ProcedureNameCache
40
+ * @typedef {import('../../util/cache.js').Cache<import('./procedureNamed.js').argsType>} ArgumentCache
32
41
  */
33
42
 
34
43
  /**
@@ -38,12 +47,16 @@ import {sanitizeProcName} from './procedureSanitize.js';
38
47
  * @param {argObjType} argObj - The arguments to pass to the procedure
39
48
  * @param {configPlSqlHandlerType} options - The options for the middleware
40
49
  * @param {Connection} databaseConnection - The database connection
41
- * @returns {Promise<{sql: string; bind: BindParameterConfig}>} - The SQL statement and bindings for the procedure to execute
50
+ * @param {ProcedureNameCache} procedureNameCache - The procedure name cache.
51
+ * @param {ArgumentCache} argumentCache - The argument cache.
52
+ * @returns {Promise<{sql: string; bind: BindParameterConfig; resolvedName?: string}>} - The SQL statement and bindings for the procedure to execute
42
53
  */
43
- const getProcedure = async (req, procName, argObj, options, databaseConnection) => {
54
+ const getProcedure = async (req, procName, argObj, options, databaseConnection, procedureNameCache, argumentCache) => {
44
55
  // path alias
45
56
  if (options.pathAlias?.toLowerCase() === procName.toLowerCase()) {
57
+ /* v8 ignore start */
46
58
  debug(`getProcedure: path alias "${options.pathAlias}" redirects to "${options.pathAliasProcedure}"`);
59
+ /* v8 ignore stop */
47
60
  return {
48
61
  sql: `${options.pathAliasProcedure}(p_path=>:p_path)`,
49
62
  bind: {
@@ -56,13 +69,20 @@ const getProcedure = async (req, procName, argObj, options, databaseConnection)
56
69
  const useVariableArguments = procName.startsWith('!');
57
70
 
58
71
  // sanitize procedure name
59
- const sanitizedProcName = await sanitizeProcName(useVariableArguments ? procName.substring(1) : procName, databaseConnection, options);
72
+ const rawName = useVariableArguments ? procName.substring(1) : procName;
73
+ const sanitizedProcName = await sanitizeProcName(rawName, databaseConnection, options, procedureNameCache);
60
74
 
61
75
  // run procedure
62
76
  if (useVariableArguments) {
63
- return getProcedureVariable(req, sanitizedProcName, argObj);
77
+ return {
78
+ ...getProcedureVariable(req, sanitizedProcName, argObj),
79
+ resolvedName: sanitizedProcName,
80
+ };
64
81
  } else {
65
- return await getProcedureNamed(req, sanitizedProcName, argObj, databaseConnection);
82
+ return {
83
+ ...(await getProcedureNamed(req, sanitizedProcName, argObj, databaseConnection, argumentCache)),
84
+ resolvedName: sanitizedProcName,
85
+ };
66
86
  }
67
87
  };
68
88
 
@@ -119,47 +139,6 @@ const procedureExecute = async (para, databaseConnection) => {
119
139
  }
120
140
  };
121
141
 
122
- /**
123
- * Get page from procedure
124
- *
125
- * @param {boolean} test - Test.
126
- * @param {Connection} databaseConnection - Database connection.
127
- * @returns {Promise<string>} Promise resolving to the returned page content.
128
- */
129
- const procedureGetPage = async (test, databaseConnection) => {
130
- const MAX_IROWS = 100000;
131
-
132
- /** @type {BindParameterConfig} */
133
- const bindParameter = {
134
- lines: {dir: oracledb.BIND_OUT, type: oracledb.STRING, maxArraySize: MAX_IROWS},
135
- irows: {dir: oracledb.BIND_INOUT, type: oracledb.NUMBER, val: MAX_IROWS},
136
- };
137
-
138
- const sqlStatement = 'BEGIN owa.get_page(thepage=>:lines, irows=>:irows); END;';
139
-
140
- /** @type {Result} */
141
- let result = {};
142
- try {
143
- result = await databaseConnection.execute(sqlStatement, bindParameter);
144
- } catch (err) {
145
- if (debug.enabled) {
146
- debug(getBlock('procedureGetPage: results', inspect(result)));
147
- }
148
-
149
- throw new ProcedureError(`procedureGetPage: error when getting page returned by procedure\n${errorToString(err)}`, {}, sqlStatement, bindParameter);
150
- }
151
-
152
- const {lines, irows} = z.object({irows: z.number(), lines: z.array(z.string())}).parse(result.outBinds);
153
-
154
- // Make sure that we have retrieved all the rows
155
- if (irows > MAX_IROWS) {
156
- /* v8 ignore next - defensive check for row limit */
157
- throw new ProcedureError(`procedureGetPage: error when retrieving rows. irows="${irows}"`, {}, sqlStatement, bindParameter);
158
- }
159
-
160
- return lines.join('');
161
- };
162
-
163
142
  /**
164
143
  * Download files from procedure
165
144
  *
@@ -197,9 +176,11 @@ END;
197
176
  try {
198
177
  result = await databaseConnection.execute(sqlStatement, bindParameter);
199
178
  } catch (err) {
179
+ /* v8 ignore start */
200
180
  if (debug.enabled) {
201
181
  debug(getBlock('procedureDownloadFiles: results', inspect(result)));
202
182
  }
183
+ /* v8 ignore stop */
203
184
 
204
185
  throw new ProcedureError(`procedureDownloadFiles: error when downloading files\n${errorToString(err)}`, {}, sqlStatement, bindParameter);
205
186
  }
@@ -229,81 +210,216 @@ END;
229
210
  * @param {fileUploadType[]} filesToUpload - Array of files to be uploaded
230
211
  * @param {configPlSqlHandlerType} options - the options for the middleware.
231
212
  * @param {Connection} databaseConnection - Database connection.
213
+ * @param {ProcedureNameCache} procedureNameCache - The procedure name cache.
214
+ * @param {ArgumentCache} argumentCache - The argument cache.
232
215
  * @returns {Promise<void>} Promise resolving to the page content generated by the executed procedure
233
216
  */
234
- export const invokeProcedure = async (req, res, argObj, cgiObj, filesToUpload, options, databaseConnection) => {
217
+ export const invokeProcedure = async (req, res, argObj, cgiObj, filesToUpload, options, databaseConnection, procedureNameCache, argumentCache) => {
235
218
  debug('invokeProcedure: begin');
236
219
 
237
- // 1) upload files
238
- debug(`invokeProcedure: upload "${filesToUpload.length}" files`);
239
- if (filesToUpload.length > 0) {
240
- if (typeof options.documentTable === 'string' && options.documentTable.length > 0) {
241
- const {documentTable} = options;
242
-
243
- await Promise.all(filesToUpload.map((file) => uploadFile(file, documentTable, databaseConnection)));
244
- } else {
245
- console.warn(`Unable to upload "${filesToUpload.length}" files because the option ""doctable" has not been defined`);
246
- }
247
- }
248
-
249
- // 2) get procedure to execute and the arguments
250
-
251
- debug('invokeProcedure: get procedure to execute and the arguments');
252
- const para = await getProcedure(req, Array.isArray(req.params.name) ? req.params.name[0] : req.params.name, argObj, options, databaseConnection);
253
-
254
- // 3) prepare the session
255
-
256
- debug('invokeProcedure: prepare the session');
257
- await procedurePrepare(cgiObj, databaseConnection);
258
-
259
- // 4) execute the procedure
260
-
261
- debug('invokeProcedure: execute the session');
262
- await procedureExecute(para, databaseConnection);
263
-
264
- // 5) get the page returned from the procedure
265
-
266
- debug('invokeProcedure: get the page returned from the procedure');
267
- const lines = await procedureGetPage(true, databaseConnection);
268
- if (debug.enabled) {
269
- debug(getBlock('data', lines));
220
+ const startTime = Date.now();
221
+ /** @type {TraceEntry | null} */
222
+ let traceData = null;
223
+
224
+ if (traceManager.isEnabled()) {
225
+ traceData = {
226
+ id: Math.random().toString(36).substring(2, 15),
227
+ timestamp: new Date().toISOString(),
228
+ source: cgiObj.REMOTE_ADDR ?? '',
229
+ url: req.originalUrl,
230
+ method: req.method,
231
+ status: 'pending',
232
+ duration: 0,
233
+ cgi: cgiObj,
234
+ headers: /** @type {Record<string, string>} */ (req.headers),
235
+ cookies: req.cookies,
236
+ uploads: filesToUpload.map((f) => ({
237
+ originalname: f.originalname,
238
+ mimetype: f.mimetype,
239
+ size: f.size,
240
+ })),
241
+ };
270
242
  }
271
243
 
272
- // 6) download files
273
-
274
- debug('invokeProcedure: download files');
275
- const fileBlob = await databaseConnection.createLob(oracledb.BLOB);
276
-
277
244
  try {
278
- const fileDownload = await procedureDownloadFiles(fileBlob, databaseConnection);
279
- if (debug.enabled) {
280
- debug(getBlock('fileDownload', inspect({fileType: fileDownload.fileType, fileSize: fileDownload.fileSize})));
245
+ // 1) upload files
246
+ debug(`invokeProcedure: upload "${filesToUpload.length}" files`);
247
+ if (filesToUpload.length > 0) {
248
+ if (typeof options.documentTable === 'string' && options.documentTable.length > 0) {
249
+ const {documentTable} = options;
250
+
251
+ await Promise.all(filesToUpload.map((file) => uploadFile(file, documentTable, databaseConnection)));
252
+ } else {
253
+ console.warn(`Unable to upload "${filesToUpload.length}" files because the option ""doctable" has not been defined`);
254
+ }
281
255
  }
282
256
 
283
- // 7) parse the page
257
+ // 2) get procedure to execute and the arguments
284
258
 
285
- debug('invokeProcedure: parse the page');
286
- const pageComponents = parsePage(lines);
259
+ debug('invokeProcedure: get procedure to execute and the arguments');
260
+ // Extract the raw procedure name from params
261
+ const rawProcName = Array.isArray(req.params.name) ? req.params.name[0] : req.params.name;
262
+ if (!rawProcName) {
263
+ throw new RequestError('No procedure name provided');
264
+ }
265
+ const para = await getProcedure(req, rawProcName, argObj, options, databaseConnection, procedureNameCache, argumentCache);
287
266
 
288
- // add "Server" header
289
- pageComponents.head.server = cgiObj.SERVER_SOFTWARE;
267
+ if (traceData) {
268
+ traceData.procedure = para.resolvedName;
269
+ traceData.parameters = para.bind;
270
+ }
290
271
 
291
- // add file download information
292
- if (fileDownload.fileType !== '' && fileDownload.fileSize > 0 && fileDownload.fileBlob !== null) {
293
- pageComponents.file.fileType = fileDownload.fileType;
294
- pageComponents.file.fileSize = fileDownload.fileSize;
295
- pageComponents.file.fileBlob = fileDownload.fileBlob;
272
+ // 3) prepare the session
273
+
274
+ debug('invokeProcedure: prepare the session');
275
+ await procedurePrepare(cgiObj, databaseConnection);
276
+
277
+ // 4) execute the procedure
278
+
279
+ debug('invokeProcedure: execute the session');
280
+ try {
281
+ await procedureExecute(para, databaseConnection);
282
+ } catch (err) {
283
+ // Invalidation Logic
284
+ if (err instanceof ProcedureError) {
285
+ const errorString = err.toString();
286
+ // Check for ORA-04068, ORA-04061, ORA-04065, ORA-06550
287
+ if (
288
+ errorString.includes('ORA-04068') ||
289
+ errorString.includes('ORA-04061') ||
290
+ errorString.includes('ORA-04065') ||
291
+ errorString.includes('ORA-06550')
292
+ ) {
293
+ debug(`invokeProcedure: detected invalidation error (${errorString}). Clearing caches.`);
294
+
295
+ // Clear name resolution cache for the input name
296
+ if (rawProcName) {
297
+ procedureNameCache.delete(rawProcName);
298
+ }
299
+
300
+ // Clear argument cache for the resolved name
301
+ if (para.resolvedName) {
302
+ argumentCache.delete(para.resolvedName.toUpperCase());
303
+ }
304
+ }
305
+ }
306
+ throw err;
296
307
  }
297
308
 
298
- // 8) send the page to browser
309
+ // 5) get the page returned from the procedure
310
+ debug('invokeProcedure: get the page returned from the procedure');
299
311
 
300
- debug('invokeProcedure: send the page to browser');
301
- await sendResponse(req, res, pageComponents);
302
- } finally {
303
- // 9) cleanup
312
+ const streamInstance = new OWAPageStream(databaseConnection);
313
+ const lines = await streamInstance.fetchChunk();
304
314
 
305
- debug('invokeProcedure: cleanup');
306
- fileBlob.destroy();
315
+ /* v8 ignore start */
316
+ if (debug.enabled) {
317
+ debug(getBlock('data', lines.join('')));
318
+ }
319
+ /* v8 ignore stop */
320
+
321
+ // 6) download files
322
+
323
+ debug('invokeProcedure: download files');
324
+ const fileBlob = await databaseConnection.createLob(oracledb.BLOB);
325
+
326
+ try {
327
+ const fileDownload = await procedureDownloadFiles(fileBlob, databaseConnection);
328
+ /* v8 ignore start */
329
+ if (debug.enabled) {
330
+ debug(getBlock('fileDownload', inspect({fileType: fileDownload.fileType, fileSize: fileDownload.fileSize})));
331
+ }
332
+ /* v8 ignore stop */
333
+
334
+ // 7) parse the page
335
+
336
+ debug('invokeProcedure: parse the page');
337
+ // We parse the headers from the first chunk
338
+ const pageComponents = parsePage(lines.join(''));
339
+
340
+ // add "Server" header
341
+ pageComponents.head.server = cgiObj.SERVER_SOFTWARE ?? '';
342
+
343
+ // add file download information
344
+ if (fileDownload.fileType !== '' && fileDownload.fileSize > 0 && fileDownload.fileBlob !== null) {
345
+ pageComponents.file.fileType = fileDownload.fileType;
346
+ pageComponents.file.fileSize = fileDownload.fileSize;
347
+ pageComponents.file.fileBlob = fileDownload.fileBlob;
348
+
349
+ if (traceData) {
350
+ traceData.downloads = {
351
+ fileType: fileDownload.fileType,
352
+ fileSize: fileDownload.fileSize,
353
+ };
354
+ }
355
+ } else {
356
+ // For normal pages, we use the stream.
357
+ // Prepend the initial body (parsed by parsePage) to the stream
358
+ if (typeof pageComponents.body === 'string' && pageComponents.body.length > 0) {
359
+ streamInstance.addBody(pageComponents.body);
360
+ }
361
+ // Use the stream as the body
362
+ pageComponents.body = streamInstance;
363
+
364
+ if (traceData) {
365
+ // Buffer HTML if tracing enabled
366
+ let htmlBuffer = typeof pageComponents.body === 'string' ? pageComponents.body : lines.join('');
367
+ const MAX_HTML_SIZE = 1024 * 1024; // 1MB
368
+
369
+ const originalPush = streamInstance.push.bind(streamInstance);
370
+ streamInstance.push = (chunk) => {
371
+ if (chunk !== null && htmlBuffer.length < MAX_HTML_SIZE) {
372
+ const str = String(chunk);
373
+ htmlBuffer += str;
374
+ if (htmlBuffer.length > MAX_HTML_SIZE) {
375
+ htmlBuffer = htmlBuffer.substring(0, MAX_HTML_SIZE) + '... [truncated]';
376
+ }
377
+ }
378
+ return originalPush(chunk);
379
+ };
380
+
381
+ // Since we can't easily wait for the stream to finish here without blocking,
382
+ // we'll have to finalize traceData when the stream ends.
383
+ const currentTrace = traceData;
384
+ streamInstance.on('end', () => {
385
+ currentTrace.html = htmlBuffer;
386
+ currentTrace.status = 'success';
387
+ currentTrace.duration = Date.now() - startTime;
388
+ traceManager.addTrace(currentTrace);
389
+ });
390
+ streamInstance.on('error', (err) => {
391
+ currentTrace.status = 'fail';
392
+ currentTrace.error = err instanceof Error ? err.message : String(err);
393
+ currentTrace.duration = Date.now() - startTime;
394
+ traceManager.addTrace(currentTrace);
395
+ });
396
+ }
397
+ }
398
+
399
+ // 8) send the page to browser
400
+
401
+ debug('invokeProcedure: send the page to browser');
402
+ await sendResponse(req, res, pageComponents);
403
+
404
+ if (traceData?.downloads) {
405
+ traceData.status = 'success';
406
+ traceData.duration = Date.now() - startTime;
407
+ traceManager.addTrace(traceData);
408
+ }
409
+ } finally {
410
+ // 9) cleanup
411
+
412
+ debug('invokeProcedure: cleanup');
413
+ fileBlob.destroy();
414
+ }
415
+ } catch (err) {
416
+ if (traceData) {
417
+ traceData.status = err instanceof ProcedureError ? 'error' : 'fail';
418
+ traceData.error = err instanceof Error ? err.message : String(err);
419
+ traceData.duration = Date.now() - startTime;
420
+ traceManager.addTrace(traceData);
421
+ }
422
+ throw err;
307
423
  }
308
424
 
309
425
  debug('invokeProcedure: end');