web_plsql 1.0.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 (51) hide show
  1. package/README.md +5 -5
  2. package/package.json +5 -5
  3. package/src/admin/client/charts.ts +303 -4
  4. package/src/admin/index.html +283 -69
  5. package/src/admin/js/api.ts +95 -20
  6. package/src/admin/js/app.ts +460 -152
  7. package/src/admin/js/schemas.ts +76 -14
  8. package/src/admin/js/templates/config.ts +10 -9
  9. package/src/admin/js/templates/errorRow.ts +8 -5
  10. package/src/admin/js/templates/index.ts +1 -0
  11. package/src/admin/js/templates/poolCard.ts +6 -6
  12. package/src/admin/js/templates/traceRow.ts +24 -0
  13. package/src/admin/js/types.ts +132 -19
  14. package/src/admin/js/ui/components.ts +44 -0
  15. package/src/admin/js/ui/table.ts +173 -0
  16. package/src/admin/js/ui/views.ts +311 -48
  17. package/src/admin/js/util/format.ts +22 -3
  18. package/src/admin/js/util/metrics.ts +24 -0
  19. package/src/admin/lib/chart.bundle.css +1 -0
  20. package/src/admin/lib/chart.bundle.js +54 -47
  21. package/src/admin/style.css +143 -27
  22. package/src/handler/handlerAdmin.js +121 -26
  23. package/src/handler/plsql/errorPage.js +0 -4
  24. package/src/handler/plsql/owaPageStream.js +93 -0
  25. package/src/handler/plsql/procedure.js +191 -121
  26. package/src/handler/plsql/procedureNamed.js +17 -3
  27. package/src/handler/plsql/procedureSanitize.js +24 -0
  28. package/src/handler/plsql/procedureVariable.js +2 -0
  29. package/src/handler/plsql/sendResponse.js +41 -3
  30. package/src/index.js +0 -1
  31. package/src/server/adminContext.js +23 -0
  32. package/src/server/server.js +83 -68
  33. package/src/types.js +1 -1
  34. package/src/util/statsManager.js +368 -0
  35. package/src/util/trace.js +2 -1
  36. package/src/util/traceManager.js +68 -0
  37. package/src/version.js +1 -1
  38. package/types/admin/js/schemas.d.ts +384 -0
  39. package/types/admin/js/types.d.ts +312 -0
  40. package/types/handler/handlerAdmin.d.ts +70 -0
  41. package/types/handler/plsql/owaPageStream.d.ts +28 -0
  42. package/types/handler/plsql/procedure.d.ts +1 -0
  43. package/types/index.d.ts +0 -1
  44. package/types/server/adminContext.d.ts +17 -0
  45. package/types/server/server.d.ts +2 -19
  46. package/types/types.d.ts +1 -1
  47. package/types/util/statsManager.d.ts +395 -0
  48. package/types/util/traceManager.d.ts +35 -0
  49. package/src/admin/lib/assets/main-zpdhQ1gD.css +0 -1
  50. package/src/handler/handlerMetrics.js +0 -68
  51. package/types/handler/handlerMetrics.d.ts +0 -25
@@ -1,5 +1,6 @@
1
1
  :root {
2
2
  /* Material Design 3 inspired tokens - Light Mode */
3
+ --accent-rgb: 59, 130, 246;
3
4
  --bg-main: #f8fafc;
4
5
  --bg-side: #fff;
5
6
  --bg-card: #fff;
@@ -427,6 +428,50 @@ tr:last-child td {
427
428
  }
428
429
  }
429
430
 
431
+ /* System View Compact */
432
+ .view-system-compact {
433
+ padding: 20px 32px;
434
+ }
435
+
436
+ .view-system-compact .metrics-grid {
437
+ display: grid;
438
+ grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
439
+ gap: 16px;
440
+ align-items: start;
441
+ margin-bottom: 24px;
442
+ }
443
+
444
+ .view-system-compact .card {
445
+ padding: 16px;
446
+ }
447
+
448
+ .view-system-compact .stat-row {
449
+ padding: 4px 0;
450
+ }
451
+
452
+ .view-system-compact .card-header {
453
+ margin-bottom: 12px;
454
+ }
455
+
456
+ .resource-card {
457
+ display: flex;
458
+ flex-direction: column;
459
+ min-height: 350px;
460
+ }
461
+
462
+ .resource-card .chart-container-mini {
463
+ margin-top: auto;
464
+ }
465
+
466
+ .chart-container-mini {
467
+ height: 100px;
468
+ width: 100%;
469
+ position: relative;
470
+ overflow: hidden;
471
+ border: none;
472
+ border-radius: 4px;
473
+ }
474
+
430
475
  /* Theme Toggle */
