4bnode 4.0.2 → 4.0.4

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.
@@ -3,11 +3,51 @@ import fs from 'fs';
3
3
  import path from 'path';
4
4
  import { execSync, exec } from 'child_process';
5
5
  import { promisify } from 'util';
6
+ import { pathToFileURL } from 'url';
6
7
  const execAsync = promisify(exec);
7
8
  import crypto from 'crypto';
8
9
 
9
10
  const router = express.Router();
10
11
 
12
+ // ── Console Log Capture ─────────────────────────────────────────────────────
13
+ const _logBuffer = [];
14
+ const _logMaxSize = 500;
15
+ const _logClients = new Set();
16
+
17
+ function pushLog(type, args) {
18
+ const entry = {
19
+ id: Date.now() + '-' + Math.random().toString(36).slice(2, 7),
20
+ type,
21
+ message: args.map(a => (typeof a === 'string' ? a : JSON.stringify(a, null, 2))).join(' '),
22
+ timestamp: new Date().toISOString(),
23
+ };
24
+ _logBuffer.push(entry);
25
+ if (_logBuffer.length > _logMaxSize) _logBuffer.splice(0, _logBuffer.length - _logMaxSize);
26
+ for (const client of _logClients) {
27
+ client.write(`data: ${JSON.stringify(entry)}\n\n`);
28
+ }
29
+ }
30
+
31
+ const _origLog = console.log.bind(console);
32
+ const _origError = console.error.bind(console);
33
+ const _origWarn = console.warn.bind(console);
34
+ const _origInfo = console.info.bind(console);
35
+
36
+ console.log = (...args) => { _origLog(...args); pushLog('log', args); };
37
+ console.error = (...args) => { _origError(...args); pushLog('error', args); };
38
+ console.warn = (...args) => { _origWarn(...args); pushLog('warn', args); };
39
+ console.info = (...args) => { _origInfo(...args); pushLog('info', args); };
40
+
41
+ // Capture uncaught errors
42
+ process.on('uncaughtException', (err) => {
43
+ pushLog('error', [`Uncaught Exception: ${err.stack || err.message}`]);
44
+ _origError('Uncaught Exception:', err);
45
+ });
46
+ process.on('unhandledRejection', (reason) => {
47
+ pushLog('error', [`Unhandled Rejection: ${reason?.stack || reason}`]);
48
+ _origError('Unhandled Rejection:', reason);
49
+ });
50
+
11
51
  // Serve dashboard UI and assets
12
52
  const __4bnodeDir = path.join(process.cwd(), '.4bnode');
13
53
  router.get('/', (req, res) => {
@@ -40,6 +80,8 @@ router.post('/api/auth', (req, res) => {
40
80
  // Auth middleware — protect all /api/* routes below
41
81
  router.use('/api', (req, res, next) => {
42
82
  if (req.path === '/auth') return next();
83
+ // SSE endpoint handles its own auth via query param (EventSource can't send headers)
84
+ if (req.path === '/logs/stream') return next();
43
85
  const stored = getPasskeyHash();
44
86
  if (!stored) return next();
45
87
  const passkey = req.headers['x-passkey'];
@@ -133,12 +175,12 @@ function getModelSchemaDetailed(modelName) {
133
175
  modelName + ".js"
134
176
  );
135
177
  if (!fs.existsSync(modelFilePath)) {
136
- return [];
178
+ return { fields: [], indexes: [] };
137
179
  }
138
180
 
139
181
  const content = fs.readFileSync(modelFilePath, "utf8");
140
182
  const schemaContent = extractSchemaBlock(content);
141
- if (!schemaContent) return [];
183
+ if (!schemaContent) return { fields: [], indexes: [] };
142
184
  const fields = [];
143
185
  // Match each field block: fieldName: { type: ..., required: ..., unique: ..., default: ... }
144
186
  const fieldRegex = /(\w+)\s*:\s*\{([^}]*)\}/g;
@@ -152,16 +194,41 @@ function getModelSchemaDetailed(modelName) {
152
194
  : "String";
153
195
  const required = /required:\s*true/.test(props);
154
196
  const unique = /unique:\s*true/.test(props);
197
+ const indexed = /index:\s*true/.test(props);
155
198
  const refMatch = props.match(/ref:\s*['"](\w+)['"]/);
156
199
  const defaultMatch = props.match(/default:\s*(.+?)(?:,\s*\w+:|$)/);
157
200
  const field = { name, type, required, unique };
201
+ if (indexed) field.index = true;
158
202
  if (refMatch) field.ref = refMatch[1];
159
203
  if (defaultMatch) {
160
204
  field.default = defaultMatch[1].trim();
161
205
  }
162
206
  fields.push(field);
163
207
  }
164
- return fields;
208
+
209
+ // Parse compound/text indexes: SchemaName.index({ ... })
210
+ const indexes = [];
211
+ const indexRegex = /\w+Schema\.index\(\s*\{([^}]+)\}(?:\s*,\s*\{([^}]*)\})?\s*\)/g;
212
+ let idxMatch;
213
+ while ((idxMatch = indexRegex.exec(content)) !== null) {
214
+ const fieldsStr = idxMatch[1];
215
+ const optsStr = idxMatch[2] || '';
216
+ const idxFields = [];
217
+ const fieldEntries = fieldsStr.split(',').map(s => s.trim()).filter(Boolean);
218
+ for (const entry of fieldEntries) {
219
+ const [key, val] = entry.split(':').map(s => s.trim());
220
+ if (val === "'text'" || val === '"text"') {
221
+ idxFields.push({ field: key, direction: 'text' });
222
+ } else {
223
+ idxFields.push({ field: key, direction: parseInt(val) || 1 });
224
+ }
225
+ }
226
+ const isUnique = /unique:\s*true/.test(optsStr);
227
+ const isText = idxFields.some(f => f.direction === 'text');
228
+ indexes.push({ fields: idxFields, unique: isUnique, type: isText ? 'text' : 'compound' });
229
+ }
230
+
231
+ return { fields, indexes };
165
232
  }
166
233
 
167
234
  function getModelSchema(modelName) {
@@ -323,7 +390,7 @@ ${assignments}
323
390
  };
324
391
  ${passwordLogic}
325
392
  try {
326
- const updatedDocument = await ${pascalName}.findByIdAndUpdate(req.params.id, updatedData, { new: true });
393
+ const updatedDocument = await ${pascalName}.findByIdAndUpdate(req.params.id, updatedData, { returnDocument: 'after' });
327
394
  if (!updatedDocument) {
328
395
  return res.status(404).json({ message: 'Document not found' });
329
396
  }
@@ -355,23 +422,35 @@ router.delete('/:id', async (req, res) => {
355
422
  // Schema file generation helper
356
423
  // ---------------------------------------------------------------------------
357
424
 
358
- function generateSchemaFileContent(pascalName, fields) {
425
+ function generateSchemaFileContent(pascalName, fields, indexes = []) {
359
426
  const fieldLines = fields.map((f) => {
360
427
  const type = f.type || 'String';
361
428
  const parts = [type === 'ObjectId' ? 'type: mongoose.Schema.Types.ObjectId' : `type: ${type}`];
362
429
  if (f.ref) parts.push(`ref: '${f.ref}'`);
363
430
  if (f.required) parts.push('required: true');
364
431
  if (f.unique) parts.push('unique: true');
432
+ if (f.index) parts.push('index: true');
365
433
  if (f.default !== undefined && f.default !== '') parts.push(`default: ${f.default}`);
366
434
  return ` ${f.name}: { ${parts.join(', ')} }`;
367
435
  });
368
436
 
437
+ // Add index lines
438
+ let indexLines = '';
439
+ for (const idx of indexes) {
440
+ const fieldObj = idx.fields.map(f => {
441
+ if (f.direction === 'text') return `${f.field}: 'text'`;
442
+ return `${f.field}: ${f.direction || 1}`;
443
+ }).join(', ');
444
+ const options = idx.unique ? ', { unique: true }' : '';
445
+ indexLines += `\n${pascalName}Schema.index({ ${fieldObj} }${options});`;
446
+ }
447
+
369
448
  return `import mongoose from 'mongoose';
370
449
 
371
450
  const ${pascalName}Schema = new mongoose.Schema({
372
451
  ${fieldLines.join(',\n')}
373
452
  }, { timestamps: true });
374
-
453
+ ${indexLines}
375
454
  const ${pascalName} = mongoose.model('${pascalName}', ${pascalName}Schema);
376
455
 
377
456
  export default ${pascalName};
@@ -388,8 +467,8 @@ router.get('/api/dashboard', (req, res) => {
388
467
  const modelFiles = listModels();
389
468
  const models = modelFiles.map((f) => {
390
469
  const name = path.basename(f, '.js');
391
- const fields = getModelSchemaDetailed(name);
392
- return { name, fields };
470
+ const { fields, indexes } = getModelSchemaDetailed(name);
471
+ return { name, fields, indexes };
393
472
  });
394
473
  const routes = listRoutes();
395
474
  const mongoConfigured = checkMongoConfig();
@@ -435,8 +514,8 @@ router.get('/api/models', (req, res) => {
435
514
  router.get('/api/models/:name', (req, res) => {
436
515
  try {
437
516
  const name = req.params.name;
438
- const fields = getModelSchemaDetailed(name);
439
- res.json({ name, fields });
517
+ const { fields, indexes } = getModelSchemaDetailed(name);
518
+ res.json({ name, fields, indexes });
440
519
  } catch (err) {
441
520
  res.status(500).json({ error: err.message });
442
521
  }
@@ -456,7 +535,7 @@ router.get('/api/models/:name/data', async (req, res) => {
456
535
  if (!fs.existsSync(projectMongoose)) {
457
536
  return res.status(400).json({ error: 'Mongoose is not installed. Set up MongoDB first.' });
458
537
  }
459
- const mongoose = (await import(projectMongoose)).default;
538
+ const mongoose = (await import(pathToFileURL(projectMongoose).href)).default;
460
539
 
461
540
  // Connect if not already connected
462
541
  if (mongoose.connection.readyState === 0) {
@@ -482,7 +561,7 @@ router.get('/api/models/:name/data', async (req, res) => {
482
561
  router.get('/api/models/:name/share', (req, res) => {
483
562
  try {
484
563
  const name = req.params.name;
485
- const fields = getModelSchemaDetailed(name);
564
+ const { fields } = getModelSchemaDetailed(name);
486
565
  const routes = listRoutes();
487
566
  const routeFile = routes.find(r => r === name + '.js' || r === name + 's.js');
488
567
 
@@ -519,7 +598,7 @@ router.post('/api/models', (req, res) => {
519
598
  return res.status(400).json({ error: 'Database must be configured before creating schemas. Set up MongoDB first.' });
520
599
  }
521
600
 
522
- const { name, fields } = req.body;
601
+ const { name, fields, indexes } = req.body;
523
602
  const safeName = sanitizeName(name);
524
603
  const pascalName = toPascalCase(safeName);
525
604
 
@@ -533,7 +612,7 @@ router.post('/api/models', (req, res) => {
533
612
  return res.status(409).json({ error: `Model "${safeName}" already exists.` });
534
613
  }
535
614
 
536
- const content = generateSchemaFileContent(pascalName, fields);
615
+ const content = generateSchemaFileContent(pascalName, fields, indexes);
537
616
  fs.writeFileSync(filePath, content, 'utf8');
538
617
  res.json({ message: `Model "${safeName}" created.`, file: `src/models/${safeName}.js` });
539
618
  } catch (err) {
@@ -545,7 +624,7 @@ router.post('/api/models', (req, res) => {
545
624
  router.put('/api/models/:name', (req, res) => {
546
625
  try {
547
626
  const name = req.params.name;
548
- const { fields } = req.body;
627
+ const { fields, indexes } = req.body;
549
628
  const pascalName = toPascalCase(name);
550
629
 
551
630
  const filePath = path.join(process.cwd(), 'src', 'models', name + '.js');
@@ -553,7 +632,7 @@ router.put('/api/models/:name', (req, res) => {
553
632
  return res.status(404).json({ error: `Model "${name}" not found.` });
554
633
  }
555
634
 
556
- const content = generateSchemaFileContent(pascalName, fields);
635
+ const content = generateSchemaFileContent(pascalName, fields, indexes);
557
636
  fs.writeFileSync(filePath, content, 'utf8');
558
637
  res.json({ message: `Model "${name}" updated.`, file: `src/models/${name}.js` });
559
638
  } catch (err) {
@@ -749,18 +828,56 @@ router.post('/api/routes/:name/add-method', async (req, res) => {
749
828
  } else if (routeType === 'crud' && modelName) {
750
829
  addImport(`import ${pascalModel} from '../models/${modelName}.js';`);
751
830
  const crudMethods = newMethods.length > 0 ? newMethods : ['get', 'post', 'put', 'delete'].filter(m => !existingMethods.includes(m));
831
+ const allCrudFields = [...selectedFields.map(f => typeof f === 'string' ? { name: f, type: 'String' } : f), ...customFields];
832
+ const crudBodyFields = allCrudFields.filter(f => f.type !== 'File');
833
+ const crudFileFields = allCrudFields.filter(f => f.type === 'File');
834
+ const crudHasFiles = crudFileFields.length > 0;
835
+ const crudHasFields = allCrudFields.length > 0;
836
+
837
+ if (crudHasFiles) {
838
+ addImport("import multer from 'multer';");
839
+ if (!content.includes('const upload = multer')) {
840
+ content = content.replace('const router = express.Router();', "const router = express.Router();\n\nconst upload = multer({ dest: 'uploads/' });");
841
+ }
842
+ packagesToInstall.push('multer');
843
+ }
844
+
845
+ let dest = '';
846
+ let obj = 'req.body';
847
+ if (crudHasFields) {
848
+ const parts = [];
849
+ if (crudBodyFields.length > 0) {
850
+ dest = `const { ${crudBodyFields.map(f => f.name).join(', ')} } = req.body;`;
851
+ parts.push(...crudBodyFields.map(f => f.name));
852
+ }
853
+ if (crudFileFields.length > 0) {
854
+ const fileLines = crudFileFields.map(f => {
855
+ if (crudFileFields.length === 1) return `const ${f.name} = req.file ? req.file.path : undefined;`;
856
+ return `const ${f.name} = req.files?.['${f.name}']?.[0]?.path || undefined;`;
857
+ }).join('\\n ');
858
+ dest = (dest ? dest + '\\n ' : '') + fileLines;
859
+ parts.push(...crudFileFields.map(f => f.name));
860
+ }
861
+ obj = `{ ${parts.join(', ')} }`;
862
+ }
863
+
864
+ let mw = '';
865
+ if (crudHasFiles) {
866
+ if (crudFileFields.length === 1) mw = `upload.single('${crudFileFields[0].name}'), `;
867
+ else mw = `upload.fields([${crudFileFields.map(f => `{ name: '${f.name}', maxCount: 1 }`).join(', ')}]), `;
868
+ }
752
869
 
753
870
  if (crudMethods.includes('get')) {
754
871
  handlers += `\n// Get all\nrouter.get('/', async (req, res) => {\n try {\n const items = await ${pascalModel}.find();\n res.json(items);\n } catch (err) {\n res.status(500).json({ message: err.message });\n }\n});\n\n// Get by ID\nrouter.get('/:id', async (req, res) => {\n try {\n const item = await ${pascalModel}.findById(req.params.id);\n if (!item) return res.status(404).json({ message: '${pascalModel} not found' });\n res.json(item);\n } catch (err) {\n res.status(500).json({ message: err.message });\n }\n});\n`;
755
872
  }
756
873
  if (crudMethods.includes('post')) {
757
- handlers += `\n// Create\nrouter.post('/', async (req, res) => {\n try {\n const item = new ${pascalModel}(req.body);\n const saved = await item.save();\n res.status(201).json(saved);\n } catch (err) {\n res.status(400).json({ message: err.message });\n }\n});\n`;
874
+ handlers += `\n// Create\nrouter.post('/', ${mw}async (req, res) => {\n try {\n ${dest}\n const item = new ${pascalModel}(${obj});\n const saved = await item.save();\n res.status(201).json(saved);\n } catch (err) {\n res.status(400).json({ message: err.message });\n }\n});\n`;
758
875
  }
759
876
  if (crudMethods.includes('put')) {
760
- handlers += `\n// Update\nrouter.put('/:id', async (req, res) => {\n try {\n const item = await ${pascalModel}.findByIdAndUpdate(req.params.id, req.body, { new: true, runValidators: true });\n if (!item) return res.status(404).json({ message: '${pascalModel} not found' });\n res.json(item);\n } catch (err) {\n res.status(400).json({ message: err.message });\n }\n});\n`;
877
+ handlers += `\n// Update\nrouter.put('/:id', ${mw}async (req, res) => {\n try {\n ${dest}\n const item = await ${pascalModel}.findByIdAndUpdate(req.params.id, ${obj}, { returnDocument: 'after', runValidators: true });\n if (!item) return res.status(404).json({ message: '${pascalModel} not found' });\n res.json(item);\n } catch (err) {\n res.status(400).json({ message: err.message });\n }\n});\n`;
761
878
  }
762
879
  if (crudMethods.includes('patch')) {
763
- handlers += `\n// Partial Update\nrouter.patch('/:id', async (req, res) => {\n try {\n const item = await ${pascalModel}.findByIdAndUpdate(req.params.id, req.body, { new: true, runValidators: true });\n if (!item) return res.status(404).json({ message: '${pascalModel} not found' });\n res.json(item);\n } catch (err) {\n res.status(400).json({ message: err.message });\n }\n});\n`;
880
+ handlers += `\n// Partial Update\nrouter.patch('/:id', ${mw}async (req, res) => {\n try {\n ${dest}\n const item = await ${pascalModel}.findByIdAndUpdate(req.params.id, ${obj}, { returnDocument: 'after', runValidators: true });\n if (!item) return res.status(404).json({ message: '${pascalModel} not found' });\n res.json(item);\n } catch (err) {\n res.status(400).json({ message: err.message });\n }\n});\n`;
764
881
  }
765
882
  if (crudMethods.includes('delete')) {
766
883
  handlers += `\n// Delete\nrouter.delete('/:id', async (req, res) => {\n try {\n const item = await ${pascalModel}.findByIdAndDelete(req.params.id);\n if (!item) return res.status(404).json({ message: '${pascalModel} not found' });\n res.json({ message: '${pascalModel} deleted' });\n } catch (err) {\n res.status(500).json({ message: err.message });\n }\n});\n`;
@@ -849,7 +966,7 @@ router.get('/api/routes/:name/share', (req, res) => {
849
966
  const modelImportMatch = content.match(/import\s+(\w+)\s+from\s+['"]\.\.\/models\/(\w+)['"]/);
850
967
  let modelFields = [];
851
968
  if (modelImportMatch) {
852
- modelFields = getModelSchemaDetailed(modelImportMatch[2]);
969
+ modelFields = getModelSchemaDetailed(modelImportMatch[2]).fields;
853
970
  }
854
971
 
855
972
  // Parse each endpoint with body/query fields
@@ -1322,6 +1439,46 @@ router.${m}('/', async (req, res) => {
1322
1439
  }
1323
1440
  } else if (type === 'crud' && modelName) {
1324
1441
  const crudMethods = methodList.length > 0 ? methodList : ['get', 'post', 'put', 'delete'];
1442
+ const allFields = [...selectedFields.map(f => typeof f === 'string' ? { name: f, type: 'String' } : f), ...customFields];
1443
+ const bodyFields = allFields.filter(f => f.type !== 'File');
1444
+ const fileFields = allFields.filter(f => f.type === 'File');
1445
+ const hasFileFields = fileFields.length > 0;
1446
+ const hasFields = allFields.length > 0;
1447
+
1448
+ if (hasFileFields) {
1449
+ imports += "import multer from 'multer';\n";
1450
+ }
1451
+
1452
+ // Build destructure and object construction
1453
+ let destructure = '';
1454
+ let buildObj = 'req.body';
1455
+ if (hasFields) {
1456
+ const parts = [];
1457
+ if (bodyFields.length > 0) {
1458
+ destructure = `const { ${bodyFields.map(f => f.name).join(', ')} } = req.body;`;
1459
+ parts.push(...bodyFields.map(f => f.name));
1460
+ }
1461
+ if (fileFields.length > 0) {
1462
+ const fileLines = fileFields.map(f => {
1463
+ if (fileFields.length === 1) return `const ${f.name} = req.file ? req.file.path : undefined;`;
1464
+ return `const ${f.name} = req.files?.['${f.name}']?.[0]?.path || undefined;`;
1465
+ }).join('\n ');
1466
+ destructure = (destructure ? destructure + '\n ' : '') + fileLines;
1467
+ parts.push(...fileFields.map(f => f.name));
1468
+ }
1469
+ buildObj = `{ ${parts.join(', ')} }`;
1470
+ }
1471
+
1472
+ // Multer middleware string for write methods
1473
+ let middlewareStr = '';
1474
+ if (hasFileFields) {
1475
+ if (fileFields.length === 1) {
1476
+ middlewareStr = `upload.single('${fileFields[0].name}'), `;
1477
+ } else {
1478
+ const fieldsArg = fileFields.map(f => `{ name: '${f.name}', maxCount: 1 }`).join(', ');
1479
+ middlewareStr = `upload.fields([${fieldsArg}]), `;
1480
+ }
1481
+ }
1325
1482
 
1326
1483
  if (crudMethods.includes('get')) {
1327
1484
  handlers += `
@@ -1351,9 +1508,10 @@ router.get('/:id', async (req, res) => {
1351
1508
  if (crudMethods.includes('post')) {
1352
1509
  handlers += `
1353
1510
  // Create
1354
- router.post('/', async (req, res) => {
1511
+ router.post('/', ${middlewareStr}async (req, res) => {
1355
1512
  try {
1356
- const item = new ${pascalModel}(req.body);
1513
+ ${destructure}
1514
+ const item = new ${pascalModel}(${buildObj});
1357
1515
  const saved = await item.save();
1358
1516
  res.status(201).json(saved);
1359
1517
  } catch (err) {
@@ -1366,9 +1524,10 @@ router.post('/', async (req, res) => {
1366
1524
  if (crudMethods.includes('put')) {
1367
1525
  handlers += `
1368
1526
  // Update
1369
- router.put('/:id', async (req, res) => {
1527
+ router.put('/:id', ${middlewareStr}async (req, res) => {
1370
1528
  try {
1371
- const item = await ${pascalModel}.findByIdAndUpdate(req.params.id, req.body, { new: true, runValidators: true });
1529
+ ${destructure}
1530
+ const item = await ${pascalModel}.findByIdAndUpdate(req.params.id, ${buildObj}, { returnDocument: 'after', runValidators: true });
1372
1531
  if (!item) return res.status(404).json({ message: '${pascalModel} not found' });
1373
1532
  res.json(item);
1374
1533
  } catch (err) {
@@ -1381,9 +1540,10 @@ router.put('/:id', async (req, res) => {
1381
1540
  if (crudMethods.includes('patch')) {
1382
1541
  handlers += `
1383
1542
  // Partial Update
1384
- router.patch('/:id', async (req, res) => {
1543
+ router.patch('/:id', ${middlewareStr}async (req, res) => {
1385
1544
  try {
1386
- const item = await ${pascalModel}.findByIdAndUpdate(req.params.id, req.body, { new: true, runValidators: true });
1545
+ ${destructure}
1546
+ const item = await ${pascalModel}.findByIdAndUpdate(req.params.id, ${buildObj}, { returnDocument: 'after', runValidators: true });
1387
1547
  if (!item) return res.status(404).json({ message: '${pascalModel} not found' });
1388
1548
  res.json(item);
1389
1549
  } catch (err) {
@@ -1407,6 +1567,10 @@ router.delete('/:id', async (req, res) => {
1407
1567
  });
1408
1568
  `;
1409
1569
  }
1570
+
1571
+ if (hasFileFields) {
1572
+ handlers = `\nconst upload = multer({ dest: 'uploads/' });\n` + handlers;
1573
+ }
1410
1574
  } else if (type === 'register' && modelName) {
1411
1575
  const regFields = registerFields.length > 0 ? registerFields : [findField];
1412
1576
  const allDestructured = regFields.join(', ');
@@ -2307,7 +2471,7 @@ router.get('/api/api-tester/routes', (req, res) => {
2307
2471
  const modelImportMatch = content.match(/import\s+(\w+)\s+from\s+['"]\.\.\/models\/(\w+)['"]/);
2308
2472
  let modelFields = [];
2309
2473
  if (modelImportMatch) {
2310
- modelFields = getModelSchemaDetailed(modelImportMatch[2]);
2474
+ modelFields = getModelSchemaDetailed(modelImportMatch[2]).fields;
2311
2475
  }
2312
2476
 
2313
2477
  const endpoints = [];
@@ -2383,6 +2547,414 @@ router.get('/api/api-tester/routes', (req, res) => {
2383
2547
  }
2384
2548
  });
2385
2549
 
2550
+ // ---------------------------------------------------------------------------
2551
+ // Reports — schema info & Excel generation
2552
+ // ---------------------------------------------------------------------------
2553
+ router.get('/api/reports/schema-info', (req, res) => {
2554
+ try {
2555
+ const modelFiles = listModels();
2556
+ const models = modelFiles.map(f => {
2557
+ const name = path.basename(f, '.js');
2558
+ const detail = getModelSchemaDetailed(name);
2559
+ const fields = Array.isArray(detail) ? detail : detail.fields;
2560
+ return { name, fields: fields.map(field => {
2561
+ const result = { ...field };
2562
+ if (field.type === 'ObjectId' && field.ref) {
2563
+ const refDetail = getModelSchemaDetailed(field.ref);
2564
+ const refFields = Array.isArray(refDetail) ? refDetail : refDetail.fields;
2565
+ result.refFields = refFields.map(rf => rf.name);
2566
+ }
2567
+ return result;
2568
+ })};
2569
+ });
2570
+ res.json({ models });
2571
+ } catch (err) {
2572
+ res.status(500).json({ error: err.message });
2573
+ }
2574
+ });
2575
+
2576
+ // Date formatting helper
2577
+ function formatDateValue(val, format, tz) {
2578
+ if (!(val instanceof Date)) {
2579
+ try { val = new Date(val); } catch { return String(val); }
2580
+ if (isNaN(val.getTime())) return String(val);
2581
+ }
2582
+ if (format === 'ISO') return val.toISOString();
2583
+ if (format === 'UNIX') return Math.floor(val.getTime() / 1000);
2584
+ const isAmPm = format.includes(' A');
2585
+ const opts = { timeZone: tz || 'UTC', hour12: isAmPm };
2586
+ const parts = {};
2587
+ try {
2588
+ const fmt = new Intl.DateTimeFormat('en-US', { ...opts, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' });
2589
+ fmt.formatToParts(val).forEach(p => { parts[p.type] = p.value; });
2590
+ } catch { return val.toISOString(); }
2591
+ const Y = parts.year, M = parts.month, D = parts.day;
2592
+ const H = parts.hour, m = parts.minute, s = parts.second;
2593
+ const ampm = parts.dayPeriod || '';
2594
+ const time = isAmPm ? `${H}:${m}:${s} ${ampm}` : `${H}:${m}:${s}`;
2595
+ // Normalize hour for 24h (en-US with hour12:false gives "24" for midnight, fix to "00")
2596
+ const H24 = !isAmPm && H === '24' ? '00' : H;
2597
+ const time24 = `${H24}:${m}:${s}`;
2598
+ switch (format) {
2599
+ case 'YYYY-MM-DD HH:mm:ss': return `${Y}-${M}-${D} ${time24}`;
2600
+ case 'DD/MM/YYYY HH:mm:ss': return `${D}/${M}/${Y} ${time24}`;
2601
+ case 'MM/DD/YYYY HH:mm:ss': return `${M}/${D}/${Y} ${time24}`;
2602
+ case 'YYYY-MM-DD hh:mm:ss A': return `${Y}-${M}-${D} ${time}`;
2603
+ case 'DD/MM/YYYY hh:mm:ss A': return `${D}/${M}/${Y} ${time}`;
2604
+ case 'MM/DD/YYYY hh:mm:ss A': return `${M}/${D}/${Y} ${time}`;
2605
+ case 'YYYY-MM-DD': return `${Y}-${M}-${D}`;
2606
+ case 'DD/MM/YYYY': return `${D}/${M}/${Y}`;
2607
+ case 'MM/DD/YYYY': return `${M}/${D}/${Y}`;
2608
+ default: return `${Y}-${M}-${D} ${time24}`;
2609
+ }
2610
+ }
2611
+
2612
+ function formatVal(val, dateFormat, timezone) {
2613
+ if (val === null || val === undefined) return null;
2614
+ // Native Date
2615
+ if (val instanceof Date) return formatDateValue(val, dateFormat, timezone);
2616
+ // String that looks like ISO date
2617
+ if (typeof val === 'string' && /^\d{4}-\d{2}-\d{2}T/.test(val)) return formatDateValue(val, dateFormat, timezone);
2618
+ // Non-null objects (BSON types from MongoDB driver)
2619
+ if (typeof val === 'object') {
2620
+ const str = val.toString();
2621
+ // BSON Date — toString gives a date string, or has getTime
2622
+ if (typeof val.getTime === 'function') return formatDateValue(new Date(val.getTime()), dateFormat, timezone);
2623
+ // ObjectId or other BSON — toString gives hex string
2624
+ if (str && str !== '[object Object]') return str;
2625
+ return JSON.stringify(val);
2626
+ }
2627
+ return val;
2628
+ }
2629
+
2630
+ router.post('/api/reports/preview', async (req, res) => {
2631
+ try {
2632
+ const { name, fields = [], systemFields = [], populatedFields = {}, dateFormat = 'YYYY-MM-DD HH:mm:ss', timezone = 'UTC', fieldPrefixes = {} } = req.body;
2633
+ if (!name) return res.status(400).json({ error: 'Model name required' });
2634
+
2635
+ const projectMongoose = path.join(process.cwd(), 'node_modules', 'mongoose', 'index.js');
2636
+ if (!fs.existsSync(projectMongoose)) return res.status(400).json({ error: 'Mongoose not installed.' });
2637
+ const mongoose = (await import(pathToFileURL(projectMongoose).href)).default;
2638
+ if (mongoose.connection.readyState === 0) {
2639
+ const devEnv = path.join(process.cwd(), '.env.development');
2640
+ const uri = getEnvValue(devEnv, 'MONGO_URI');
2641
+ if (!uri) return res.status(400).json({ error: 'MONGO_URI not configured.' });
2642
+ await mongoose.connect(uri);
2643
+ }
2644
+
2645
+ const collection = mongoose.connection.db.collection(name + 's');
2646
+ const rawDocs = await collection.find({}).limit(10).toArray();
2647
+ // Convert BSON to plain JSON objects for reliable field access
2648
+ const docs = JSON.parse(JSON.stringify(rawDocs));
2649
+
2650
+ // Populate referenced fields
2651
+ const lookups = {};
2652
+ for (const [refField, subFields] of Object.entries(populatedFields)) {
2653
+ if (!subFields || subFields.length === 0) continue;
2654
+ const detail = getModelSchemaDetailed(name);
2655
+ const schemaFields = detail.fields || [];
2656
+ const fieldDef = schemaFields.find(f => f.name === refField);
2657
+ if (!fieldDef || !fieldDef.ref) continue;
2658
+ const ids = [...new Set(docs.map(d => d[refField]).filter(Boolean).map(id => typeof id === 'object' ? (id.$oid || id.toString()) : String(id)))];
2659
+ if (ids.length === 0) continue;
2660
+ const { ObjectId } = mongoose.Types;
2661
+ const rawRefDocs = await mongoose.connection.db.collection(fieldDef.ref + 's')
2662
+ .find({ _id: { $in: ids.map(id => { try { return new ObjectId(id); } catch { return id; } }) } }).toArray();
2663
+ const refDocs = JSON.parse(JSON.stringify(rawRefDocs));
2664
+ const map = {};
2665
+ for (const rd of refDocs) {
2666
+ const rdId = typeof rd._id === 'object' ? (rd._id.$oid || rd._id) : rd._id;
2667
+ map[String(rdId)] = rd;
2668
+ }
2669
+ lookups[refField] = { subFields, map };
2670
+ }
2671
+
2672
+ function applyPrefix(key, val) {
2673
+ if (val === null || val === undefined) return val;
2674
+ const prefix = fieldPrefixes[key];
2675
+ return prefix ? prefix + val : val;
2676
+ }
2677
+
2678
+ const rows = docs.map(doc => {
2679
+ const row = {};
2680
+ // System fields first
2681
+ for (const sf of systemFields) {
2682
+ let val = doc[sf];
2683
+ if (val && typeof val === 'object' && val.$oid) val = val.$oid;
2684
+ if (val && typeof val === 'object' && val.$date) val = val.$date;
2685
+ row[sf] = applyPrefix(sf, formatVal(val, dateFormat, timezone));
2686
+ }
2687
+ // Model fields
2688
+ for (const f of fields) {
2689
+ let val = doc[f];
2690
+ if (val && typeof val === 'object' && val.$oid) val = val.$oid;
2691
+ if (val && typeof val === 'object' && val.$date) val = val.$date;
2692
+ row[f] = applyPrefix(f, formatVal(val, dateFormat, timezone));
2693
+ }
2694
+ // Populated ref fields
2695
+ for (const [refField, { subFields, map }] of Object.entries(lookups)) {
2696
+ let refId = doc[refField];
2697
+ if (refId && typeof refId === 'object' && refId.$oid) refId = refId.$oid;
2698
+ const refDoc = refId ? map[String(refId)] : null;
2699
+ for (const sf of subFields) {
2700
+ const colKey = refField + '.' + sf;
2701
+ let val = refDoc ? refDoc[sf] : null;
2702
+ if (val && typeof val === 'object' && val.$oid) val = val.$oid;
2703
+ if (val && typeof val === 'object' && val.$date) val = val.$date;
2704
+ row[colKey] = applyPrefix(colKey, formatVal(val, dateFormat, timezone));
2705
+ }
2706
+ }
2707
+ return row;
2708
+ });
2709
+
2710
+ // Reorder columns if columnOrder provided
2711
+ const columnOrder = req.body.columnOrder;
2712
+ if (columnOrder && columnOrder.length > 0) {
2713
+ const ordered = rows.map(row => {
2714
+ const r = {};
2715
+ columnOrder.forEach(col => { if (col in row) r[col] = row[col]; });
2716
+ return r;
2717
+ });
2718
+ return res.json({ rows: ordered });
2719
+ }
2720
+ res.json({ rows });
2721
+ } catch (err) {
2722
+ res.status(500).json({ error: err.message });
2723
+ }
2724
+ });
2725
+
2726
+ router.post('/api/reports/generate', async (req, res) => {
2727
+ try {
2728
+ const { models: reportModels, filename, dateFormat = 'YYYY-MM-DD HH:mm:ss', timezone = 'UTC' } = req.body;
2729
+ if (!reportModels || !reportModels.length) {
2730
+ return res.status(400).json({ error: 'No models selected' });
2731
+ }
2732
+
2733
+ // Try to import xlsx-js-style (supports styling) or fall back to xlsx
2734
+ let XLSX;
2735
+ try {
2736
+ const styledPkg = path.join(process.cwd(), 'node_modules', 'xlsx-js-style');
2737
+ const plainPkg = path.join(process.cwd(), 'node_modules', 'xlsx');
2738
+ if (!fs.existsSync(styledPkg)) {
2739
+ await execAsync('npm install xlsx-js-style', { cwd: process.cwd() });
2740
+ }
2741
+ // Use createRequire for CJS packages
2742
+ const { createRequire } = await import('module');
2743
+ const require = createRequire(process.cwd() + '/');
2744
+ XLSX = fs.existsSync(styledPkg) ? require('xlsx-js-style') : require('xlsx');
2745
+ } catch (e) {
2746
+ return res.status(400).json({ error: 'Failed to load xlsx. Run: npm install xlsx-js-style' });
2747
+ }
2748
+
2749
+ // Connect to MongoDB
2750
+ const projectMongoose = path.join(process.cwd(), 'node_modules', 'mongoose', 'index.js');
2751
+ if (!fs.existsSync(projectMongoose)) {
2752
+ return res.status(400).json({ error: 'Mongoose is not installed.' });
2753
+ }
2754
+ const mongoose = (await import(pathToFileURL(projectMongoose).href)).default;
2755
+ if (mongoose.connection.readyState === 0) {
2756
+ const devEnv = path.join(process.cwd(), '.env.development');
2757
+ const uri = getEnvValue(devEnv, 'MONGO_URI');
2758
+ if (!uri) return res.status(400).json({ error: 'MONGO_URI not configured.' });
2759
+ await mongoose.connect(uri);
2760
+ }
2761
+
2762
+ const wb = XLSX.utils.book_new();
2763
+
2764
+ for (const rm of reportModels) {
2765
+ const collectionName = rm.name + 's';
2766
+ const collection = mongoose.connection.db.collection(collectionName);
2767
+ const rowLimit = req.body.limit > 0 ? Math.min(req.body.limit, 50000) : 10000;
2768
+ const rawDocs = await collection.find({}).limit(rowLimit).toArray();
2769
+ const docs = JSON.parse(JSON.stringify(rawDocs));
2770
+
2771
+ // Build lookup maps for populated fields
2772
+ const lookups = {};
2773
+ if (rm.populatedFields) {
2774
+ for (const [refField, subFields] of Object.entries(rm.populatedFields)) {
2775
+ if (!subFields || subFields.length === 0) continue;
2776
+ const detail = getModelSchemaDetailed(rm.name);
2777
+ const schemaFields = detail.fields || [];
2778
+ const fieldDef = schemaFields.find(f => f.name === refField);
2779
+ if (!fieldDef || !fieldDef.ref) continue;
2780
+
2781
+ const ids = [...new Set(docs.map(d => d[refField]).filter(Boolean).map(id => typeof id === 'object' ? (id.$oid || id) : String(id)))];
2782
+ if (ids.length === 0) continue;
2783
+
2784
+ const { ObjectId } = mongoose.Types;
2785
+ const rawRefDocs = await mongoose.connection.db.collection(fieldDef.ref + 's')
2786
+ .find({ _id: { $in: ids.map(id => { try { return new ObjectId(id); } catch { return id; } }) } }).toArray();
2787
+ const refDocs = JSON.parse(JSON.stringify(rawRefDocs));
2788
+
2789
+ const map = {};
2790
+ for (const rd of refDocs) {
2791
+ const rdId = typeof rd._id === 'object' ? (rd._id.$oid || rd._id) : rd._id;
2792
+ map[String(rdId)] = rd;
2793
+ }
2794
+ lookups[refField] = { subFields, map };
2795
+ }
2796
+ }
2797
+
2798
+ // Build rows
2799
+ const sysFields = rm.systemFields || [];
2800
+ const rmPrefixes = rm.fieldPrefixes || {};
2801
+ function applyRmPrefix(key, val) {
2802
+ if (val === null || val === undefined || val === '') return val;
2803
+ const prefix = rmPrefixes[key];
2804
+ return prefix ? prefix + val : val;
2805
+ }
2806
+ const rows = docs.map(doc => {
2807
+ const row = {};
2808
+ // System fields first
2809
+ for (const sf of sysFields) {
2810
+ let val = doc[sf];
2811
+ if (val && typeof val === 'object' && val.$oid) val = val.$oid;
2812
+ if (val && typeof val === 'object' && val.$date) val = val.$date;
2813
+ const fv = formatVal(val, dateFormat, timezone);
2814
+ row[sf] = applyRmPrefix(sf, fv === null ? '' : fv);
2815
+ }
2816
+ // Model fields
2817
+ for (const field of rm.fields) {
2818
+ let val = doc[field];
2819
+ if (val && typeof val === 'object' && val.$oid) val = val.$oid;
2820
+ if (val && typeof val === 'object' && val.$date) val = val.$date;
2821
+ const fv = formatVal(val, dateFormat, timezone);
2822
+ row[field] = applyRmPrefix(field, fv === null ? '' : fv);
2823
+ }
2824
+ // Populated ref fields
2825
+ for (const [refField, { subFields, map }] of Object.entries(lookups)) {
2826
+ let refId = doc[refField];
2827
+ if (refId && typeof refId === 'object' && refId.$oid) refId = refId.$oid;
2828
+ const refDoc = refId ? map[String(refId)] : null;
2829
+ for (const sf of subFields) {
2830
+ const colKey = refField + '.' + sf;
2831
+ let val = refDoc ? refDoc[sf] : null;
2832
+ if (val && typeof val === 'object' && val.$oid) val = val.$oid;
2833
+ if (val && typeof val === 'object' && val.$date) val = val.$date;
2834
+ const fv = formatVal(val, dateFormat, timezone);
2835
+ row[colKey] = applyRmPrefix(colKey, fv === null ? '' : fv);
2836
+ }
2837
+ }
2838
+ return row;
2839
+ });
2840
+
2841
+ // Reorder columns if columnOrder provided
2842
+ const colOrder = rm.columnOrder;
2843
+ const headerLabels = rm.headerLabels || {};
2844
+ let finalRows = rows;
2845
+ if (colOrder && colOrder.length > 0) {
2846
+ finalRows = rows.map(row => {
2847
+ const r = {};
2848
+ colOrder.forEach(col => {
2849
+ const label = headerLabels[col] || col;
2850
+ if (col in row) r[label] = row[col];
2851
+ });
2852
+ return r;
2853
+ });
2854
+ } else if (Object.keys(headerLabels).length > 0) {
2855
+ finalRows = rows.map(row => {
2856
+ const r = {};
2857
+ Object.keys(row).forEach(col => {
2858
+ const label = headerLabels[col] || col;
2859
+ r[label] = row[col];
2860
+ });
2861
+ return r;
2862
+ });
2863
+ }
2864
+
2865
+ // Build headers with custom labels
2866
+ const rawHeaders = colOrder && colOrder.length > 0 ? colOrder : Object.keys(rows[0] || {});
2867
+ const displayHeaders = rawHeaders.map(h => headerLabels[h] || h);
2868
+
2869
+ // Create worksheet with explicit column order
2870
+ const ws = XLSX.utils.json_to_sheet(finalRows, { header: displayHeaders });
2871
+
2872
+ // Professional styling
2873
+ const range = XLSX.utils.decode_range(ws['!ref'] || 'A1');
2874
+ // Style header row — bold with background
2875
+ const thinBorder = { style: "thin", color: { rgb: "B4C6E7" } };
2876
+ const allBorders = { top: thinBorder, bottom: thinBorder, left: thinBorder, right: thinBorder };
2877
+ const headerBorder = { top: { style: "thin", color: { rgb: "2F5597" } }, bottom: { style: "thin", color: { rgb: "2F5597" } }, left: { style: "thin", color: { rgb: "2F5597" } }, right: { style: "thin", color: { rgb: "2F5597" } } };
2878
+ // Style header row
2879
+ for (let C = range.s.c; C <= range.e.c; C++) {
2880
+ const addr = XLSX.utils.encode_cell({ r: 0, c: C });
2881
+ if (!ws[addr]) continue;
2882
+ ws[addr].s = {
2883
+ font: { bold: true, color: { rgb: "FFFFFF" }, sz: 12, name: "Calibri" },
2884
+ fill: { fgColor: { rgb: "4472C4" } },
2885
+ alignment: { horizontal: "center", vertical: "center" },
2886
+ border: headerBorder,
2887
+ };
2888
+ }
2889
+ // Alternate row colors with full borders
2890
+ for (let R = 1; R <= range.e.r; R++) {
2891
+ const isEven = R % 2 === 0;
2892
+ for (let C = range.s.c; C <= range.e.c; C++) {
2893
+ const addr = XLSX.utils.encode_cell({ r: R, c: C });
2894
+ if (!ws[addr]) ws[addr] = { v: '', t: 's' };
2895
+ ws[addr].s = {
2896
+ fill: isEven ? { fgColor: { rgb: "D6E4F0" } } : { fgColor: { rgb: "FFFFFF" } },
2897
+ font: { sz: 11, color: { rgb: "333333" }, name: "Calibri" },
2898
+ alignment: { vertical: "center" },
2899
+ border: allBorders,
2900
+ };
2901
+ }
2902
+ }
2903
+ // Column widths with breathing room
2904
+ ws['!cols'] = displayHeaders.map((key, i) => {
2905
+ const rawKey = rawHeaders[i];
2906
+ const maxLen = Math.max(key.length, ...finalRows.map(r => String(r[key] || '').length).slice(0, 100));
2907
+ return { wch: maxLen + 4 };
2908
+ });
2909
+ // Row heights — uniform for all rows
2910
+ const totalRows = range.e.r + 1;
2911
+ ws['!rows'] = Array.from({ length: totalRows }, () => ({ hpt: 26 }));
2912
+
2913
+ const sheetName = rm.name.length > 31 ? rm.name.slice(0, 31) : rm.name;
2914
+ XLSX.utils.book_append_sheet(wb, ws, sheetName);
2915
+ }
2916
+
2917
+ const buf = XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' });
2918
+ const fname = (filename || 'report') + '.xlsx';
2919
+ res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
2920
+ res.setHeader('Content-Disposition', 'attachment; filename="' + fname + '"');
2921
+ res.send(Buffer.from(buf));
2922
+ } catch (err) {
2923
+ res.status(500).json({ error: err.message });
2924
+ }
2925
+ });
2926
+
2927
+ // ---------------------------------------------------------------------------
2928
+ // Logs — history, SSE stream, clear
2929
+ // ---------------------------------------------------------------------------
2930
+ router.get('/api/logs', (req, res) => {
2931
+ res.json(_logBuffer);
2932
+ });
2933
+
2934
+ router.get('/api/logs/stream', (req, res) => {
2935
+ // EventSource can't send custom headers, so verify passkey from query param
2936
+ const stored = getPasskeyHash();
2937
+ if (stored) {
2938
+ const pk = req.query.passkey;
2939
+ if (!pk || hashPasskey(pk) !== stored) {
2940
+ return res.status(401).json({ error: 'Unauthorized' });
2941
+ }
2942
+ }
2943
+ res.writeHead(200, {
2944
+ 'Content-Type': 'text/event-stream',
2945
+ 'Cache-Control': 'no-cache',
2946
+ Connection: 'keep-alive',
2947
+ });
2948
+ res.write(':\n\n');
2949
+ _logClients.add(res);
2950
+ req.on('close', () => _logClients.delete(res));
2951
+ });
2952
+
2953
+ router.delete('/api/logs', (req, res) => {
2954
+ _logBuffer.length = 0;
2955
+ res.json({ message: 'Logs cleared' });
2956
+ });
2957
+
2386
2958
  // ---------------------------------------------------------------------------
2387
2959
  // Share Collection — save and serve standalone shared page
2388
2960
  // ---------------------------------------------------------------------------
@@ -2406,6 +2978,8 @@ router.post('/api/api-tester/share-collection', (req, res) => {
2406
2978
 
2407
2979
  router.get('/shared/:id', (req, res) => {
2408
2980
  try {
2981
+ res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate');
2982
+ res.setHeader('Pragma', 'no-cache');
2409
2983
  const filePath = path.join(sharedDir, req.params.id + '.json');
2410
2984
  if (!fs.existsSync(filePath)) return res.status(404).send('Collection not found');
2411
2985
  const data = JSON.parse(fs.readFileSync(filePath, 'utf8'));
@@ -2438,7 +3012,7 @@ body{font-family:'Inter',-apple-system,BlinkMacSystemFont,sans-serif;background:
2438
3012
  .sidebar-search input:focus{border-color:#7c3aed}
2439
3013
  .sidebar-search input::placeholder{color:#475569}
2440
3014
  .sidebar-list{flex:1;overflow-y:auto}
2441
- .sidebar-item{padding:10px 14px;cursor:pointer;border-bottom:1px solid rgba(30,36,51,0.5);display:flex;align-items:center;gap:10px;transition:all 0.12s;border-left:3px solid transparent}
3015
+ .sidebar-item{padding:10px 14px;cursor:pointer;border-bottom:1px solid rgba(30,36,51,0.5);display:flex;align-items:center;gap:10px;transition:all 0.12s;border-left:3px solid transparent;color:#e2e8f0}
2442
3016
  .sidebar-item:hover{background:rgba(124,58,237,0.06)}
2443
3017
  .sidebar-item.active{background:rgba(124,58,237,0.1);border-left-color:#7c3aed}
2444
3018
  .sidebar-child{padding:8px 14px 8px 48px;cursor:pointer;border-bottom:1px solid rgba(30,36,51,0.3);display:flex;align-items:center;gap:8px;transition:all 0.12s;border-left:3px solid transparent}
@@ -2450,7 +3024,7 @@ body{font-family:'Inter',-apple-system,BlinkMacSystemFont,sans-serif;background:
2450
3024
  .mb-put{background:rgba(59,130,246,0.15);color:#3b82f6}
2451
3025
  .mb-patch{background:rgba(139,92,246,0.15);color:#8b5cf6}
2452
3026
  .mb-delete{background:rgba(248,113,113,0.15);color:#f87171}
2453
- .item-name{font-size:12px;font-weight:500;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
3027
+ .item-name{font-size:12px;font-weight:500;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:#e2e8f0}
2454
3028
  .item-arrow{font-size:10px;color:#475569;width:14px;text-align:center;flex-shrink:0}
2455
3029
  .item-badge{font-size:9px;padding:2px 7px;border-radius:8px;background:rgba(124,58,237,0.15);color:#a78bfa;font-weight:600}
2456
3030
  .sb{font-size:9px;font-weight:700;padding:3px 7px;border-radius:4px;font-family:'SF Mono','Fira Code',monospace;flex-shrink:0}
@@ -2554,7 +3128,15 @@ pre{font-family:'SF Mono','Fira Code',monospace;font-size:12px;line-height:1.7;c
2554
3128
  if (code) text = code.querySelector('code').textContent;
2555
3129
  }
2556
3130
  if (text) {
2557
- navigator.clipboard.writeText(text);
3131
+ if (navigator.clipboard && navigator.clipboard.writeText) {
3132
+ navigator.clipboard.writeText(text);
3133
+ } else {
3134
+ var ta = document.createElement('textarea');
3135
+ ta.value = text; ta.style.cssText = 'position:fixed;opacity:0';
3136
+ document.body.appendChild(ta); ta.select();
3137
+ document.execCommand('copy');
3138
+ document.body.removeChild(ta);
3139
+ }
2558
3140
  var orig = btn.textContent;
2559
3141
  btn.textContent = 'Copied!';
2560
3142
  btn.style.color = '#34d399';
@@ -2737,24 +3319,177 @@ pre{font-family:'SF Mono','Fira Code',monospace;font-size:12px;line-height:1.7;c
2737
3319
  var main = document.getElementById('main-content');
2738
3320
 
2739
3321
  if (tt === 'socketio' || tt === 'websocket') {
3322
+ var nl = String.fromCharCode(10);
3323
+ var resps = responses[id] || [];
3324
+ var sentLogs = resps.filter(function(r) { return r.dir === 'sent'; });
3325
+ var recvLogs = resps.filter(function(r) { return r.dir === 'received'; });
3326
+ var sysLogs = resps.filter(function(r) { return r.dir === 'system'; });
3327
+
2740
3328
  var h = '<div class="card"><div class="card-header">';
2741
3329
  if (tt === 'socketio') h += '<span class="mb type-sio" style="font-size:11px;padding:4px 10px">Socket.IO</span>';
2742
3330
  else h += '<span class="mb type-ws" style="font-size:11px;padding:4px 10px">WebSocket</span>';
2743
3331
  h += '<span style="font-size:16px;font-weight:600">' + esc(t.name) + '</span></div>';
3332
+
2744
3333
  h += '<div class="stitle">Connection URL</div>';
2745
3334
  h += '<div class="endpoint-url"><code>' + esc(t.url) + '</code><button class="copy-btn" data-copy="url">Copy</button></div>';
2746
- if (tt === 'socketio' && t.event) { h += '<div class="stitle">Emit Event</div><pre style="margin-bottom:16px">' + esc(t.event) + '</pre>'; }
3335
+ h += '</div>';
3336
+
3337
+ // ── Emit Events Section ──
3338
+ h += '<div class="card">';
3339
+ h += '<div class="card-header"><span style="font-size:14px;font-weight:600;color:#f59e0b">&#8593; Emit Events</span>';
3340
+ h += '<span style="font-size:10px;padding:3px 10px;border-radius:10px;background:rgba(245,158,11,0.12);color:#f59e0b;font-weight:600;margin-left:auto">Client &#8594; Server</span></div>';
3341
+
3342
+ if (tt === 'socketio' && t.event) {
3343
+ h += '<div style="display:flex;align-items:center;gap:10px;margin-bottom:14px">';
3344
+ h += '<span class="stitle" style="margin:0;min-width:70px">Event</span>';
3345
+ h += '<span style="font-size:13px;padding:6px 14px;border-radius:6px;background:rgba(245,158,11,0.1);color:#f59e0b;font-family:SF Mono,monospace;font-weight:600;border:1px solid rgba(245,158,11,0.2)">' + esc(t.event) + '</span>';
3346
+ h += '</div>';
3347
+ }
3348
+ if (tt === 'websocket' && t.message) {
3349
+ h += '<div class="stitle">Message</div>';
3350
+ }
3351
+
2747
3352
  if (t.data || t.message) {
2748
- h += '<div class="stitle">' + (tt === 'socketio' ? 'Event Data' : 'Message') + '</div>';
3353
+ h += '<div class="stitle" style="margin-bottom:6px">Payload</div>';
2749
3354
  var d = t.data || t.message || ''; try { d = JSON.stringify(JSON.parse(d), null, 2); } catch(e) {}
2750
- h += '<pre style="margin-bottom:16px">' + esc(d) + '</pre>';
3355
+ h += '<div style="position:relative"><pre style="margin-bottom:14px">' + esc(d) + '</pre>';
3356
+ h += '<button class="copy-btn" style="position:absolute;top:8px;right:8px" data-copy="prev-pre">Copy</button></div>';
2751
3357
  }
3358
+
3359
+ // Emit code example
3360
+ if (tt === 'socketio') {
3361
+ h += '<div class="stitle" style="margin-bottom:6px">Code</div>';
3362
+ var emitCode = "// Client-side emit" + nl;
3363
+ emitCode += "import { io } from 'socket.io-client';" + nl;
3364
+ emitCode += "const socket = io('" + t.url + "');" + nl + nl;
3365
+ if (t.event) {
3366
+ var ed = t.data || '{}'; try { ed = JSON.stringify(JSON.parse(ed), null, 2); } catch(e) {}
3367
+ emitCode += "socket.emit('" + t.event + "', " + ed + ");" + nl + nl;
3368
+ }
3369
+ emitCode += "// Server-side handler" + nl;
3370
+ emitCode += "io.on('connection', (socket) => {" + nl;
3371
+ if (t.event) {
3372
+ emitCode += " socket.on('" + t.event + "', (data) => {" + nl;
3373
+ emitCode += " console.log('" + t.event + ":', data);" + nl;
3374
+ emitCode += " });" + nl;
3375
+ }
3376
+ emitCode += "});" + nl + nl;
3377
+ emitCode += "// Emit from Express route" + nl;
3378
+ if (t.event) {
3379
+ var ed2 = t.data || '{}'; try { ed2 = JSON.stringify(JSON.parse(ed2), null, 2); } catch(e) {}
3380
+ emitCode += "req.io.emit('" + t.event + "', " + ed2 + ");";
3381
+ } else {
3382
+ emitCode += "req.io.emit('event_name', { key: 'value' });";
3383
+ }
3384
+ h += '<div style="position:relative"><pre style="margin-bottom:14px">' + esc(emitCode) + '</pre>';
3385
+ h += '<button class="copy-btn" style="position:absolute;top:8px;right:8px" data-copy="prev-pre">Copy</button></div>';
3386
+ } else {
3387
+ h += '<div class="stitle" style="margin-bottom:6px">Code</div>';
3388
+ var wsSendCode = "const ws = new WebSocket('" + t.url + "');" + nl + nl;
3389
+ wsSendCode += "ws.onopen = () => {" + nl;
3390
+ if (t.message) {
3391
+ wsSendCode += " ws.send(JSON.stringify(" + (t.message || "{}") + "));" + nl;
3392
+ } else {
3393
+ wsSendCode += " ws.send(JSON.stringify({ key: 'value' }));" + nl;
3394
+ }
3395
+ wsSendCode += "};";
3396
+ h += '<div style="position:relative"><pre style="margin-bottom:14px">' + esc(wsSendCode) + '</pre>';
3397
+ h += '<button class="copy-btn" style="position:absolute;top:8px;right:8px" data-copy="prev-pre">Copy</button></div>';
3398
+ }
3399
+
3400
+ // Saved emitted logs
3401
+ if (sentLogs.length > 0) {
3402
+ h += '<div class="stitle" style="margin-bottom:8px">Saved Emits (' + sentLogs.length + ')</div>';
3403
+ sentLogs.forEach(function(r) {
3404
+ var body = r.body || ''; try { body = JSON.stringify(JSON.parse(body), null, 2); } catch(e) {}
3405
+ h += '<div style="background:#0f1117;border:1px solid #1e2433;border-radius:8px;padding:10px 14px;margin-bottom:6px">';
3406
+ h += '<div style="display:flex;align-items:center;gap:8px;margin-bottom:' + (body ? '8px' : '0') + '">';
3407
+ h += '<span class="sb dir-sent" style="font-size:9px;padding:2px 6px">&#8593; EMIT</span>';
3408
+ h += '<span style="font-size:12px;font-weight:500">' + esc(r.label) + '</span>';
3409
+ h += '</div>';
3410
+ if (body) {
3411
+ h += '<div style="position:relative"><pre style="margin:0;font-size:11px">' + esc(body) + '</pre>';
3412
+ h += '<button class="copy-btn" style="position:absolute;top:4px;right:4px;font-size:10px;padding:2px 8px" data-copy="prev-pre">Copy</button></div>';
3413
+ }
3414
+ h += '</div>';
3415
+ });
3416
+ }
3417
+ h += '</div>';
3418
+
3419
+ // ── Event Listeners Section ──
3420
+ h += '<div class="card">';
3421
+ h += '<div class="card-header"><span style="font-size:14px;font-weight:600;color:#34d399">&#8595; Event Listeners</span>';
3422
+ h += '<span style="font-size:10px;padding:3px 10px;border-radius:10px;background:rgba(52,211,153,0.12);color:#34d399;font-weight:600;margin-left:auto">Server &#8594; Client</span></div>';
3423
+
2752
3424
  if (tt === 'socketio' && t.listeners && t.listeners.length > 0) {
2753
- h += '<div class="stitle">Listeners</div><div style="display:flex;flex-wrap:wrap;gap:6px">';
2754
- t.listeners.forEach(function(l) { h += '<span style="font-size:11px;padding:4px 10px;border-radius:6px;background:rgba(52,211,153,0.1);color:#34d399">' + esc(l) + '</span>'; });
3425
+ h += '<div class="stitle" style="margin-bottom:8px">Listening On</div>';
3426
+ h += '<div style="display:flex;flex-wrap:wrap;gap:6px;margin-bottom:14px">';
3427
+ t.listeners.forEach(function(l) {
3428
+ h += '<span style="font-size:12px;padding:6px 14px;border-radius:6px;background:rgba(52,211,153,0.1);color:#34d399;font-family:SF Mono,monospace;font-weight:600;border:1px solid rgba(52,211,153,0.2)">' + esc(l) + '</span>';
3429
+ });
2755
3430
  h += '</div>';
3431
+
3432
+ // Listener code example
3433
+ h += '<div class="stitle" style="margin-bottom:6px">Code</div>';
3434
+ var listenCode = "// Client-side listeners" + nl;
3435
+ listenCode += "import { io } from 'socket.io-client';" + nl;
3436
+ listenCode += "const socket = io('" + t.url + "');" + nl;
3437
+ t.listeners.forEach(function(l) {
3438
+ listenCode += nl + "socket.on('" + l + "', (data) => {" + nl;
3439
+ listenCode += " console.log('" + l + ":', data);" + nl;
3440
+ listenCode += "});" + nl;
3441
+ });
3442
+ listenCode += nl + "// Server-side broadcast" + nl;
3443
+ t.listeners.forEach(function(l) {
3444
+ listenCode += "io.emit('" + l + "', data);" + nl;
3445
+ });
3446
+ h += '<div style="position:relative"><pre style="margin-bottom:14px">' + esc(listenCode) + '</pre>';
3447
+ h += '<button class="copy-btn" style="position:absolute;top:8px;right:8px" data-copy="prev-pre">Copy</button></div>';
3448
+ } else if (tt === 'websocket') {
3449
+ h += '<div class="stitle" style="margin-bottom:6px">Code</div>';
3450
+ var wsRecvCode = "const ws = new WebSocket('" + t.url + "');" + nl + nl;
3451
+ wsRecvCode += "ws.onmessage = (event) => {" + nl;
3452
+ wsRecvCode += " const data = JSON.parse(event.data);" + nl;
3453
+ wsRecvCode += " console.log('Received:', data);" + nl;
3454
+ wsRecvCode += "};" + nl + nl;
3455
+ wsRecvCode += "ws.onclose = () => console.log('Disconnected');";
3456
+ h += '<div style="position:relative"><pre style="margin-bottom:14px">' + esc(wsRecvCode) + '</pre>';
3457
+ h += '<button class="copy-btn" style="position:absolute;top:8px;right:8px" data-copy="prev-pre">Copy</button></div>';
3458
+ } else {
3459
+ h += '<div style="font-size:12px;color:#475569;padding:8px 0">No listeners configured</div>';
2756
3460
  }
2757
- h += '</div>'; main.innerHTML = h; return;
3461
+
3462
+ // Saved received logs
3463
+ if (recvLogs.length > 0) {
3464
+ h += '<div class="stitle" style="margin-bottom:8px">Received Events (' + recvLogs.length + ')</div>';
3465
+ recvLogs.forEach(function(r) {
3466
+ var body = r.body || ''; try { body = JSON.stringify(JSON.parse(body), null, 2); } catch(e) {}
3467
+ h += '<div style="background:#0f1117;border:1px solid #1e2433;border-radius:8px;padding:10px 14px;margin-bottom:6px">';
3468
+ h += '<div style="display:flex;align-items:center;gap:8px;margin-bottom:' + (body ? '8px' : '0') + '">';
3469
+ h += '<span class="sb dir-recv" style="font-size:9px;padding:2px 6px">&#8595; RECV</span>';
3470
+ h += '<span style="font-size:12px;font-weight:500">' + esc(r.label) + '</span>';
3471
+ h += '</div>';
3472
+ if (body) {
3473
+ h += '<div style="position:relative"><pre style="margin:0;font-size:11px">' + esc(body) + '</pre>';
3474
+ h += '<button class="copy-btn" style="position:absolute;top:4px;right:4px;font-size:10px;padding:2px 8px" data-copy="prev-pre">Copy</button></div>';
3475
+ }
3476
+ h += '</div>';
3477
+ });
3478
+ }
3479
+
3480
+ // System logs
3481
+ if (sysLogs.length > 0) {
3482
+ h += '<div class="stitle" style="margin-top:12px;margin-bottom:8px">System Events (' + sysLogs.length + ')</div>';
3483
+ sysLogs.forEach(function(r) {
3484
+ h += '<div style="background:#0f1117;border:1px solid #1e2433;border-radius:8px;padding:8px 14px;margin-bottom:4px;display:flex;align-items:center;gap:8px">';
3485
+ h += '<span class="sb dir-sys" style="font-size:9px;padding:2px 6px">&#9679; SYS</span>';
3486
+ h += '<span style="font-size:12px;color:#94a3b8">' + esc(r.label) + '</span>';
3487
+ h += '</div>';
3488
+ });
3489
+ }
3490
+
3491
+ h += '</div>';
3492
+ main.innerHTML = h; return;
2758
3493
  }
2759
3494
 
2760
3495
  var mc = { GET:'#34d399', POST:'#f59e0b', PUT:'#3b82f6', PATCH:'#8b5cf6', DELETE:'#f87171' };
@@ -2910,13 +3645,13 @@ pre{font-family:'SF Mono','Fira Code',monospace;font-size:12px;line-height:1.7;c
2910
3645
  }
2911
3646
  });
2912
3647
 
2913
- renderSidebar();
3648
+ try { renderSidebar(); } catch(e) { console.error('renderSidebar error:', e); }
2914
3649
  if (tests.length > 0) {
2915
3650
  activeTestId = tests[0].id;
2916
3651
  var firstResps = responses[tests[0].id] || [];
2917
3652
  if (firstResps.length > 0) expanded[tests[0].id] = true;
2918
- renderSidebar();
2919
- renderTestView(tests[0].id);
3653
+ try { renderSidebar(); } catch(e) { console.error('renderSidebar error:', e); }
3654
+ try { renderTestView(tests[0].id); } catch(e) { console.error('renderTestView error:', e); }
2920
3655
  }
2921
3656
  })();
2922
3657
  </script></body></html>`;