431
476
 
432
477
  /* Chart Styles */
@@ -1055,6 +1100,13 @@ nav button .material-symbols-rounded {
1055
1100
  text-align: left;
1056
1101
  }
1057
1102
 
1103
+ /* Dense table variant */
1104
+ .dense-table th,
1105
+ .dense-table td {
1106
+ padding: 6px 12px;
1107
+ font-size: 0.8125rem;
1108
+ }
1109
+
1058
1110
  /* Code style */
1059
1111
  .code {
1060
1112
  font-family: 'JetBrains Mono', 'Fira Code', monospace;
@@ -1138,45 +1190,95 @@ nav button .material-symbols-rounded {
1138
1190
  font-size: 28px;
1139
1191
  }
1140
1192
 
1141
- /* Overlay */
1142
- .overlay {
1193
+ /* Modal */
1194
+ .modal {
1143
1195
  position: fixed;
1144
1196
  inset: 0;
1145
- background-color: rgba(0, 0, 0, 0.5);
1197
+ background-color: rgba(0, 0, 0, 0.7);
1146
1198
  display: flex;
1147
1199
  align-items: center;
1148
1200
  justify-content: center;
1149
- z-index: 100;
1201
+ z-index: 1000;
1202
+ padding: 20px;
1150
1203
  }
1151
1204
 
1152
- .modal {
1205
+ .modal-content {
1153
1206
  background-color: var(--bg-card);
1154
1207
  border-radius: 12px;
1155
- padding: 24px;
1156
- max-width: 400px;
1157
- width: 90%;
1158
- box-shadow:
1159
- 0 20px 25px -5px rgb(0 0 0 / 0.1),
1160
- 0 8px 10px -6px rgb(0 0 0 / 0.1);
1208
+ width: 100%;
1209
+ max-width: 900px;
1210
+ box-shadow: var(--shadow);
1211
+ display: flex;
1212
+ flex-direction: column;
1213
+ max-height: 90vh;
1161
1214
  }
1162
1215
 
1163
- .modal-title {
1216
+ .modal-header {
1217
+ padding: 16px 24px;
1218
+ border-bottom: 1px solid var(--border);
1219
+ display: flex;
1220
+ justify-content: space-between;
1221
+ align-items: center;
1222
+ }
1223
+
1224
+ .modal-header h3 {
1225
+ margin: 0;
1164
1226
  font-size: 1.125rem;
1165
- font-weight: 600;
1166
1227
  color: var(--text-bright);
1167
- margin-bottom: 8px;
1168
1228
  }
1169
1229
 
1170
- .modal-text {
1171
- color: var(--text-main);
1172
- font-size: 0.875rem;
1173
- margin-bottom: 24px;
1230
+ .modal-body {
1231
+ padding: 24px;
1232
+ overflow-y: auto;
1174
1233
  }
1175
1234
 
1176
- .modal-actions {
1235
+ .stat-values-inline {
1177
1236
  display: flex;
1178
- gap: 12px;
1179
- justify-content: flex-end;
1237
+ align-items: center;
1238
+ gap: 8px;
1239
+ }
1240
+
1241
+ /* Stat values for min/max */
1242
+ .stat-values {
1243
+ display: flex;
1244
+ flex-direction: column;
1245
+ align-items: flex-end;
1246
+ }
1247
+
1248
+ .min-max {
1249
+ font-size: 0.7rem;
1250
+ opacity: 0.7;
1251
+ margin-top: 2px;
1252
+ }
1253
+
1254
+ /* Search Group */
1255
+ .search-group {
1256
+ position: relative;
1257
+ display: flex;
1258
+ align-items: center;
1259
+ }
1260
+
1261
+ .input-sm {
1262
+ background-color: var(--bg-side);
1263
+ border: 1px solid var(--border);
1264
+ border-radius: 6px;
1265
+ color: var(--text-bright);
1266
+ padding: 6px 12px;
1267
+ font-size: 0.875rem;
1268
+ }
1269
+
1270
+ .input-sm:focus {
1271
+ border-color: var(--accent);
1272
+ outline: none;
1273
+ }
1274
+
1275
+ .errors-table-row {
1276
+ cursor: pointer;
1277
+ transition: background-color 0.2s;
1278
+ }
1279
+
1280
+ .errors-table-row:hover {
1281
+ background-color: rgba(var(--accent-rgb), 0.08);
1180
1282
  }
1181
1283
 
1182
1284
  /* Server status indicator enhanced */
@@ -1238,12 +1340,6 @@ nav button .material-symbols-rounded {
1238
1340
  margin-bottom: 8px;
1239
1341
  }
1240
1342
 
1241
- .danger-zone-text {
1242
- color: var(--text-main);
1243
- font-size: 0.875rem;
1244
- margin-bottom: 16px;
1245
- }
1246
-
1247
1343
  /* Empty state */
1248
1344
  .empty-state {
1249
1345
  text-align: center;
@@ -1319,3 +1415,23 @@ select {
1319
1415
  accent-color: var(--accent);
1320
1416
  cursor: pointer;
1321
1417
  }
1418
+
1419
+ /* Animations */
1420
+ @keyframes spin {
1421
+ from {
1422
+ transform: rotate(0deg);
1423
+ }
1424
+ to {
1425
+ transform: rotate(360deg);
1426
+ }
1427
+ }
1428
+
1429
+ .animate-spin {
1430
+ animation: spin 1s linear infinite;
1431
+ display: inline-block;
1432
+ }
1433
+
1434
+ .btn.loading {
1435
+ pointer-events: none;
1436
+ opacity: 0.8;
1437
+ }
@@ -1,7 +1,9 @@
1
1
  import express from 'express';
2
2
  import fs from 'node:fs';
3
+ import os from 'node:os';
3
4
  import readline from 'node:readline';
4
- import {AdminContext} from '../server/server.js';
5
+ import {AdminContext} from '../server/adminContext.js';
6
+ import {traceManager} from '../util/traceManager.js';
5
7
  import {getVersion} from '../version.js';
6
8
 
7
9
  const version = getVersion();
@@ -13,15 +15,40 @@ import {forceShutdown} from '../util/shutdown.js';
13
15
  * @typedef {import('express').NextFunction} NextFunction
14
16
  */
15
17
 
18
+ /**
19
+ * @typedef {import('../util/statsManager.js').Bucket} Bucket
20
+ */
21
+
22
+ /**
23
+ * @typedef {object} StatsSummary
24
+ * @property {Date} startTime - Server start time.
25
+ * @property {number} totalRequests - Total requests handled.
26
+ * @property {number} totalErrors - Total errors encountered.
27
+ * @property {number} avgResponseTime - Lifetime average response time.
28
+ * @property {number} minResponseTime - Lifetime minimum response time.
29
+ * @property {number} maxResponseTime - Lifetime maximum response time.
30
+ * @property {number} maxRequestsPerSecond - Lifetime maximum requests per second.
31
+ * @property {object} maxMemory - Lifetime memory extremes.
32
+ * @property {number} maxMemory.heapUsedMax - Maximum heap used.
33
+ * @property {number} maxMemory.heapTotalMax - Maximum heap total.
34
+ * @property {number} maxMemory.rssMax - Maximum RSS.
35
+ * @property {number} maxMemory.externalMax - Maximum external memory.
36
+ * @property {object} cpu - Lifetime CPU extremes.
37
+ * @property {number} cpu.max - Max CPU.
38
+ * @property {number} cpu.userMax - Max user CPU.
39
+ * @property {number} cpu.systemMax - Max system CPU.
40
+ */
41
+
16
42
  export const handlerAdmin = express.Router();
17
43
 
18
44
  /**
19
45
  * Helper to read last N lines of a file
20
46
  * @param {string} filePath - Path to file
21
47
  * @param {number} n - Number of lines
48
+ * @param {string} [filter] - Optional filter string
22
49
  * @returns {Promise<string[]>} - The lines
23
50
  */
24
- const readLastLines = async (filePath, n = 100) => {
51
+ const readLastLines = async (filePath, n = 100, filter = '') => {
25
52
  if (!fs.existsSync(filePath)) {
26
53
  return [];
27
54
  }
@@ -32,12 +59,16 @@ const readLastLines = async (filePath, n = 100) => {
32
59
  crlfDelay: Infinity,
33
60
  });
34
61
 
62
+ const filterLower = filter.toLowerCase();
63
+
35
64
  /** @type {string[]} */
36
65
  const lines = [];
37
66
  for await (const line of rl) {
38
- lines.push(line);
39
- if (lines.length > n) {
40
- lines.shift();
67
+ if (!filter || line.toLowerCase().includes(filterLower)) {
68
+ lines.push(line);
69
+ if (lines.length > n) {
70
+ lines.shift();
71
+ }
41
72
  }
42
73
  }
43
74
  return lines;
@@ -48,42 +79,72 @@ handlerAdmin.get('/api/status', (_req, res) => {
48
79
  const uptime = (new Date().getTime() - AdminContext.startTime.getTime()) / 1000;
49
80
 
50
81
  const poolStats = AdminContext.pools.map((pool, index) => {
51
- const name = AdminContext.caches[index]?.poolName ?? `pool-${index}`;
82
+ const cache = AdminContext.caches[index];
83
+ const name = cache?.poolName ?? `pool-${index}`;
52
84
  const p = /** @type {import('oracledb').Pool & {getStatistics?: () => Record<string, unknown>}} */ (pool);
85
+ const procStats = cache?.procedureNameCache.getStats();
86
+ const argStats = cache?.argumentCache.getStats();
87
+
53
88
  return {
54
89
  name,
55
90
  stats: typeof p.getStatistics === 'function' ? p.getStatistics() : null,
56
91
  connectionsOpen: pool.connectionsOpen,
57
92
  connectionsInUse: pool.connectionsInUse,
93
+ cache: {
94
+ procedureName: {
95
+ size: cache?.procedureNameCache.keys().length ?? 0,
96
+ hits: procStats?.hits ?? 0,
97
+ misses: procStats?.misses ?? 0,
98
+ },
99
+ argument: {
100
+ size: cache?.argumentCache.keys().length ?? 0,
101
+ hits: argStats?.hits ?? 0,
102
+ misses: argStats?.misses ?? 0,
103
+ },
104
+ },
58
105
  };
59
106
  });
60
107
 
61
108
  const memUsage = process.memoryUsage();
109
+ const systemMemoryUsed = os.totalmem() - os.freemem();
62
110
  const cpuUsage = process.cpuUsage();
111
+ const summary = /** @type {StatsSummary} */ (AdminContext.statsManager.getSummary());
63
112
 
64
113
  res.json({
65
114
  version,
66
115
  status: AdminContext.paused ? 'paused' : 'running',
67
116
  uptime,
68
117
  startTime: AdminContext.startTime,
118
+ intervalMs: AdminContext.statsManager.config.intervalMs,
69
119
  metrics: {
70
- ...AdminContext.metrics,
71
- avgResponseTime: AdminContext.metrics.requestCount > 0 ? AdminContext.metrics.totalDuration / AdminContext.metrics.requestCount : 0,
120
+ requestCount: summary.totalRequests,
121
+ errorCount: summary.totalErrors,
122
+ avgResponseTime: summary.avgResponseTime,
123
+ minResponseTime: summary.minResponseTime,
124
+ maxResponseTime: summary.maxResponseTime,
125
+ maxRequestsPerSecond: summary.maxRequestsPerSecond,
72
126
  },
127
+ history: AdminContext.statsManager.getHistory(),
73
128
  pools: poolStats,
74
129
  system: {
75
130
  nodeVersion: process.version,
76
131
  platform: process.platform,
77
132
  arch: process.arch,
133
+ cpuCores: os.cpus().length,
78
134
  memory: {
79
- rss: memUsage.rss,
135
+ rss: systemMemoryUsed,
80
136
  heapTotal: memUsage.heapTotal,
81
137
  heapUsed: memUsage.heapUsed,
82
138
  external: memUsage.external,
139
+ totalMemory: os.totalmem(),
140
+ ...summary.maxMemory,
83
141
  },
84
142
  cpu: {
85
143
  user: cpuUsage.user,
86
144
  system: cpuUsage.system,
145
+ max: summary.cpu.max,
146
+ userMax: summary.cpu.userMax,
147
+ systemMax: summary.cpu.systemMax,
87
148
  },
88
149
  },
89
150
  config: AdminContext.config
@@ -103,8 +164,9 @@ handlerAdmin.get('/api/status', (_req, res) => {
103
164
  handlerAdmin.get('/api/logs/error', async (req, res) => {
104
165
  try {
105
166
  const limit = Number(req.query.limit) || 100;
167
+ const filter = typeof req.query.filter === 'string' ? req.query.filter : '';
106
168
  const logFile = 'error.json.log';
107
- const lines = await readLastLines(logFile, limit);
169
+ const lines = await readLastLines(logFile, limit, filter);
108
170
 
109
171
  /** @type {Record<string, unknown>[]} */
110
172
  const logs = [];
@@ -129,6 +191,7 @@ handlerAdmin.get('/api/logs/error', async (req, res) => {
129
191
  handlerAdmin.get('/api/logs/access', async (req, res) => {
130
192
  try {
131
193
  const limit = Number(req.query.limit) || 100;
194
+ const filter = typeof req.query.filter === 'string' ? req.query.filter : '';
132
195
  const logFile = AdminContext.config?.loggerFilename ?? 'access.log';
133
196
 
134
197
  if (!AdminContext.config?.loggerFilename) {
@@ -136,28 +199,14 @@ handlerAdmin.get('/api/logs/access', async (req, res) => {
136
199
  return;
137
200
  }
138
201
 
139
- const lines = await readLastLines(logFile, limit);
202
+ const lines = await readLastLines(logFile, limit, filter);
140
203
  res.json(lines.reverse());
141
204
  } catch (err) {
142
205
  res.status(500).json({error: String(err)});
143
206
  }
144
207
  });
145
208
 
146
- // GET /api/cache
147
- handlerAdmin.get('/api/cache', (_req, res) => {
148
- const caches = AdminContext.caches.map((c) => ({
149
- poolName: c.poolName,
150
- procedureNameCache: {
151
- size: c.procedureNameCache.keys().length,
152
- stats: c.procedureNameCache.getStats(),
153
- },
154
- argumentCache: {
155
- size: c.argumentCache.keys().length,
156
- stats: c.argumentCache.getStats(),
157
- },
158
- }));
159
- res.json(caches);
160
- });
209
+ // GET /api/cache - REMOVED (Merged into /api/status)
161
210
 
162
211
  // POST /api/cache/clear
163
212
  handlerAdmin.post('/api/cache/clear', (req, res) => {
@@ -196,3 +245,49 @@ handlerAdmin.post('/api/server/:action', (req, res) => {
196
245
  res.status(400).json({error: 'Invalid action'});
197
246
  }
198
247
  });
248
+
249
+ // GET /api/trace/status
250
+ handlerAdmin.get('/api/trace/status', (_req, res) => {
251
+ res.json({enabled: traceManager.isEnabled()});
252
+ });
253
+
254
+ // POST /api/trace/toggle
255
+ handlerAdmin.post('/api/trace/toggle', (req, res) => {
256
+ const body = /** @type {unknown} */ (req.body);
257
+ const enabled = body && typeof body === 'object' && 'enabled' in body && typeof body.enabled === 'boolean' ? body.enabled : false;
258
+ traceManager.setEnabled(enabled);
259
+ res.json({enabled: traceManager.isEnabled()});
260
+ });
261
+
262
+ // GET /api/trace/logs
263
+ handlerAdmin.get('/api/trace/logs', async (req, res) => {
264
+ try {
265
+ const limit = Number(req.query.limit) || 100;
266
+ const filter = typeof req.query.filter === 'string' ? req.query.filter : '';
267
+ const logFile = traceManager.getFilePath();
268
+ const lines = await readLastLines(logFile, limit, filter);
269
+
270
+ /** @type {Record<string, unknown>[]} */
271
+ const logs = [];
272
+ for (const line of lines) {
273
+ try {
274
+ const parsed = /** @type {unknown} */ (JSON.parse(line));
275
+ if (parsed && typeof parsed === 'object') {
276
+ logs.push(/** @type {Record<string, unknown>} */ (parsed));
277
+ }
278
+ } catch (e) {
279
+ // ignore
280
+ }
281
+ }
282
+
283
+ res.json(logs.reverse());
284
+ } catch (err) {
285
+ res.status(500).json({error: String(err)});
286
+ }
287
+ });
288
+
289
+ // POST /api/trace/clear
290
+ handlerAdmin.post('/api/trace/clear', (_req, res) => {
291
+ traceManager.clear();
292
+ res.json({message: 'Traces cleared'});
293
+ });
@@ -8,7 +8,6 @@ import {getFormattedMessage, logToFile} from '../../util/trace.js';
8
8
  import {errorToString} from '../../util/errorToString.js';
9
9
  import {getHtmlPage} from '../../util/html.js';
10
10
  import {jsonLogger} from '../../util/jsonLogger.js';
11
- import {AdminContext} from '../../server/server.js';
12
11
 
13
12
  /**
14
13
  * @typedef {import('express').Request} Request
@@ -76,9 +75,6 @@ export const errorPage = (req, res, options, error) => {
76
75
  // get error data
77
76
  const errorData = getErrorData(req, error);
78
77
 
79
- // Update metrics
80
- AdminContext.metrics.errorCount++;
81
-
82
78
  // get formatted message
83
79
  const {html, text} = getFormattedMessage(errorData);
84
80
 
@@ -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
+ }