4bnode 3.5.3 → 4.0.2
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.
- package/README.md +144 -109
- package/add-api.js +288 -156
- package/add-crud.js +226 -0
- package/add-login.js +152 -319
- package/add-mongo-delete.js +67 -138
- package/add-mongo-insert.js +87 -204
- package/add-mongo-read.js +100 -216
- package/add-mongo-schema.js +431 -199
- package/add-mongo-update.js +89 -202
- package/add-mongo.js +125 -218
- package/add-serialport.js +53 -98
- package/add-socket.js +43 -66
- package/add-websocket.js +68 -140
- package/index.js +341 -156
- package/lib/env.js +18 -0
- package/lib/indexFile.js +134 -0
- package/lib/names.js +21 -0
- package/lib/project.js +122 -0
- package/lib/routeFile.js +56 -0
- package/lib/ui.js +161 -0
- package/package.json +9 -16
- package/skeleton/.4bnode/assets/fonts/NotoSans-Bold.woff2 +0 -0
- package/skeleton/.4bnode/assets/fonts/NotoSans-Light.woff2 +0 -0
- package/skeleton/.4bnode/assets/fonts/NotoSans-Medium.woff2 +0 -0
- package/skeleton/.4bnode/assets/fonts/NotoSans-Regular.woff2 +0 -0
- package/skeleton/.4bnode/assets/fonts/NotoSans-SemiBold.woff2 +0 -0
- package/skeleton/.4bnode/assets/images/mongo.png +0 -0
- package/skeleton/.4bnode/assets/images/mysql.png +0 -0
- package/skeleton/.4bnode/assets/images/postgresql.png +0 -0
- package/skeleton/.4bnode/assets/images/sqlite.png +0 -0
- package/skeleton/.4bnode/assets/js/App.js +115 -0
- package/skeleton/.4bnode/assets/js/Root.js +44 -0
- package/skeleton/.4bnode/assets/js/components/Modal.js +52 -0
- package/skeleton/.4bnode/assets/js/components/Sidebar.js +68 -0
- package/skeleton/.4bnode/assets/js/components/ToastContainer.js +15 -0
- package/skeleton/.4bnode/assets/js/context.js +7 -0
- package/skeleton/.4bnode/assets/js/lib.js +83 -0
- package/skeleton/.4bnode/assets/js/main.js +5 -0
- package/skeleton/.4bnode/assets/js/pages/ApiTesterPage.js +1594 -0
- package/skeleton/.4bnode/assets/js/pages/DashboardPage.js +435 -0
- package/skeleton/.4bnode/assets/js/pages/DatabasePage.js +461 -0
- package/skeleton/.4bnode/assets/js/pages/EnvironmentPage.js +141 -0
- package/skeleton/.4bnode/assets/js/pages/RealtimePage.js +186 -0
- package/skeleton/.4bnode/assets/js/pages/RoutesPage.js +1656 -0
- package/skeleton/.4bnode/assets/js/pages/SchemasPage.js +901 -0
- package/skeleton/.4bnode/assets/js/pages/SerialPortPage.js +170 -0
- package/skeleton/.4bnode/assets/js/screens/PasskeyScreen.js +105 -0
- package/skeleton/.4bnode/assets/js/screens/SplashScreen.js +66 -0
- package/skeleton/.4bnode/dev-api.js +2930 -0
- package/skeleton/.4bnode/ui.html +1556 -0
- package/skeleton/index.js +70 -17
- package/skeleton/package.json +9 -5
- package/skeleton/public/index.html +288 -86
- package/README_4bnode.md +0 -197
- package/src/auth/passport.js +0 -21
- package/src/db.js +0 -13
- package/test-server/.env.development +0 -1
- package/test-server/.env.production +0 -1
- package/test-server/index.js +0 -82
- package/test-server/package-lock.json +0 -1267
- package/test-server/package.json +0 -20
- package/test-server/public/index.html +0 -90
|
@@ -0,0 +1,2930 @@
|
|
|
1
|
+
import express from 'express';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import { execSync, exec } from 'child_process';
|
|
5
|
+
import { promisify } from 'util';
|
|
6
|
+
const execAsync = promisify(exec);
|
|
7
|
+
import crypto from 'crypto';
|
|
8
|
+
|
|
9
|
+
const router = express.Router();
|
|
10
|
+
|
|
11
|
+
// Serve dashboard UI and assets
|
|
12
|
+
const __4bnodeDir = path.join(process.cwd(), '.4bnode');
|
|
13
|
+
router.get('/', (req, res) => {
|
|
14
|
+
res.sendFile('ui.html', { root: __4bnodeDir });
|
|
15
|
+
});
|
|
16
|
+
router.use('/assets', express.static(path.join(__4bnodeDir, 'assets')));
|
|
17
|
+
|
|
18
|
+
// ── Passkey Authentication ──────────────────────────────────────────────────
|
|
19
|
+
function getPasskeyHash() {
|
|
20
|
+
const file = path.join(__4bnodeDir, '.passkey');
|
|
21
|
+
if (!fs.existsSync(file)) return null;
|
|
22
|
+
return fs.readFileSync(file, 'utf8').trim();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function hashPasskey(pin) {
|
|
26
|
+
return crypto.createHash('sha256').update(pin).digest('hex');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Verify passkey endpoint (no auth middleware needed here)
|
|
30
|
+
router.post('/api/auth', (req, res) => {
|
|
31
|
+
const { passkey } = req.body;
|
|
32
|
+
const stored = getPasskeyHash();
|
|
33
|
+
if (!stored) return res.json({ ok: true });
|
|
34
|
+
if (!passkey || hashPasskey(passkey) !== stored) {
|
|
35
|
+
return res.status(401).json({ error: 'Invalid passkey' });
|
|
36
|
+
}
|
|
37
|
+
res.json({ ok: true });
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
// Auth middleware — protect all /api/* routes below
|
|
41
|
+
router.use('/api', (req, res, next) => {
|
|
42
|
+
if (req.path === '/auth') return next();
|
|
43
|
+
const stored = getPasskeyHash();
|
|
44
|
+
if (!stored) return next();
|
|
45
|
+
const passkey = req.headers['x-passkey'];
|
|
46
|
+
if (!passkey || hashPasskey(passkey) !== stored) {
|
|
47
|
+
return res.status(401).json({ error: 'Unauthorized' });
|
|
48
|
+
}
|
|
49
|
+
next();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
// Utility functions (self-contained, no external imports)
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
|
|
56
|
+
function toCamelCase(str) {
|
|
57
|
+
return str.replace(/-([a-z])/g, (g) => g[1].toUpperCase());
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function toPascalCase(str) {
|
|
61
|
+
const camel = toCamelCase(str);
|
|
62
|
+
return camel.charAt(0).toUpperCase() + camel.slice(1);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function sanitizeName(input) {
|
|
66
|
+
if (!input || typeof input !== "string") {
|
|
67
|
+
throw new Error("Name is required.");
|
|
68
|
+
}
|
|
69
|
+
const trimmed = input.trim();
|
|
70
|
+
if (!/^[a-zA-Z][a-zA-Z0-9_-]*$/.test(trimmed)) {
|
|
71
|
+
throw new Error(
|
|
72
|
+
`Invalid name "${trimmed}". Use only letters, numbers, hyphens, and underscores. Must start with a letter.`
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
return trimmed;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function listModels() {
|
|
79
|
+
const modelsDir = path.join(process.cwd(), "src", "models");
|
|
80
|
+
if (!fs.existsSync(modelsDir)) {
|
|
81
|
+
return [];
|
|
82
|
+
}
|
|
83
|
+
return fs.readdirSync(modelsDir).filter((f) => f.endsWith(".js"));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function listRoutes() {
|
|
87
|
+
const routesDir = path.join(process.cwd(), "src", "routes");
|
|
88
|
+
if (!fs.existsSync(routesDir)) {
|
|
89
|
+
return [];
|
|
90
|
+
}
|
|
91
|
+
return fs.readdirSync(routesDir).filter((f) => f.endsWith(".js"));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function checkMongoConfig() {
|
|
95
|
+
const envPath = path.join(process.cwd(), ".env.development");
|
|
96
|
+
return (
|
|
97
|
+
fs.existsSync(envPath) &&
|
|
98
|
+
fs.readFileSync(envPath, "utf8").includes("MONGO_URI")
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function getEnvValue(filePath, key) {
|
|
103
|
+
if (!fs.existsSync(filePath)) return '';
|
|
104
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
105
|
+
const match = content.match(new RegExp(`^${key}=(.*)`, 'm'));
|
|
106
|
+
return match ? match[1].trim() : '';
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function extractSchemaBlock(content) {
|
|
110
|
+
const marker = "new mongoose.Schema(";
|
|
111
|
+
const idx = content.indexOf(marker);
|
|
112
|
+
if (idx === -1) return null;
|
|
113
|
+
// Find the opening { after the marker
|
|
114
|
+
const braceStart = content.indexOf("{", idx + marker.length);
|
|
115
|
+
if (braceStart === -1) return null;
|
|
116
|
+
// Match balanced braces to find the outer schema object
|
|
117
|
+
let depth = 0;
|
|
118
|
+
for (let i = braceStart; i < content.length; i++) {
|
|
119
|
+
if (content[i] === "{") depth++;
|
|
120
|
+
else if (content[i] === "}") {
|
|
121
|
+
depth--;
|
|
122
|
+
if (depth === 0) return content.slice(braceStart + 1, i);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function getModelSchemaDetailed(modelName) {
|
|
129
|
+
const modelFilePath = path.join(
|
|
130
|
+
process.cwd(),
|
|
131
|
+
"src",
|
|
132
|
+
"models",
|
|
133
|
+
modelName + ".js"
|
|
134
|
+
);
|
|
135
|
+
if (!fs.existsSync(modelFilePath)) {
|
|
136
|
+
return [];
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const content = fs.readFileSync(modelFilePath, "utf8");
|
|
140
|
+
const schemaContent = extractSchemaBlock(content);
|
|
141
|
+
if (!schemaContent) return [];
|
|
142
|
+
const fields = [];
|
|
143
|
+
// Match each field block: fieldName: { type: ..., required: ..., unique: ..., default: ... }
|
|
144
|
+
const fieldRegex = /(\w+)\s*:\s*\{([^}]*)\}/g;
|
|
145
|
+
let fm;
|
|
146
|
+
while ((fm = fieldRegex.exec(schemaContent)) !== null) {
|
|
147
|
+
const name = fm[1];
|
|
148
|
+
const props = fm[2];
|
|
149
|
+
const typeMatch = props.match(/type:\s*([\w.]+)/);
|
|
150
|
+
const type = typeMatch
|
|
151
|
+
? typeMatch[1].replace("mongoose.Schema.Types.", "")
|
|
152
|
+
: "String";
|
|
153
|
+
const required = /required:\s*true/.test(props);
|
|
154
|
+
const unique = /unique:\s*true/.test(props);
|
|
155
|
+
const refMatch = props.match(/ref:\s*['"](\w+)['"]/);
|
|
156
|
+
const defaultMatch = props.match(/default:\s*(.+?)(?:,\s*\w+:|$)/);
|
|
157
|
+
const field = { name, type, required, unique };
|
|
158
|
+
if (refMatch) field.ref = refMatch[1];
|
|
159
|
+
if (defaultMatch) {
|
|
160
|
+
field.default = defaultMatch[1].trim();
|
|
161
|
+
}
|
|
162
|
+
fields.push(field);
|
|
163
|
+
}
|
|
164
|
+
return fields;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function getModelSchema(modelName) {
|
|
168
|
+
const modelFilePath = path.join(
|
|
169
|
+
process.cwd(),
|
|
170
|
+
"src",
|
|
171
|
+
"models",
|
|
172
|
+
modelName + ".js"
|
|
173
|
+
);
|
|
174
|
+
if (!fs.existsSync(modelFilePath)) {
|
|
175
|
+
return [];
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const content = fs.readFileSync(modelFilePath, "utf8");
|
|
179
|
+
const schemaContent = extractSchemaBlock(content);
|
|
180
|
+
if (!schemaContent) return [];
|
|
181
|
+
|
|
182
|
+
const fieldRegex = /(\w+)\s*:\s*\{/g;
|
|
183
|
+
const fields = [];
|
|
184
|
+
let fieldMatch;
|
|
185
|
+
while ((fieldMatch = fieldRegex.exec(schemaContent)) !== null) {
|
|
186
|
+
fields.push(fieldMatch[1]);
|
|
187
|
+
}
|
|
188
|
+
return fields;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// ---------------------------------------------------------------------------
|
|
192
|
+
// Route file manipulation functions
|
|
193
|
+
// ---------------------------------------------------------------------------
|
|
194
|
+
|
|
195
|
+
function addImportToRoute(filePath, importStatement) {
|
|
196
|
+
let content = fs.readFileSync(filePath, "utf8");
|
|
197
|
+
if (!content.includes(importStatement.trim())) {
|
|
198
|
+
content = importStatement.trim() + "\n" + content;
|
|
199
|
+
fs.writeFileSync(filePath, content, "utf8");
|
|
200
|
+
}
|
|
201
|
+
return content;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function insertCodeIntoRoute(filePath, codeBlock) {
|
|
205
|
+
let content = fs.readFileSync(filePath, "utf8");
|
|
206
|
+
|
|
207
|
+
// Prefer inserting before "export default router;" as it's more reliable
|
|
208
|
+
const exportIndex = content.lastIndexOf("export default router;");
|
|
209
|
+
if (exportIndex !== -1) {
|
|
210
|
+
content =
|
|
211
|
+
content.slice(0, exportIndex) +
|
|
212
|
+
codeBlock.trim() +
|
|
213
|
+
"\n\n" +
|
|
214
|
+
content.slice(exportIndex);
|
|
215
|
+
} else {
|
|
216
|
+
// Fallback to inserting before last "});"
|
|
217
|
+
const lastClose = content.lastIndexOf("});");
|
|
218
|
+
if (lastClose !== -1) {
|
|
219
|
+
content =
|
|
220
|
+
content.slice(0, lastClose) +
|
|
221
|
+
codeBlock.trim() +
|
|
222
|
+
"\n" +
|
|
223
|
+
content.slice(lastClose);
|
|
224
|
+
} else {
|
|
225
|
+
content += "\n" + codeBlock.trim() + "\n";
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
fs.writeFileSync(filePath, content, "utf8");
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function detectHttpMethod(filePath) {
|
|
233
|
+
const content = fs.readFileSync(filePath, "utf8");
|
|
234
|
+
const methodRegex =
|
|
235
|
+
/router\.(get|post|put|patch|delete)\((['"`])\/.*?\2,\s*async\s*\(req,\s*res/;
|
|
236
|
+
const match = content.match(methodRegex);
|
|
237
|
+
return match ? match[1].toLowerCase() : "post";
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function getRequestDataSource(method) {
|
|
241
|
+
return method === "get" || method === "delete" ? "req.query" : "req.body";
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// ---------------------------------------------------------------------------
|
|
245
|
+
// CRUD code builders
|
|
246
|
+
// ---------------------------------------------------------------------------
|
|
247
|
+
|
|
248
|
+
function buildInsertCode(pascalName, fields, dataSource) {
|
|
249
|
+
const assignments = fields
|
|
250
|
+
.map((f) => ` ${f}: ${dataSource}.${f}`)
|
|
251
|
+
.join(",\n");
|
|
252
|
+
|
|
253
|
+
let passwordLogic = "";
|
|
254
|
+
if (fields.includes("password")) {
|
|
255
|
+
passwordLogic = `
|
|
256
|
+
if (newData.password) {
|
|
257
|
+
const salt = await bcrypt.genSalt(10);
|
|
258
|
+
newData.password = await bcrypt.hash(newData.password, salt);
|
|
259
|
+
}`;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
return `
|
|
263
|
+
router.post('/create', async (req, res) => {
|
|
264
|
+
const newData = {
|
|
265
|
+
${assignments}
|
|
266
|
+
};
|
|
267
|
+
${passwordLogic}
|
|
268
|
+
try {
|
|
269
|
+
const newDocument = new ${pascalName}(newData);
|
|
270
|
+
await newDocument.save();
|
|
271
|
+
res.json({ message: 'Data inserted successfully', data: newDocument });
|
|
272
|
+
} catch (err) {
|
|
273
|
+
console.error(err);
|
|
274
|
+
res.status(500).json({ message: 'Server error' });
|
|
275
|
+
}
|
|
276
|
+
});`;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function buildReadCode(pascalName) {
|
|
280
|
+
return `
|
|
281
|
+
router.get('/', async (req, res) => {
|
|
282
|
+
try {
|
|
283
|
+
const documents = await ${pascalName}.find();
|
|
284
|
+
res.json({ data: documents });
|
|
285
|
+
} catch (err) {
|
|
286
|
+
console.error(err);
|
|
287
|
+
res.status(500).json({ message: 'Server error' });
|
|
288
|
+
}
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
router.get('/:id', async (req, res) => {
|
|
292
|
+
try {
|
|
293
|
+
const document = await ${pascalName}.findById(req.params.id);
|
|
294
|
+
if (!document) {
|
|
295
|
+
return res.status(404).json({ message: 'Document not found' });
|
|
296
|
+
}
|
|
297
|
+
res.json({ data: document });
|
|
298
|
+
} catch (err) {
|
|
299
|
+
console.error(err);
|
|
300
|
+
res.status(500).json({ message: 'Server error' });
|
|
301
|
+
}
|
|
302
|
+
});`;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function buildUpdateCode(pascalName, fields, dataSource) {
|
|
306
|
+
const assignments = fields
|
|
307
|
+
.map((f) => ` ${f}: ${dataSource}.${f}`)
|
|
308
|
+
.join(",\n");
|
|
309
|
+
|
|
310
|
+
let passwordLogic = "";
|
|
311
|
+
if (fields.includes("password")) {
|
|
312
|
+
passwordLogic = `
|
|
313
|
+
if (updatedData.password) {
|
|
314
|
+
const salt = await bcrypt.genSalt(10);
|
|
315
|
+
updatedData.password = await bcrypt.hash(updatedData.password, salt);
|
|
316
|
+
}`;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
return `
|
|
320
|
+
router.put('/:id', async (req, res) => {
|
|
321
|
+
const updatedData = {
|
|
322
|
+
${assignments}
|
|
323
|
+
};
|
|
324
|
+
${passwordLogic}
|
|
325
|
+
try {
|
|
326
|
+
const updatedDocument = await ${pascalName}.findByIdAndUpdate(req.params.id, updatedData, { new: true });
|
|
327
|
+
if (!updatedDocument) {
|
|
328
|
+
return res.status(404).json({ message: 'Document not found' });
|
|
329
|
+
}
|
|
330
|
+
res.json({ message: 'Data updated successfully', data: updatedDocument });
|
|
331
|
+
} catch (err) {
|
|
332
|
+
console.error(err);
|
|
333
|
+
res.status(500).json({ message: 'Server error' });
|
|
334
|
+
}
|
|
335
|
+
});`;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function buildDeleteCode(pascalName) {
|
|
339
|
+
return `
|
|
340
|
+
router.delete('/:id', async (req, res) => {
|
|
341
|
+
try {
|
|
342
|
+
const deletedDocument = await ${pascalName}.findByIdAndDelete(req.params.id);
|
|
343
|
+
if (!deletedDocument) {
|
|
344
|
+
return res.status(404).json({ message: 'Document not found' });
|
|
345
|
+
}
|
|
346
|
+
res.json({ message: 'Data deleted successfully', data: deletedDocument });
|
|
347
|
+
} catch (err) {
|
|
348
|
+
console.error(err);
|
|
349
|
+
res.status(500).json({ message: 'Server error' });
|
|
350
|
+
}
|
|
351
|
+
});`;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// ---------------------------------------------------------------------------
|
|
355
|
+
// Schema file generation helper
|
|
356
|
+
// ---------------------------------------------------------------------------
|
|
357
|
+
|
|
358
|
+
function generateSchemaFileContent(pascalName, fields) {
|
|
359
|
+
const fieldLines = fields.map((f) => {
|
|
360
|
+
const type = f.type || 'String';
|
|
361
|
+
const parts = [type === 'ObjectId' ? 'type: mongoose.Schema.Types.ObjectId' : `type: ${type}`];
|
|
362
|
+
if (f.ref) parts.push(`ref: '${f.ref}'`);
|
|
363
|
+
if (f.required) parts.push('required: true');
|
|
364
|
+
if (f.unique) parts.push('unique: true');
|
|
365
|
+
if (f.default !== undefined && f.default !== '') parts.push(`default: ${f.default}`);
|
|
366
|
+
return ` ${f.name}: { ${parts.join(', ')} }`;
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
return `import mongoose from 'mongoose';
|
|
370
|
+
|
|
371
|
+
const ${pascalName}Schema = new mongoose.Schema({
|
|
372
|
+
${fieldLines.join(',\n')}
|
|
373
|
+
}, { timestamps: true });
|
|
374
|
+
|
|
375
|
+
const ${pascalName} = mongoose.model('${pascalName}', ${pascalName}Schema);
|
|
376
|
+
|
|
377
|
+
export default ${pascalName};
|
|
378
|
+
`;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// ---------------------------------------------------------------------------
|
|
382
|
+
// API Routes
|
|
383
|
+
// ---------------------------------------------------------------------------
|
|
384
|
+
|
|
385
|
+
// GET /api/dashboard
|
|
386
|
+
router.get('/api/dashboard', (req, res) => {
|
|
387
|
+
try {
|
|
388
|
+
const modelFiles = listModels();
|
|
389
|
+
const models = modelFiles.map((f) => {
|
|
390
|
+
const name = path.basename(f, '.js');
|
|
391
|
+
const fields = getModelSchemaDetailed(name);
|
|
392
|
+
return { name, fields };
|
|
393
|
+
});
|
|
394
|
+
const routes = listRoutes();
|
|
395
|
+
const mongoConfigured = checkMongoConfig();
|
|
396
|
+
const routesDirExists = fs.existsSync(path.join(process.cwd(), 'src', 'routes'));
|
|
397
|
+
|
|
398
|
+
let dbType = null;
|
|
399
|
+
const indexPath = path.join(process.cwd(), 'index.js');
|
|
400
|
+
if (fs.existsSync(indexPath)) {
|
|
401
|
+
const indexContent = fs.readFileSync(indexPath, 'utf8');
|
|
402
|
+
if (indexContent.includes('import connectDB') || indexContent.includes('mongoose')) {
|
|
403
|
+
dbType = 'mongodb';
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
const authConfigured = fs.existsSync(path.join(process.cwd(), 'src', 'middleware', 'auth.js'));
|
|
408
|
+
|
|
409
|
+
let mongoUri = '', mongoProdUri = '';
|
|
410
|
+
if (mongoConfigured) {
|
|
411
|
+
const devEnv = path.join(process.cwd(), '.env.development');
|
|
412
|
+
const prodEnv = path.join(process.cwd(), '.env.production');
|
|
413
|
+
mongoUri = getEnvValue(devEnv, 'MONGO_URI');
|
|
414
|
+
mongoProdUri = getEnvValue(prodEnv, 'MONGO_URI');
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
res.json({ models, routes, mongoConfigured, dbType, routesDirExists, authConfigured, mongoUri, mongoProdUri });
|
|
418
|
+
} catch (err) {
|
|
419
|
+
res.status(500).json({ error: err.message });
|
|
420
|
+
}
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
// GET /api/models
|
|
424
|
+
router.get('/api/models', (req, res) => {
|
|
425
|
+
try {
|
|
426
|
+
const modelFiles = listModels();
|
|
427
|
+
const names = modelFiles.map((f) => path.basename(f, '.js'));
|
|
428
|
+
res.json(names);
|
|
429
|
+
} catch (err) {
|
|
430
|
+
res.status(500).json({ error: err.message });
|
|
431
|
+
}
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
// GET /api/models/:name
|
|
435
|
+
router.get('/api/models/:name', (req, res) => {
|
|
436
|
+
try {
|
|
437
|
+
const name = req.params.name;
|
|
438
|
+
const fields = getModelSchemaDetailed(name);
|
|
439
|
+
res.json({ name, fields });
|
|
440
|
+
} catch (err) {
|
|
441
|
+
res.status(500).json({ error: err.message });
|
|
442
|
+
}
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
// GET /api/models/:name/data — fetch stored documents from MongoDB collection
|
|
446
|
+
router.get('/api/models/:name/data', async (req, res) => {
|
|
447
|
+
try {
|
|
448
|
+
const name = req.params.name;
|
|
449
|
+
const rawLimit = parseInt(req.query.limit);
|
|
450
|
+
const limit = rawLimit === 0 ? 0 : Math.min(rawLimit || 50, 500);
|
|
451
|
+
const skip = parseInt(req.query.skip) || 0;
|
|
452
|
+
const pascalName = toPascalCase(name);
|
|
453
|
+
|
|
454
|
+
// Dynamically import mongoose from the project's node_modules
|
|
455
|
+
const projectMongoose = path.join(process.cwd(), 'node_modules', 'mongoose', 'index.js');
|
|
456
|
+
if (!fs.existsSync(projectMongoose)) {
|
|
457
|
+
return res.status(400).json({ error: 'Mongoose is not installed. Set up MongoDB first.' });
|
|
458
|
+
}
|
|
459
|
+
const mongoose = (await import(projectMongoose)).default;
|
|
460
|
+
|
|
461
|
+
// Connect if not already connected
|
|
462
|
+
if (mongoose.connection.readyState === 0) {
|
|
463
|
+
const devEnv = path.join(process.cwd(), '.env.development');
|
|
464
|
+
const uri = getEnvValue(devEnv, 'MONGO_URI');
|
|
465
|
+
if (!uri) return res.status(400).json({ error: 'MONGO_URI not configured.' });
|
|
466
|
+
await mongoose.connect(uri);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
const collection = mongoose.connection.db.collection(name + 's');
|
|
470
|
+
const total = await collection.countDocuments();
|
|
471
|
+
const query = collection.find({}).skip(skip);
|
|
472
|
+
if (limit > 0) query.limit(limit);
|
|
473
|
+
const docs = await query.toArray();
|
|
474
|
+
|
|
475
|
+
res.json({ docs, total, limit, skip });
|
|
476
|
+
} catch (err) {
|
|
477
|
+
res.status(500).json({ error: err.message });
|
|
478
|
+
}
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
// GET /api/models/:name/share — generate shareable endpoint documentation
|
|
482
|
+
router.get('/api/models/:name/share', (req, res) => {
|
|
483
|
+
try {
|
|
484
|
+
const name = req.params.name;
|
|
485
|
+
const fields = getModelSchemaDetailed(name);
|
|
486
|
+
const routes = listRoutes();
|
|
487
|
+
const routeFile = routes.find(r => r === name + '.js' || r === name + 's.js');
|
|
488
|
+
|
|
489
|
+
let endpoints = [];
|
|
490
|
+
if (routeFile) {
|
|
491
|
+
const filePath = path.join(process.cwd(), 'src', 'routes', routeFile);
|
|
492
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
493
|
+
const regex = /router\.(get|post|put|patch|delete)\(\s*['"`](\/[^'"`]*?)['"`]/gi;
|
|
494
|
+
let m;
|
|
495
|
+
while ((m = regex.exec(content)) !== null) {
|
|
496
|
+
endpoints.push({ method: m[1].toUpperCase(), path: m[2] });
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// Determine the route prefix
|
|
501
|
+
const indexPath = path.join(process.cwd(), 'index.js');
|
|
502
|
+
let prefix = '/api/' + name;
|
|
503
|
+
if (fs.existsSync(indexPath)) {
|
|
504
|
+
const indexContent = fs.readFileSync(indexPath, 'utf8');
|
|
505
|
+
const prefixMatch = indexContent.match(new RegExp(`app\\.use\\(['"](\\/[^'"]+)['"].*${routeFile ? routeFile.replace('.js', '') : name}`));
|
|
506
|
+
if (prefixMatch) prefix = prefixMatch[1];
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
res.json({ name, fields, endpoints, prefix, routeFile: routeFile || null });
|
|
510
|
+
} catch (err) {
|
|
511
|
+
res.status(500).json({ error: err.message });
|
|
512
|
+
}
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
// POST /api/models
|
|
516
|
+
router.post('/api/models', (req, res) => {
|
|
517
|
+
try {
|
|
518
|
+
if (!checkMongoConfig()) {
|
|
519
|
+
return res.status(400).json({ error: 'Database must be configured before creating schemas. Set up MongoDB first.' });
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
const { name, fields } = req.body;
|
|
523
|
+
const safeName = sanitizeName(name);
|
|
524
|
+
const pascalName = toPascalCase(safeName);
|
|
525
|
+
|
|
526
|
+
const modelsDir = path.join(process.cwd(), 'src', 'models');
|
|
527
|
+
if (!fs.existsSync(modelsDir)) {
|
|
528
|
+
fs.mkdirSync(modelsDir, { recursive: true });
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
const filePath = path.join(modelsDir, safeName + '.js');
|
|
532
|
+
if (fs.existsSync(filePath)) {
|
|
533
|
+
return res.status(409).json({ error: `Model "${safeName}" already exists.` });
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
const content = generateSchemaFileContent(pascalName, fields);
|
|
537
|
+
fs.writeFileSync(filePath, content, 'utf8');
|
|
538
|
+
res.json({ message: `Model "${safeName}" created.`, file: `src/models/${safeName}.js` });
|
|
539
|
+
} catch (err) {
|
|
540
|
+
res.status(500).json({ error: err.message });
|
|
541
|
+
}
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
// PUT /api/models/:name
|
|
545
|
+
router.put('/api/models/:name', (req, res) => {
|
|
546
|
+
try {
|
|
547
|
+
const name = req.params.name;
|
|
548
|
+
const { fields } = req.body;
|
|
549
|
+
const pascalName = toPascalCase(name);
|
|
550
|
+
|
|
551
|
+
const filePath = path.join(process.cwd(), 'src', 'models', name + '.js');
|
|
552
|
+
if (!fs.existsSync(filePath)) {
|
|
553
|
+
return res.status(404).json({ error: `Model "${name}" not found.` });
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
const content = generateSchemaFileContent(pascalName, fields);
|
|
557
|
+
fs.writeFileSync(filePath, content, 'utf8');
|
|
558
|
+
res.json({ message: `Model "${name}" updated.`, file: `src/models/${name}.js` });
|
|
559
|
+
} catch (err) {
|
|
560
|
+
res.status(500).json({ error: err.message });
|
|
561
|
+
}
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
// DELETE /api/models/:name
|
|
565
|
+
router.delete('/api/models/:name', (req, res) => {
|
|
566
|
+
try {
|
|
567
|
+
const name = req.params.name;
|
|
568
|
+
const filePath = path.join(process.cwd(), 'src', 'models', name + '.js');
|
|
569
|
+
if (!fs.existsSync(filePath)) {
|
|
570
|
+
return res.status(404).json({ error: `Model "${name}" not found.` });
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
fs.unlinkSync(filePath);
|
|
574
|
+
res.json({ message: `Model "${name}" deleted.` });
|
|
575
|
+
} catch (err) {
|
|
576
|
+
res.status(500).json({ error: err.message });
|
|
577
|
+
}
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
// GET /api/routes
|
|
581
|
+
router.get('/api/routes', (req, res) => {
|
|
582
|
+
try {
|
|
583
|
+
const routes = listRoutes();
|
|
584
|
+
res.json(routes);
|
|
585
|
+
} catch (err) {
|
|
586
|
+
res.status(500).json({ error: err.message });
|
|
587
|
+
}
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
// GET /api/routes/:name/content
|
|
591
|
+
router.get('/api/routes/:name/content', (req, res) => {
|
|
592
|
+
try {
|
|
593
|
+
const name = req.params.name;
|
|
594
|
+
const filePath = path.join(process.cwd(), 'src', 'routes', name);
|
|
595
|
+
if (!fs.existsSync(filePath)) {
|
|
596
|
+
return res.status(404).json({ error: `Route file "${name}" not found.` });
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
600
|
+
res.json({ name, content });
|
|
601
|
+
} catch (err) {
|
|
602
|
+
res.status(500).json({ error: err.message });
|
|
603
|
+
}
|
|
604
|
+
});
|
|
605
|
+
|
|
606
|
+
// DELETE /api/routes/:name
|
|
607
|
+
router.delete('/api/routes/:name', (req, res) => {
|
|
608
|
+
try {
|
|
609
|
+
const name = req.params.name;
|
|
610
|
+
const routeFile = name.endsWith('.js') ? name : name + '.js';
|
|
611
|
+
const safeName = routeFile.replace('.js', '');
|
|
612
|
+
const filePath = path.join(process.cwd(), 'src', 'routes', routeFile);
|
|
613
|
+
|
|
614
|
+
if (!fs.existsSync(filePath)) {
|
|
615
|
+
return res.status(404).json({ error: `Route file "${routeFile}" not found.` });
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
// Delete the route file
|
|
619
|
+
fs.unlinkSync(filePath);
|
|
620
|
+
|
|
621
|
+
// Send response BEFORE modifying index.js (nodemon restart kills connection)
|
|
622
|
+
res.json({ message: `Route "${safeName}" deleted successfully.` });
|
|
623
|
+
|
|
624
|
+
// Remove import and registration from index.js
|
|
625
|
+
const indexPath = path.join(process.cwd(), 'index.js');
|
|
626
|
+
if (fs.existsSync(indexPath)) {
|
|
627
|
+
let indexContent = fs.readFileSync(indexPath, 'utf8');
|
|
628
|
+
// Remove import line
|
|
629
|
+
indexContent = indexContent.replace(new RegExp(`import\\s+\\w+Router\\s+from\\s+['\"]\\./src/routes/${safeName}\\.js['\"];?\\n?`, 'g'), '');
|
|
630
|
+
// Remove app.use registration line
|
|
631
|
+
indexContent = indexContent.replace(new RegExp(`app\\.use\\(['\"][^'\"]*${safeName}['\"],\\s*\\w+Router\\);?\\n?`, 'g'), '');
|
|
632
|
+
fs.writeFileSync(indexPath, indexContent, 'utf8');
|
|
633
|
+
}
|
|
634
|
+
} catch (err) {
|
|
635
|
+
if (!res.headersSent) res.status(500).json({ error: err.message });
|
|
636
|
+
}
|
|
637
|
+
});
|
|
638
|
+
|
|
639
|
+
// PUT /api/routes/:name/content — edit route source code
|
|
640
|
+
// ---------------------------------------------------------------------------
|
|
641
|
+
router.put('/api/routes/:name/content', (req, res) => {
|
|
642
|
+
try {
|
|
643
|
+
const name = req.params.name;
|
|
644
|
+
const { content } = req.body;
|
|
645
|
+
if (!content || typeof content !== 'string') {
|
|
646
|
+
return res.status(400).json({ error: 'Content is required.' });
|
|
647
|
+
}
|
|
648
|
+
const routeFile = name.endsWith('.js') ? name : name + '.js';
|
|
649
|
+
const filePath = path.join(process.cwd(), 'src', 'routes', routeFile);
|
|
650
|
+
if (!fs.existsSync(filePath)) {
|
|
651
|
+
return res.status(404).json({ error: `Route file "${routeFile}" not found.` });
|
|
652
|
+
}
|
|
653
|
+
fs.writeFileSync(filePath, content, 'utf8');
|
|
654
|
+
res.json({ message: `Route "${routeFile}" updated successfully.` });
|
|
655
|
+
} catch (err) {
|
|
656
|
+
if (!res.headersSent) res.status(500).json({ error: err.message });
|
|
657
|
+
}
|
|
658
|
+
});
|
|
659
|
+
|
|
660
|
+
// ---------------------------------------------------------------------------
|
|
661
|
+
// POST /api/routes/:name/add-method — add handlers to existing route
|
|
662
|
+
// ---------------------------------------------------------------------------
|
|
663
|
+
router.post('/api/routes/:name/add-method', async (req, res) => {
|
|
664
|
+
try {
|
|
665
|
+
const name = req.params.name.replace(/\.js$/, '');
|
|
666
|
+
const routeFile = name + '.js';
|
|
667
|
+
const filePath = path.join(process.cwd(), 'src', 'routes', routeFile);
|
|
668
|
+
if (!fs.existsSync(filePath)) {
|
|
669
|
+
return res.status(404).json({ error: `Route file "${routeFile}" not found.` });
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
const { routeType = 'methods', methods: methodList = [], modelName, loginField, passwordField, registerFields = [], selectedFields = [], customFields = [] } = req.body;
|
|
673
|
+
const pascalModel = modelName ? toPascalCase(modelName) : '';
|
|
674
|
+
const hasPassword = !!passwordField;
|
|
675
|
+
const findField = loginField || 'email';
|
|
676
|
+
|
|
677
|
+
let content = fs.readFileSync(filePath, 'utf8');
|
|
678
|
+
|
|
679
|
+
// Check for duplicate methods
|
|
680
|
+
const existingMethods = [];
|
|
681
|
+
const existCheck = /router\.(get|post|put|patch|delete)\(/gi;
|
|
682
|
+
let em;
|
|
683
|
+
while ((em = existCheck.exec(content)) !== null) existingMethods.push(em[1].toLowerCase());
|
|
684
|
+
|
|
685
|
+
const newMethods = methodList.filter(m => !existingMethods.includes(m.toLowerCase()));
|
|
686
|
+
|
|
687
|
+
// Helper: add import if not present
|
|
688
|
+
function addImport(imp) {
|
|
689
|
+
if (!content.includes(imp.split(' from ')[0])) {
|
|
690
|
+
const lastImport = content.lastIndexOf('import ');
|
|
691
|
+
const lineEnd = content.indexOf('\n', lastImport);
|
|
692
|
+
content = content.slice(0, lineEnd + 1) + imp + '\n' + content.slice(lineEnd + 1);
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
// Helper: insert handler before export
|
|
697
|
+
function insertHandler(handler) {
|
|
698
|
+
const exportIdx = content.lastIndexOf('export default router;');
|
|
699
|
+
if (exportIdx !== -1) {
|
|
700
|
+
content = content.slice(0, exportIdx) + handler + '\n\n' + content.slice(exportIdx);
|
|
701
|
+
} else {
|
|
702
|
+
content += '\n' + handler;
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
let handlers = '';
|
|
707
|
+
const packagesToInstall = [];
|
|
708
|
+
|
|
709
|
+
if (routeType === 'methods') {
|
|
710
|
+
if (newMethods.length === 0) {
|
|
711
|
+
return res.status(400).json({ error: 'All selected methods already exist in this route.' });
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
const allFields = [...selectedFields.map(f => typeof f === 'string' ? { name: f, type: 'String' } : f), ...customFields];
|
|
715
|
+
const fileFields = allFields.filter(f => f.type === 'File');
|
|
716
|
+
const bodyFields = allFields.filter(f => f.type !== 'File');
|
|
717
|
+
const hasFileFields = fileFields.length > 0;
|
|
718
|
+
|
|
719
|
+
if (hasFileFields) {
|
|
720
|
+
addImport("import multer from 'multer';");
|
|
721
|
+
if (!content.includes('const upload = multer')) {
|
|
722
|
+
content = content.replace('const router = express.Router();', "const router = express.Router();\n\nconst upload = multer({ dest: 'uploads/' });");
|
|
723
|
+
}
|
|
724
|
+
packagesToInstall.push('multer');
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
newMethods.forEach(m => {
|
|
728
|
+
const dataSource = (m === 'get' || m === 'delete') ? 'req.query' : 'req.body';
|
|
729
|
+
let mw = '';
|
|
730
|
+
let destruct = '';
|
|
731
|
+
|
|
732
|
+
if (hasFileFields && m !== 'get' && m !== 'delete') {
|
|
733
|
+
if (fileFields.length === 1) mw = `upload.single('${fileFields[0].name}'), `;
|
|
734
|
+
else mw = `upload.fields([${fileFields.map(f => `{ name: '${f.name}', maxCount: 1 }`).join(', ')}]), `;
|
|
735
|
+
}
|
|
736
|
+
if (bodyFields.length > 0) destruct += ` const { ${bodyFields.map(f => f.name).join(', ')} } = ${dataSource};\n`;
|
|
737
|
+
if (hasFileFields && m !== 'get' && m !== 'delete') {
|
|
738
|
+
if (fileFields.length === 1) destruct += ` const ${fileFields[0].name} = req.file;\n`;
|
|
739
|
+
else fileFields.forEach(f => { destruct += ` const ${f.name} = req.files?.['${f.name}']?.[0];\n`; });
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
if (allFields.length > 0) {
|
|
743
|
+
handlers += `\nrouter.${m}('/', ${mw}async (req, res) => {\n${destruct}\n // TODO: Add your logic here\n\n res.json({ msg: '${name} ${m.toUpperCase()} endpoint works' });\n});\n`;
|
|
744
|
+
} else {
|
|
745
|
+
handlers += `\nrouter.${m}('/', async (req, res) => {\n res.json({ msg: '${name} ${m.toUpperCase()} endpoint works' });\n});\n`;
|
|
746
|
+
}
|
|
747
|
+
});
|
|
748
|
+
|
|
749
|
+
} else if (routeType === 'crud' && modelName) {
|
|
750
|
+
addImport(`import ${pascalModel} from '../models/${modelName}.js';`);
|
|
751
|
+
const crudMethods = newMethods.length > 0 ? newMethods : ['get', 'post', 'put', 'delete'].filter(m => !existingMethods.includes(m));
|
|
752
|
+
|
|
753
|
+
if (crudMethods.includes('get')) {
|
|
754
|
+
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
|
+
}
|
|
756
|
+
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`;
|
|
758
|
+
}
|
|
759
|
+
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`;
|
|
761
|
+
}
|
|
762
|
+
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`;
|
|
764
|
+
}
|
|
765
|
+
if (crudMethods.includes('delete')) {
|
|
766
|
+
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`;
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
} else if (routeType === 'login' && modelName) {
|
|
770
|
+
addImport(`import ${pascalModel} from '../models/${modelName}.js';`);
|
|
771
|
+
if (hasPassword) { addImport("import bcrypt from 'bcrypt';"); packagesToInstall.push('bcrypt'); }
|
|
772
|
+
addImport("import jwt from 'jsonwebtoken';");
|
|
773
|
+
packagesToInstall.push('jsonwebtoken');
|
|
774
|
+
|
|
775
|
+
if (hasPassword) {
|
|
776
|
+
handlers += `\n// Login\nrouter.post('/', async (req, res) => {\n try {\n const { ${findField}, ${passwordField} } = req.body;\n const ${modelName} = await ${pascalModel}.findOne({ ${findField} });\n if (!${modelName}) return res.status(401).json({ message: 'Invalid credentials' });\n\n const isMatch = await bcrypt.compare(${passwordField}, ${modelName}.${passwordField});\n if (!isMatch) return res.status(401).json({ message: 'Invalid credentials' });\n\n const token = jwt.sign({ id: ${modelName}._id }, process.env.JWT_SECRET, { expiresIn: '7d' });\n res.json({ token, ${modelName}: { id: ${modelName}._id, ${findField}: ${modelName}.${findField} } });\n } catch (err) {\n res.status(500).json({ message: err.message });\n }\n});\n`;
|
|
777
|
+
} else {
|
|
778
|
+
handlers += `\n// Login\nrouter.post('/', async (req, res) => {\n try {\n const { ${findField} } = req.body;\n const ${modelName} = await ${pascalModel}.findOne({ ${findField} });\n if (!${modelName}) return res.status(401).json({ message: 'Invalid credentials' });\n\n const token = jwt.sign({ id: ${modelName}._id }, process.env.JWT_SECRET, { expiresIn: '7d' });\n res.json({ token, ${modelName}: { id: ${modelName}._id, ${findField}: ${modelName}.${findField} } });\n } catch (err) {\n res.status(500).json({ message: err.message });\n }\n});\n`;
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
// Ensure JWT_SECRET
|
|
782
|
+
const devEnv = path.join(process.cwd(), '.env.development');
|
|
783
|
+
const prodEnv = path.join(process.cwd(), '.env.production');
|
|
784
|
+
if (fs.existsSync(devEnv) && !fs.readFileSync(devEnv, 'utf8').includes('JWT_SECRET')) updateEnvFile(devEnv, 'JWT_SECRET', crypto.randomBytes(32).toString('hex'));
|
|
785
|
+
if (fs.existsSync(prodEnv) && !fs.readFileSync(prodEnv, 'utf8').includes('JWT_SECRET')) updateEnvFile(prodEnv, 'JWT_SECRET', crypto.randomBytes(32).toString('hex'));
|
|
786
|
+
|
|
787
|
+
} else if (routeType === 'register' && modelName) {
|
|
788
|
+
addImport(`import ${pascalModel} from '../models/${modelName}.js';`);
|
|
789
|
+
if (hasPassword) { addImport("import bcrypt from 'bcrypt';"); packagesToInstall.push('bcrypt'); }
|
|
790
|
+
addImport("import jwt from 'jsonwebtoken';");
|
|
791
|
+
packagesToInstall.push('jsonwebtoken');
|
|
792
|
+
|
|
793
|
+
const regFields = registerFields.length > 0 ? registerFields : [findField];
|
|
794
|
+
const allDestructured = regFields.join(', ');
|
|
795
|
+
|
|
796
|
+
if (hasPassword) {
|
|
797
|
+
const nonPwFields = regFields.filter(f => f !== passwordField);
|
|
798
|
+
handlers += `\n// Register\nrouter.post('/', async (req, res) => {\n try {\n const { ${allDestructured} } = req.body;\n const existing = await ${pascalModel}.findOne({ ${findField} });\n if (existing) return res.status(400).json({ message: '${pascalModel} already exists' });\n\n const salt = await bcrypt.genSalt(10);\n const hashedPassword = await bcrypt.hash(${passwordField}, salt);\n const item = new ${pascalModel}({ ${nonPwFields.join(', ')}, ${passwordField}: hashedPassword });\n const saved = await item.save();\n\n const token = jwt.sign({ id: saved._id }, process.env.JWT_SECRET, { expiresIn: '7d' });\n res.status(201).json({ token, ${modelName}: { id: saved._id, ${findField}: saved.${findField} } });\n } catch (err) {\n res.status(400).json({ message: err.message });\n }\n});\n`;
|
|
799
|
+
} else {
|
|
800
|
+
handlers += `\n// Register\nrouter.post('/', async (req, res) => {\n try {\n const { ${allDestructured} } = req.body;\n const existing = await ${pascalModel}.findOne({ ${findField} });\n if (existing) return res.status(400).json({ message: '${pascalModel} already exists' });\n\n const item = new ${pascalModel}({ ${allDestructured} });\n const saved = await item.save();\n\n const token = jwt.sign({ id: saved._id }, process.env.JWT_SECRET, { expiresIn: '7d' });\n res.status(201).json({ token, ${modelName}: { id: saved._id, ${findField}: saved.${findField} } });\n } catch (err) {\n res.status(400).json({ message: err.message });\n }\n});\n`;
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
const devEnv = path.join(process.cwd(), '.env.development');
|
|
804
|
+
const prodEnv = path.join(process.cwd(), '.env.production');
|
|
805
|
+
if (fs.existsSync(devEnv) && !fs.readFileSync(devEnv, 'utf8').includes('JWT_SECRET')) updateEnvFile(devEnv, 'JWT_SECRET', crypto.randomBytes(32).toString('hex'));
|
|
806
|
+
if (fs.existsSync(prodEnv) && !fs.readFileSync(prodEnv, 'utf8').includes('JWT_SECRET')) updateEnvFile(prodEnv, 'JWT_SECRET', crypto.randomBytes(32).toString('hex'));
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
if (!handlers) {
|
|
810
|
+
return res.status(400).json({ error: 'Nothing to add.' });
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
// Install packages
|
|
814
|
+
if (packagesToInstall.length > 0) {
|
|
815
|
+
try { await execAsync(`npm install ${[...new Set(packagesToInstall)].join(' ')}`, { cwd: process.cwd() }); } catch {}
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
insertHandler(handlers);
|
|
819
|
+
fs.writeFileSync(filePath, content, 'utf8');
|
|
820
|
+
|
|
821
|
+
const label = routeType === 'methods' ? newMethods.map(m => m.toUpperCase()).join(', ') : routeType.toUpperCase();
|
|
822
|
+
res.json({ message: `${label} added to "${name}" route.`, content });
|
|
823
|
+
} catch (err) {
|
|
824
|
+
if (!res.headersSent) res.status(500).json({ error: err.message });
|
|
825
|
+
}
|
|
826
|
+
});
|
|
827
|
+
|
|
828
|
+
// GET /api/routes/:name/share — generate shareable endpoint docs with field metadata
|
|
829
|
+
router.get('/api/routes/:name/share', (req, res) => {
|
|
830
|
+
try {
|
|
831
|
+
const name = req.params.name.replace(/\.js$/, '');
|
|
832
|
+
const routeFile = name + '.js';
|
|
833
|
+
const filePath = path.join(process.cwd(), 'src', 'routes', routeFile);
|
|
834
|
+
if (!fs.existsSync(filePath)) {
|
|
835
|
+
return res.status(404).json({ error: `Route file "${routeFile}" not found.` });
|
|
836
|
+
}
|
|
837
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
838
|
+
|
|
839
|
+
// Determine the route prefix from index.js
|
|
840
|
+
const indexPath = path.join(process.cwd(), 'index.js');
|
|
841
|
+
let prefix = '/api/' + name;
|
|
842
|
+
if (fs.existsSync(indexPath)) {
|
|
843
|
+
const indexContent = fs.readFileSync(indexPath, 'utf8');
|
|
844
|
+
const prefixMatch = indexContent.match(new RegExp(`app\\.use\\(['"](\\/[^'"]+)['"].*${name}`));
|
|
845
|
+
if (prefixMatch) prefix = prefixMatch[1];
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
// Check if route imports a model
|
|
849
|
+
const modelImportMatch = content.match(/import\s+(\w+)\s+from\s+['"]\.\.\/models\/(\w+)['"]/);
|
|
850
|
+
let modelFields = [];
|
|
851
|
+
if (modelImportMatch) {
|
|
852
|
+
modelFields = getModelSchemaDetailed(modelImportMatch[2]);
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
// Parse each endpoint with body/query fields
|
|
856
|
+
const endpoints = [];
|
|
857
|
+
const epRegex = /router\.(get|post|put|patch|delete)\(\s*['"`](\/[^'"`]*?)['"`]\s*,\s*(?:auth\s*,\s*)?(?:async\s+)?(?:\([^)]*\)|\w+)\s*=>\s*\{([\s\S]*?)(?=\nrouter\.|$)/gi;
|
|
858
|
+
let m;
|
|
859
|
+
while ((m = epRegex.exec(content)) !== null) {
|
|
860
|
+
const method = m[1].toUpperCase();
|
|
861
|
+
const epath = m[2];
|
|
862
|
+
const body = m[3] || '';
|
|
863
|
+
|
|
864
|
+
const bodyFields = [];
|
|
865
|
+
const bodyMatch = body.match(/const\s*\{\s*([^}]+)\}\s*=\s*req\.body/);
|
|
866
|
+
if (bodyMatch) {
|
|
867
|
+
bodyMatch[1].split(',').forEach(f => {
|
|
868
|
+
const fname = f.trim();
|
|
869
|
+
if (fname) {
|
|
870
|
+
const meta = modelFields.find(mf => mf.name === fname);
|
|
871
|
+
bodyFields.push({
|
|
872
|
+
name: fname,
|
|
873
|
+
type: meta?.type || 'String',
|
|
874
|
+
required: meta?.required || false,
|
|
875
|
+
unique: meta?.unique || false,
|
|
876
|
+
});
|
|
877
|
+
}
|
|
878
|
+
});
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
const queryFields = [];
|
|
882
|
+
const queryMatch = body.match(/const\s*\{\s*([^}]+)\}\s*=\s*req\.query/);
|
|
883
|
+
if (queryMatch) {
|
|
884
|
+
queryMatch[1].split(',').forEach(f => {
|
|
885
|
+
const fname = f.trim();
|
|
886
|
+
if (fname) queryFields.push({ name: fname, type: 'String' });
|
|
887
|
+
});
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
// Check for params
|
|
891
|
+
const params = [];
|
|
892
|
+
const paramMatches = epath.match(/:(\w+)/g);
|
|
893
|
+
if (paramMatches) {
|
|
894
|
+
paramMatches.forEach(p => params.push(p.replace(':', '')));
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
endpoints.push({ method, path: epath, fullPath: prefix + (epath === '/' ? '' : epath), bodyFields, queryFields, params });
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
res.json({
|
|
901
|
+
name,
|
|
902
|
+
prefix,
|
|
903
|
+
routeFile,
|
|
904
|
+
modelName: modelImportMatch ? modelImportMatch[2] : null,
|
|
905
|
+
modelFields,
|
|
906
|
+
endpoints,
|
|
907
|
+
});
|
|
908
|
+
} catch (err) {
|
|
909
|
+
res.status(500).json({ error: err.message });
|
|
910
|
+
}
|
|
911
|
+
});
|
|
912
|
+
|
|
913
|
+
// ---------------------------------------------------------------------------
|
|
914
|
+
// POST /api/crud/preview
|
|
915
|
+
router.post('/api/crud/preview', (req, res) => {
|
|
916
|
+
try {
|
|
917
|
+
const { modelName, routeFile, operations } = req.body;
|
|
918
|
+
const pascalName = toPascalCase(modelName);
|
|
919
|
+
const fields = getModelSchema(modelName);
|
|
920
|
+
const routeFilePath = path.join(process.cwd(), 'src', 'routes', routeFile);
|
|
921
|
+
|
|
922
|
+
let dataSource = 'req.body';
|
|
923
|
+
if (fs.existsSync(routeFilePath)) {
|
|
924
|
+
const method = detectHttpMethod(routeFilePath);
|
|
925
|
+
dataSource = getRequestDataSource(method);
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
const needsBcrypt = fields.includes('password') &&
|
|
929
|
+
(operations.includes('create') || operations.includes('update'));
|
|
930
|
+
|
|
931
|
+
let code = '';
|
|
932
|
+
|
|
933
|
+
// Import block
|
|
934
|
+
let importBlock = `import ${pascalName} from '../models/${modelName}.js';`;
|
|
935
|
+
if (needsBcrypt) {
|
|
936
|
+
importBlock = `import bcrypt from 'bcrypt';\n${importBlock}`;
|
|
937
|
+
}
|
|
938
|
+
code += importBlock + '\n';
|
|
939
|
+
|
|
940
|
+
if (operations.includes('create')) {
|
|
941
|
+
code += buildInsertCode(pascalName, fields, dataSource) + '\n';
|
|
942
|
+
}
|
|
943
|
+
if (operations.includes('read')) {
|
|
944
|
+
code += buildReadCode(pascalName) + '\n';
|
|
945
|
+
}
|
|
946
|
+
if (operations.includes('update')) {
|
|
947
|
+
code += buildUpdateCode(pascalName, fields, dataSource) + '\n';
|
|
948
|
+
}
|
|
949
|
+
if (operations.includes('delete')) {
|
|
950
|
+
code += buildDeleteCode(pascalName) + '\n';
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
res.json({ code });
|
|
954
|
+
} catch (err) {
|
|
955
|
+
res.status(500).json({ error: err.message });
|
|
956
|
+
}
|
|
957
|
+
});
|
|
958
|
+
|
|
959
|
+
// POST /api/crud/generate
|
|
960
|
+
router.post('/api/crud/generate', (req, res) => {
|
|
961
|
+
try {
|
|
962
|
+
const { modelName, routeFile, operations } = req.body;
|
|
963
|
+
const pascalName = toPascalCase(modelName);
|
|
964
|
+
const fields = getModelSchema(modelName);
|
|
965
|
+
const routeFilePath = path.join(process.cwd(), 'src', 'routes', routeFile);
|
|
966
|
+
|
|
967
|
+
if (!fs.existsSync(routeFilePath)) {
|
|
968
|
+
return res.status(404).json({ error: `Route file "${routeFile}" not found.` });
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
if (fields.length === 0) {
|
|
972
|
+
return res.status(400).json({ error: 'No fields found in the selected model.' });
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
const method = detectHttpMethod(routeFilePath);
|
|
976
|
+
const dataSource = getRequestDataSource(method);
|
|
977
|
+
const needsBcrypt = fields.includes('password') &&
|
|
978
|
+
(operations.includes('create') || operations.includes('update'));
|
|
979
|
+
|
|
980
|
+
// Build import
|
|
981
|
+
let importBlock = `import ${pascalName} from '../models/${modelName}.js';`;
|
|
982
|
+
if (needsBcrypt) {
|
|
983
|
+
importBlock = `import bcrypt from 'bcrypt';\n${importBlock}`;
|
|
984
|
+
}
|
|
985
|
+
addImportToRoute(routeFilePath, importBlock);
|
|
986
|
+
|
|
987
|
+
// Add selected operations
|
|
988
|
+
const added = [];
|
|
989
|
+
|
|
990
|
+
if (operations.includes('create')) {
|
|
991
|
+
insertCodeIntoRoute(routeFilePath, buildInsertCode(pascalName, fields, dataSource));
|
|
992
|
+
added.push('POST /create');
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
if (operations.includes('read')) {
|
|
996
|
+
insertCodeIntoRoute(routeFilePath, buildReadCode(pascalName));
|
|
997
|
+
added.push('GET / and GET /:id');
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
if (operations.includes('update')) {
|
|
1001
|
+
insertCodeIntoRoute(routeFilePath, buildUpdateCode(pascalName, fields, dataSource));
|
|
1002
|
+
added.push('PUT /:id');
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
if (operations.includes('delete')) {
|
|
1006
|
+
insertCodeIntoRoute(routeFilePath, buildDeleteCode(pascalName));
|
|
1007
|
+
added.push('DELETE /:id');
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
res.json({
|
|
1011
|
+
message: `CRUD operations added to ${routeFile}`,
|
|
1012
|
+
model: modelName,
|
|
1013
|
+
routes: added,
|
|
1014
|
+
});
|
|
1015
|
+
} catch (err) {
|
|
1016
|
+
res.status(500).json({ error: err.message });
|
|
1017
|
+
}
|
|
1018
|
+
});
|
|
1019
|
+
|
|
1020
|
+
// GET /api/env
|
|
1021
|
+
router.get('/api/env', (req, res) => {
|
|
1022
|
+
try {
|
|
1023
|
+
const devEnv = path.join(process.cwd(), '.env.development');
|
|
1024
|
+
const prodEnv = path.join(process.cwd(), '.env.production');
|
|
1025
|
+
const devContent = fs.existsSync(devEnv) ? fs.readFileSync(devEnv, 'utf8') : '';
|
|
1026
|
+
const prodContent = fs.existsSync(prodEnv) ? fs.readFileSync(prodEnv, 'utf8') : '';
|
|
1027
|
+
res.json({ development: devContent, production: prodContent });
|
|
1028
|
+
} catch (err) {
|
|
1029
|
+
res.status(500).json({ error: err.message });
|
|
1030
|
+
}
|
|
1031
|
+
});
|
|
1032
|
+
|
|
1033
|
+
// PUT /api/env
|
|
1034
|
+
router.put('/api/env', (req, res) => {
|
|
1035
|
+
try {
|
|
1036
|
+
const { environment, content } = req.body;
|
|
1037
|
+
if (!environment || !['development', 'production'].includes(environment)) {
|
|
1038
|
+
return res.status(400).json({ error: 'Invalid environment. Must be "development" or "production".' });
|
|
1039
|
+
}
|
|
1040
|
+
const envPath = path.join(process.cwd(), `.env.${environment}`);
|
|
1041
|
+
fs.writeFileSync(envPath, content, 'utf8');
|
|
1042
|
+
res.json({ message: `Environment file (.env.${environment}) updated.` });
|
|
1043
|
+
} catch (err) {
|
|
1044
|
+
res.status(500).json({ error: err.message });
|
|
1045
|
+
}
|
|
1046
|
+
});
|
|
1047
|
+
|
|
1048
|
+
// ---------------------------------------------------------------------------
|
|
1049
|
+
// Index.js manipulation helpers
|
|
1050
|
+
// ---------------------------------------------------------------------------
|
|
1051
|
+
|
|
1052
|
+
function readIndexFile() {
|
|
1053
|
+
return fs.readFileSync(path.join(process.cwd(), 'index.js'), 'utf8');
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
function writeIndexFile(content) {
|
|
1057
|
+
fs.writeFileSync(path.join(process.cwd(), 'index.js'), content, 'utf8');
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
function addImportToIndex(importLine) {
|
|
1061
|
+
let content = readIndexFile();
|
|
1062
|
+
if (content.includes(importLine.trim())) return;
|
|
1063
|
+
const lines = content.split('\n');
|
|
1064
|
+
let lastImportIdx = -1;
|
|
1065
|
+
lines.forEach((line, i) => {
|
|
1066
|
+
if (line.startsWith('import ')) lastImportIdx = i;
|
|
1067
|
+
});
|
|
1068
|
+
lines.splice(lastImportIdx + 1, 0, importLine.trim());
|
|
1069
|
+
writeIndexFile(lines.join('\n'));
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
function insertBeforeListen(codeBlock) {
|
|
1073
|
+
let content = readIndexFile();
|
|
1074
|
+
const listenRegex = /(app|server)\.listen\(port/m;
|
|
1075
|
+
let match = content.match(listenRegex);
|
|
1076
|
+
if (!match) {
|
|
1077
|
+
// Match the startServer() call, not the function declaration
|
|
1078
|
+
match = content.match(/^startServer\(/m);
|
|
1079
|
+
}
|
|
1080
|
+
if (match) {
|
|
1081
|
+
content = content.slice(0, match.index) + codeBlock.trim() + '\n' + content.slice(match.index);
|
|
1082
|
+
}
|
|
1083
|
+
writeIndexFile(content);
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
function replaceAppListen(newBlock) {
|
|
1087
|
+
let content = readIndexFile();
|
|
1088
|
+
if (content.includes('startServer(')) {
|
|
1089
|
+
content = content.replace(/const listener = app\.listen\(tryPort\)/, 'const listener = server.listen(tryPort)');
|
|
1090
|
+
if (!content.includes('server.listen(tryPort)')) {
|
|
1091
|
+
content = content.replace(/app\.listen\(tryPort\)/, 'server.listen(tryPort)');
|
|
1092
|
+
}
|
|
1093
|
+
} else {
|
|
1094
|
+
content = content.replace(/app\.listen\(port[\s\S]*?\}\);?\n?/m, newBlock.trim() + '\n');
|
|
1095
|
+
}
|
|
1096
|
+
writeIndexFile(content);
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
function removeSocketIOCode() {
|
|
1100
|
+
let content = readIndexFile();
|
|
1101
|
+
// Remove Socket.IO import
|
|
1102
|
+
content = content.replace(/import\s*\{\s*Server\s*\}\s*from\s*["']socket\.io["'];\s*\n?/g, '');
|
|
1103
|
+
// Remove http import only if WebSocket doesn't need it
|
|
1104
|
+
if (!content.includes('WebSocketServer({ server')) {
|
|
1105
|
+
content = content.replace(/import\s+http\s+from\s*["']http["'];\s*\n?/g, '');
|
|
1106
|
+
}
|
|
1107
|
+
// Remove server = http.createServer(app) only if WebSocket doesn't need it
|
|
1108
|
+
if (!content.includes('WebSocketServer({ server')) {
|
|
1109
|
+
content = content.replace(/const\s+server\s*=\s*http\.createServer\(app\);\s*\n?/g, '');
|
|
1110
|
+
}
|
|
1111
|
+
// Remove io setup block (use \n}); to match outer closing brace at column 0)
|
|
1112
|
+
content = content.replace(/const\s+io\s*=\s*new\s+Server\(server[\s\S]*?\n\}\);\s*\n?/g, '');
|
|
1113
|
+
// Remove io.on("connection"...) block (nested callbacks — match \n}); at column 0)
|
|
1114
|
+
content = content.replace(/io\.on\(["']connection["'][\s\S]*?\n\}\);\s*\n?/g, '');
|
|
1115
|
+
// Remove req.io middleware
|
|
1116
|
+
content = content.replace(/app\.use\(\(req,\s*res,\s*next\)\s*=>\s*\{\s*\n?\s*req\.io\s*=\s*io;\s*\n?\s*next\(\);\s*\n?\s*\}\);\s*\n?/g, '');
|
|
1117
|
+
// Replace server.listen back to app.listen if no WebSocket uses server
|
|
1118
|
+
if (!content.includes('WebSocketServer({ server') && content.includes('server.listen(port')) {
|
|
1119
|
+
content = content.replace(/server\.listen\(port[\s\S]*?\n\}\);?\s*\n?/m, `app.listen(port, () => {
|
|
1120
|
+
console.log(\`Server is running on port \${port}\`);
|
|
1121
|
+
});\n`);
|
|
1122
|
+
}
|
|
1123
|
+
// Clean up multiple blank lines
|
|
1124
|
+
content = content.replace(/\n{3,}/g, '\n\n');
|
|
1125
|
+
writeIndexFile(content);
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
function removeWebSocketCode() {
|
|
1129
|
+
let content = readIndexFile();
|
|
1130
|
+
// Remove ws import
|
|
1131
|
+
content = content.replace(/import\s*\{\s*WebSocketServer\s*\}\s*from\s*['"]ws['"];\s*\n?/g, '');
|
|
1132
|
+
// Remove WebSocketServer setup (both same-port and separate-port)
|
|
1133
|
+
content = content.replace(/const\s+wss\s*=\s*new\s+WebSocketServer\(\{[\s\S]*?\n?\}\);\s*\n?/g, '');
|
|
1134
|
+
// Remove wss.on('connection'...) block (nested callbacks — match \n}); at column 0)
|
|
1135
|
+
content = content.replace(/wss\.on\(['"]connection['"][\s\S]*?\n\}\);\s*\n?/g, '');
|
|
1136
|
+
// Remove console.log for WS port
|
|
1137
|
+
content = content.replace(/console\.log\(['"]WebSocket server running on port.*?['"]?\);\s*\n?/g, '');
|
|
1138
|
+
// Remove http import only if Socket.IO doesn't need it
|
|
1139
|
+
if (!content.includes('new Server(server')) {
|
|
1140
|
+
content = content.replace(/import\s+http\s+from\s*['"]http['"];\s*\n?/g, '');
|
|
1141
|
+
}
|
|
1142
|
+
// Remove server = http.createServer(app) only if Socket.IO doesn't need it
|
|
1143
|
+
if (!content.includes('new Server(server')) {
|
|
1144
|
+
content = content.replace(/const\s+server\s*=\s*http\.createServer\(app\);\s*\n?/g, '');
|
|
1145
|
+
// Replace server.listen back to app.listen
|
|
1146
|
+
if (content.includes('server.listen(port')) {
|
|
1147
|
+
content = content.replace(/server\.listen\(port[\s\S]*?\n\}\);?\s*\n?/m, `app.listen(port, () => {
|
|
1148
|
+
console.log(\`Server is running on port \${port}\`);
|
|
1149
|
+
});\n`);
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
// Clean up multiple blank lines
|
|
1153
|
+
content = content.replace(/\n{3,}/g, '\n\n');
|
|
1154
|
+
writeIndexFile(content);
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
function updateEnvFile(filePath, key, value) {
|
|
1158
|
+
if (fs.existsSync(filePath)) {
|
|
1159
|
+
let content = fs.readFileSync(filePath, 'utf8');
|
|
1160
|
+
const regex = new RegExp(`^${key}=.*`, 'm');
|
|
1161
|
+
if (regex.test(content)) {
|
|
1162
|
+
content = content.replace(regex, `${key}=${value}`);
|
|
1163
|
+
} else {
|
|
1164
|
+
content = content.trimEnd() + '\n' + `${key}=${value}\n`;
|
|
1165
|
+
}
|
|
1166
|
+
fs.writeFileSync(filePath, content, 'utf8');
|
|
1167
|
+
} else {
|
|
1168
|
+
fs.writeFileSync(filePath, `${key}=${value}\n`, 'utf8');
|
|
1169
|
+
}
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
// ---------------------------------------------------------------------------
|
|
1173
|
+
// GET /api/status
|
|
1174
|
+
// ---------------------------------------------------------------------------
|
|
1175
|
+
router.get('/api/status', (req, res) => {
|
|
1176
|
+
try {
|
|
1177
|
+
const indexPath = path.join(process.cwd(), 'index.js');
|
|
1178
|
+
const indexContent = fs.existsSync(indexPath) ? fs.readFileSync(indexPath, 'utf8') : '';
|
|
1179
|
+
|
|
1180
|
+
const mongoConfigured = indexContent.includes("import connectDB") || indexContent.includes("connectDB");
|
|
1181
|
+
const routesDirExists = fs.existsSync(path.join(process.cwd(), 'src', 'routes'));
|
|
1182
|
+
const authMiddlewareExists = fs.existsSync(path.join(process.cwd(), 'src', 'middleware', 'auth.js'));
|
|
1183
|
+
const socketConfigured = indexContent.includes('setupSocketIO') || indexContent.includes('socket.io') || indexContent.includes('Server(server');
|
|
1184
|
+
const websocketConfigured = indexContent.includes('setupWebSocket') || indexContent.includes('WebSocketServer') || indexContent.includes("from 'ws'");
|
|
1185
|
+
const serialportConfigured = indexContent.includes('SerialPort') || indexContent.includes("from 'serialport'");
|
|
1186
|
+
|
|
1187
|
+
res.json({
|
|
1188
|
+
mongoConfigured,
|
|
1189
|
+
routesDirExists,
|
|
1190
|
+
authMiddlewareExists,
|
|
1191
|
+
socketConfigured,
|
|
1192
|
+
websocketConfigured,
|
|
1193
|
+
serialportConfigured
|
|
1194
|
+
});
|
|
1195
|
+
} catch (err) {
|
|
1196
|
+
res.status(500).json({ error: err.message });
|
|
1197
|
+
}
|
|
1198
|
+
});
|
|
1199
|
+
|
|
1200
|
+
// ---------------------------------------------------------------------------
|
|
1201
|
+
// POST /api/add-api
|
|
1202
|
+
// ---------------------------------------------------------------------------
|
|
1203
|
+
router.post('/api/add-api', async (req, res) => {
|
|
1204
|
+
try {
|
|
1205
|
+
const { name, pathPrefix = '/api/', routeType, methods = [], modelName, loginField, passwordField, registerFields = [], selectedFields = [], customFields = [] } = req.body;
|
|
1206
|
+
// Legacy support: single method field
|
|
1207
|
+
const method = req.body.method;
|
|
1208
|
+
|
|
1209
|
+
const safeName = sanitizeName(name);
|
|
1210
|
+
const camelName = toCamelCase(safeName);
|
|
1211
|
+
const pascalModel = modelName ? toPascalCase(modelName) : '';
|
|
1212
|
+
const type = routeType || (method ? 'methods' : 'plain');
|
|
1213
|
+
const methodList = method ? [method] : methods;
|
|
1214
|
+
const hasPassword = !!passwordField;
|
|
1215
|
+
|
|
1216
|
+
const routesDir = path.join(process.cwd(), 'src', 'routes');
|
|
1217
|
+
if (!fs.existsSync(routesDir)) {
|
|
1218
|
+
fs.mkdirSync(routesDir, { recursive: true });
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
const routeFilePath = path.join(routesDir, safeName + '.js');
|
|
1222
|
+
|
|
1223
|
+
// Install packages if needed
|
|
1224
|
+
const packagesToInstall = [];
|
|
1225
|
+
if (type === 'login' || type === 'register') {
|
|
1226
|
+
if (hasPassword) packagesToInstall.push('bcrypt');
|
|
1227
|
+
packagesToInstall.push('jsonwebtoken');
|
|
1228
|
+
}
|
|
1229
|
+
const allFieldsCombined = [...selectedFields.map(f => typeof f === 'string' ? { name: f, type: 'String' } : f), ...customFields];
|
|
1230
|
+
const needsMulter = allFieldsCombined.some(f => f.type === 'File');
|
|
1231
|
+
if (needsMulter) packagesToInstall.push('multer');
|
|
1232
|
+
if (packagesToInstall.length > 0) {
|
|
1233
|
+
await execAsync(`npm install ${packagesToInstall.join(' ')}`, { cwd: process.cwd() });
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
// Build imports
|
|
1237
|
+
let imports = "import express from 'express';\n";
|
|
1238
|
+
if ((type === 'login' || type === 'register') && hasPassword) {
|
|
1239
|
+
imports += "import bcrypt from 'bcrypt';\n";
|
|
1240
|
+
}
|
|
1241
|
+
if (type === 'login' || type === 'register') {
|
|
1242
|
+
imports += "import jwt from 'jsonwebtoken';\n";
|
|
1243
|
+
}
|
|
1244
|
+
if ((type === 'login' || type === 'register' || type === 'crud') && modelName) {
|
|
1245
|
+
imports += `import ${pascalModel} from '../models/${modelName}.js';\n`;
|
|
1246
|
+
} else if (type === 'methods' && modelName && selectedFields.length > 0) {
|
|
1247
|
+
imports += `import ${pascalModel} from '../models/${modelName}.js';\n`;
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
// Build handlers
|
|
1251
|
+
let handlers = '';
|
|
1252
|
+
const findField = loginField || 'email';
|
|
1253
|
+
|
|
1254
|
+
if (type === 'plain') {
|
|
1255
|
+
handlers += `
|
|
1256
|
+
// Add your route handlers here
|
|
1257
|
+
// Example: router.get('/', (req, res) => res.json({ message: '${safeName} works' }));
|
|
1258
|
+
`;
|
|
1259
|
+
} else if (type === 'methods') {
|
|
1260
|
+
// Combine selected model fields + custom fields
|
|
1261
|
+
const allFields = [...selectedFields.map(f => typeof f === 'string' ? { name: f, type: 'String' } : f), ...customFields];
|
|
1262
|
+
const hasFileFields = allFields.some(f => f.type === 'File');
|
|
1263
|
+
const isMultipart = hasFileFields;
|
|
1264
|
+
const bodyFields = allFields.filter(f => f.type !== 'File');
|
|
1265
|
+
const fileFields = allFields.filter(f => f.type === 'File');
|
|
1266
|
+
|
|
1267
|
+
if (isMultipart) {
|
|
1268
|
+
imports += "import multer from 'multer';\n";
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
methodList.forEach(m => {
|
|
1272
|
+
const dataSource = (m === 'get' || m === 'delete') ? 'req.query' : 'req.body';
|
|
1273
|
+
let middlewareStr = '';
|
|
1274
|
+
let destructLines = '';
|
|
1275
|
+
|
|
1276
|
+
if (isMultipart && m !== 'get' && m !== 'delete') {
|
|
1277
|
+
if (fileFields.length === 0) {
|
|
1278
|
+
middlewareStr = 'upload.none(), ';
|
|
1279
|
+
} else if (fileFields.length === 1) {
|
|
1280
|
+
middlewareStr = `upload.single('${fileFields[0].name}'), `;
|
|
1281
|
+
} else {
|
|
1282
|
+
const fieldsArg = fileFields.map(f => `{ name: '${f.name}', maxCount: 1 }`).join(', ');
|
|
1283
|
+
middlewareStr = `upload.fields([${fieldsArg}]), `;
|
|
1284
|
+
}
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
if (bodyFields.length > 0) {
|
|
1288
|
+
const fieldNames = bodyFields.map(f => f.name).join(', ');
|
|
1289
|
+
destructLines += ` const { ${fieldNames} } = ${dataSource};\n`;
|
|
1290
|
+
}
|
|
1291
|
+
if (fileFields.length > 0 && m !== 'get' && m !== 'delete') {
|
|
1292
|
+
if (fileFields.length === 1) {
|
|
1293
|
+
destructLines += ` const ${fileFields[0].name} = req.file;\n`;
|
|
1294
|
+
} else {
|
|
1295
|
+
fileFields.forEach(f => {
|
|
1296
|
+
destructLines += ` const ${f.name} = req.files?.['${f.name}']?.[0];\n`;
|
|
1297
|
+
});
|
|
1298
|
+
}
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
if (allFields.length > 0) {
|
|
1302
|
+
handlers += `
|
|
1303
|
+
router.${m}('/', ${middlewareStr}async (req, res) => {
|
|
1304
|
+
${destructLines}
|
|
1305
|
+
// TODO: Add your logic here
|
|
1306
|
+
|
|
1307
|
+
res.json({ msg: '${safeName} ${m.toUpperCase()} endpoint works' });
|
|
1308
|
+
});
|
|
1309
|
+
`;
|
|
1310
|
+
} else {
|
|
1311
|
+
handlers += `
|
|
1312
|
+
router.${m}('/', async (req, res) => {
|
|
1313
|
+
res.json({ msg: '${safeName} ${m.toUpperCase()} endpoint works' });
|
|
1314
|
+
});
|
|
1315
|
+
`;
|
|
1316
|
+
}
|
|
1317
|
+
});
|
|
1318
|
+
|
|
1319
|
+
if (isMultipart) {
|
|
1320
|
+
// Add multer setup after router declaration
|
|
1321
|
+
handlers = `\nconst upload = multer({ dest: 'uploads/' });\n` + handlers;
|
|
1322
|
+
}
|
|
1323
|
+
} else if (type === 'crud' && modelName) {
|
|
1324
|
+
const crudMethods = methodList.length > 0 ? methodList : ['get', 'post', 'put', 'delete'];
|
|
1325
|
+
|
|
1326
|
+
if (crudMethods.includes('get')) {
|
|
1327
|
+
handlers += `
|
|
1328
|
+
// Get all
|
|
1329
|
+
router.get('/', async (req, res) => {
|
|
1330
|
+
try {
|
|
1331
|
+
const items = await ${pascalModel}.find();
|
|
1332
|
+
res.json(items);
|
|
1333
|
+
} catch (err) {
|
|
1334
|
+
res.status(500).json({ message: err.message });
|
|
1335
|
+
}
|
|
1336
|
+
});
|
|
1337
|
+
|
|
1338
|
+
// Get by ID
|
|
1339
|
+
router.get('/:id', async (req, res) => {
|
|
1340
|
+
try {
|
|
1341
|
+
const item = await ${pascalModel}.findById(req.params.id);
|
|
1342
|
+
if (!item) return res.status(404).json({ message: '${pascalModel} not found' });
|
|
1343
|
+
res.json(item);
|
|
1344
|
+
} catch (err) {
|
|
1345
|
+
res.status(500).json({ message: err.message });
|
|
1346
|
+
}
|
|
1347
|
+
});
|
|
1348
|
+
`;
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
if (crudMethods.includes('post')) {
|
|
1352
|
+
handlers += `
|
|
1353
|
+
// Create
|
|
1354
|
+
router.post('/', async (req, res) => {
|
|
1355
|
+
try {
|
|
1356
|
+
const item = new ${pascalModel}(req.body);
|
|
1357
|
+
const saved = await item.save();
|
|
1358
|
+
res.status(201).json(saved);
|
|
1359
|
+
} catch (err) {
|
|
1360
|
+
res.status(400).json({ message: err.message });
|
|
1361
|
+
}
|
|
1362
|
+
});
|
|
1363
|
+
`;
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
if (crudMethods.includes('put')) {
|
|
1367
|
+
handlers += `
|
|
1368
|
+
// Update
|
|
1369
|
+
router.put('/:id', async (req, res) => {
|
|
1370
|
+
try {
|
|
1371
|
+
const item = await ${pascalModel}.findByIdAndUpdate(req.params.id, req.body, { new: true, runValidators: true });
|
|
1372
|
+
if (!item) return res.status(404).json({ message: '${pascalModel} not found' });
|
|
1373
|
+
res.json(item);
|
|
1374
|
+
} catch (err) {
|
|
1375
|
+
res.status(400).json({ message: err.message });
|
|
1376
|
+
}
|
|
1377
|
+
});
|
|
1378
|
+
`;
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
if (crudMethods.includes('patch')) {
|
|
1382
|
+
handlers += `
|
|
1383
|
+
// Partial Update
|
|
1384
|
+
router.patch('/:id', async (req, res) => {
|
|
1385
|
+
try {
|
|
1386
|
+
const item = await ${pascalModel}.findByIdAndUpdate(req.params.id, req.body, { new: true, runValidators: true });
|
|
1387
|
+
if (!item) return res.status(404).json({ message: '${pascalModel} not found' });
|
|
1388
|
+
res.json(item);
|
|
1389
|
+
} catch (err) {
|
|
1390
|
+
res.status(400).json({ message: err.message });
|
|
1391
|
+
}
|
|
1392
|
+
});
|
|
1393
|
+
`;
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1396
|
+
if (crudMethods.includes('delete')) {
|
|
1397
|
+
handlers += `
|
|
1398
|
+
// Delete
|
|
1399
|
+
router.delete('/:id', async (req, res) => {
|
|
1400
|
+
try {
|
|
1401
|
+
const item = await ${pascalModel}.findByIdAndDelete(req.params.id);
|
|
1402
|
+
if (!item) return res.status(404).json({ message: '${pascalModel} not found' });
|
|
1403
|
+
res.json({ message: '${pascalModel} deleted' });
|
|
1404
|
+
} catch (err) {
|
|
1405
|
+
res.status(500).json({ message: err.message });
|
|
1406
|
+
}
|
|
1407
|
+
});
|
|
1408
|
+
`;
|
|
1409
|
+
}
|
|
1410
|
+
} else if (type === 'register' && modelName) {
|
|
1411
|
+
const regFields = registerFields.length > 0 ? registerFields : [findField];
|
|
1412
|
+
const allDestructured = regFields.join(', ');
|
|
1413
|
+
const pwFieldName = hasPassword ? passwordField : null;
|
|
1414
|
+
const nonPwFields = regFields.filter(f => f !== pwFieldName);
|
|
1415
|
+
|
|
1416
|
+
if (hasPassword) {
|
|
1417
|
+
const assignFields = nonPwFields.map(f => f).join(', ');
|
|
1418
|
+
handlers += `
|
|
1419
|
+
// Register
|
|
1420
|
+
router.post('/', async (req, res) => {
|
|
1421
|
+
try {
|
|
1422
|
+
const { ${allDestructured} } = req.body;
|
|
1423
|
+
const existing = await ${pascalModel}.findOne({ ${findField} });
|
|
1424
|
+
if (existing) return res.status(400).json({ message: '${pascalModel} already exists' });
|
|
1425
|
+
|
|
1426
|
+
const salt = await bcrypt.genSalt(10);
|
|
1427
|
+
const hashedPassword = await bcrypt.hash(${pwFieldName}, salt);
|
|
1428
|
+
const item = new ${pascalModel}({ ${assignFields}, ${pwFieldName}: hashedPassword });
|
|
1429
|
+
const saved = await item.save();
|
|
1430
|
+
|
|
1431
|
+
const token = jwt.sign({ id: saved._id }, process.env.JWT_SECRET, { expiresIn: '7d' });
|
|
1432
|
+
res.status(201).json({ token, ${modelName}: { id: saved._id, ${findField}: saved.${findField} } });
|
|
1433
|
+
} catch (err) {
|
|
1434
|
+
res.status(400).json({ message: err.message });
|
|
1435
|
+
}
|
|
1436
|
+
});
|
|
1437
|
+
`;
|
|
1438
|
+
} else {
|
|
1439
|
+
handlers += `
|
|
1440
|
+
// Register
|
|
1441
|
+
router.post('/', async (req, res) => {
|
|
1442
|
+
try {
|
|
1443
|
+
const { ${allDestructured} } = req.body;
|
|
1444
|
+
const existing = await ${pascalModel}.findOne({ ${findField} });
|
|
1445
|
+
if (existing) return res.status(400).json({ message: '${pascalModel} already exists' });
|
|
1446
|
+
|
|
1447
|
+
const item = new ${pascalModel}({ ${allDestructured} });
|
|
1448
|
+
const saved = await item.save();
|
|
1449
|
+
|
|
1450
|
+
const token = jwt.sign({ id: saved._id }, process.env.JWT_SECRET, { expiresIn: '7d' });
|
|
1451
|
+
res.status(201).json({ token, ${modelName}: { id: saved._id, ${findField}: saved.${findField} } });
|
|
1452
|
+
} catch (err) {
|
|
1453
|
+
res.status(400).json({ message: err.message });
|
|
1454
|
+
}
|
|
1455
|
+
});
|
|
1456
|
+
`;
|
|
1457
|
+
}
|
|
1458
|
+
} else if (type === 'login' && modelName) {
|
|
1459
|
+
if (hasPassword) {
|
|
1460
|
+
handlers += `
|
|
1461
|
+
// Login
|
|
1462
|
+
router.post('/', async (req, res) => {
|
|
1463
|
+
try {
|
|
1464
|
+
const { ${findField}, ${passwordField} } = req.body;
|
|
1465
|
+
const ${modelName} = await ${pascalModel}.findOne({ ${findField} });
|
|
1466
|
+
if (!${modelName}) return res.status(404).json({ message: '${pascalModel} not found' });
|
|
1467
|
+
|
|
1468
|
+
const isMatch = await bcrypt.compare(${passwordField}, ${modelName}.${passwordField});
|
|
1469
|
+
if (!isMatch) return res.status(400).json({ message: 'Invalid credentials' });
|
|
1470
|
+
|
|
1471
|
+
const token = jwt.sign({ id: ${modelName}._id }, process.env.JWT_SECRET, { expiresIn: '7d' });
|
|
1472
|
+
res.json({ token, ${modelName}: { id: ${modelName}._id, ${findField}: ${modelName}.${findField} } });
|
|
1473
|
+
} catch (err) {
|
|
1474
|
+
res.status(500).json({ message: err.message });
|
|
1475
|
+
}
|
|
1476
|
+
});
|
|
1477
|
+
`;
|
|
1478
|
+
} else {
|
|
1479
|
+
handlers += `
|
|
1480
|
+
// Login
|
|
1481
|
+
router.post('/', async (req, res) => {
|
|
1482
|
+
try {
|
|
1483
|
+
const { ${findField} } = req.body;
|
|
1484
|
+
const ${modelName} = await ${pascalModel}.findOne({ ${findField} });
|
|
1485
|
+
if (!${modelName}) return res.status(404).json({ message: '${pascalModel} not found' });
|
|
1486
|
+
|
|
1487
|
+
const token = jwt.sign({ id: ${modelName}._id }, process.env.JWT_SECRET, { expiresIn: '7d' });
|
|
1488
|
+
res.json({ token, ${modelName}: { id: ${modelName}._id, ${findField}: ${modelName}.${findField} } });
|
|
1489
|
+
} catch (err) {
|
|
1490
|
+
res.status(500).json({ message: err.message });
|
|
1491
|
+
}
|
|
1492
|
+
});
|
|
1493
|
+
`;
|
|
1494
|
+
}
|
|
1495
|
+
}
|
|
1496
|
+
|
|
1497
|
+
const content = `${imports}
|
|
1498
|
+
const router = express.Router();
|
|
1499
|
+
${handlers}
|
|
1500
|
+
export default router;
|
|
1501
|
+
`;
|
|
1502
|
+
|
|
1503
|
+
fs.writeFileSync(routeFilePath, content, 'utf8');
|
|
1504
|
+
|
|
1505
|
+
const label = type === 'methods' ? methodList.map(m => m.toUpperCase()).join(', ') : type === 'crud' ? 'CRUD (' + (methodList.length > 0 ? methodList.map(m => m.toUpperCase()).join(', ') : 'ALL') + ')' : type === 'plain' ? 'PLAIN' : type.toUpperCase();
|
|
1506
|
+
|
|
1507
|
+
// Ensure JWT_SECRET exists in env files for login/register routes
|
|
1508
|
+
if (type === 'login' || type === 'register') {
|
|
1509
|
+
const devEnv = path.join(process.cwd(), '.env.development');
|
|
1510
|
+
const prodEnv = path.join(process.cwd(), '.env.production');
|
|
1511
|
+
const devContent = fs.existsSync(devEnv) ? fs.readFileSync(devEnv, 'utf8') : '';
|
|
1512
|
+
const prodContent = fs.existsSync(prodEnv) ? fs.readFileSync(prodEnv, 'utf8') : '';
|
|
1513
|
+
if (!devContent.includes('JWT_SECRET')) {
|
|
1514
|
+
updateEnvFile(devEnv, 'JWT_SECRET', crypto.randomBytes(32).toString('hex'));
|
|
1515
|
+
}
|
|
1516
|
+
if (!prodContent.includes('JWT_SECRET')) {
|
|
1517
|
+
updateEnvFile(prodEnv, 'JWT_SECRET', crypto.randomBytes(32).toString('hex'));
|
|
1518
|
+
}
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1521
|
+
// Send response BEFORE modifying index.js (nodemon restart kills connection)
|
|
1522
|
+
res.json({
|
|
1523
|
+
message: `Route "${safeName}" created with ${label} endpoints.`,
|
|
1524
|
+
file: `src/routes/${safeName}.js`
|
|
1525
|
+
});
|
|
1526
|
+
|
|
1527
|
+
// Build clean path: ensure prefix ends with / and no double slashes
|
|
1528
|
+
let prefix = pathPrefix || '/api/';
|
|
1529
|
+
if (!prefix.endsWith('/')) prefix += '/';
|
|
1530
|
+
const fullPath = (prefix + safeName).replace(/\/+/g, '/');
|
|
1531
|
+
|
|
1532
|
+
// Update index.js (triggers nodemon restart)
|
|
1533
|
+
const importLine = `import ${camelName}Router from './src/routes/${safeName}.js';`;
|
|
1534
|
+
addImportToIndex(importLine);
|
|
1535
|
+
|
|
1536
|
+
const routeRegistration = `app.use('${fullPath}', ${camelName}Router);`;
|
|
1537
|
+
let indexContent = readIndexFile();
|
|
1538
|
+
if (!indexContent.includes(routeRegistration)) {
|
|
1539
|
+
insertBeforeListen(routeRegistration);
|
|
1540
|
+
}
|
|
1541
|
+
} catch (err) {
|
|
1542
|
+
if (!res.headersSent) res.status(500).json({ error: err.message });
|
|
1543
|
+
}
|
|
1544
|
+
});
|
|
1545
|
+
|
|
1546
|
+
// ---------------------------------------------------------------------------
|
|
1547
|
+
// POST /api/add-mongo
|
|
1548
|
+
// ---------------------------------------------------------------------------
|
|
1549
|
+
router.post('/api/add-mongo', async (req, res) => {
|
|
1550
|
+
try {
|
|
1551
|
+
const uri = (req.body.uri || '').trim();
|
|
1552
|
+
const prodUri = (req.body.prodUri || '').trim();
|
|
1553
|
+
const alreadyConfigured = checkMongoConfig();
|
|
1554
|
+
|
|
1555
|
+
if (!uri) {
|
|
1556
|
+
return res.status(400).json({ error: 'MongoDB URI is required' });
|
|
1557
|
+
}
|
|
1558
|
+
|
|
1559
|
+
// Update env files
|
|
1560
|
+
const devEnv = path.join(process.cwd(), '.env.development');
|
|
1561
|
+
const prodEnv = path.join(process.cwd(), '.env.production');
|
|
1562
|
+
updateEnvFile(devEnv, 'MONGO_URI', uri);
|
|
1563
|
+
updateEnvFile(prodEnv, 'MONGO_URI', prodUri || uri);
|
|
1564
|
+
|
|
1565
|
+
if (alreadyConfigured) {
|
|
1566
|
+
// Edit mode — just update env files, no need to reinstall or modify index.js
|
|
1567
|
+
res.json({
|
|
1568
|
+
message: 'MongoDB connection updated. Restart the server to apply changes.',
|
|
1569
|
+
files: ['.env.development', '.env.production']
|
|
1570
|
+
});
|
|
1571
|
+
return;
|
|
1572
|
+
}
|
|
1573
|
+
|
|
1574
|
+
// First-time setup: install mongoose, create db.js, modify index.js
|
|
1575
|
+
await execAsync('npm install mongoose', { cwd: process.cwd() });
|
|
1576
|
+
|
|
1577
|
+
const dbDir = path.join(process.cwd(), 'src');
|
|
1578
|
+
if (!fs.existsSync(dbDir)) {
|
|
1579
|
+
fs.mkdirSync(dbDir, { recursive: true });
|
|
1580
|
+
}
|
|
1581
|
+
|
|
1582
|
+
const dbContent = `import mongoose from 'mongoose';
|
|
1583
|
+
|
|
1584
|
+
const connectDB = async () => {
|
|
1585
|
+
try {
|
|
1586
|
+
const conn = await mongoose.connect(process.env.MONGO_URI);
|
|
1587
|
+
console.log(\`MongoDB connected: \${conn.connection.host}\`);
|
|
1588
|
+
} catch (err) {
|
|
1589
|
+
console.error('MongoDB connection error:', err.message);
|
|
1590
|
+
console.error('Check your MONGO_URI in .env.development and ensure MongoDB is running.');
|
|
1591
|
+
}
|
|
1592
|
+
};
|
|
1593
|
+
|
|
1594
|
+
export default connectDB;
|
|
1595
|
+
`;
|
|
1596
|
+
fs.writeFileSync(path.join(dbDir, 'db.js'), dbContent, 'utf8');
|
|
1597
|
+
|
|
1598
|
+
// Send response BEFORE modifying index.js (nodemon restart kills connection)
|
|
1599
|
+
res.json({
|
|
1600
|
+
message: 'MongoDB setup complete.',
|
|
1601
|
+
files: ['src/db.js', '.env.development', '.env.production', 'index.js']
|
|
1602
|
+
});
|
|
1603
|
+
|
|
1604
|
+
// Modify index.js LAST (triggers nodemon restart)
|
|
1605
|
+
addImportToIndex("import connectDB from './src/db.js';");
|
|
1606
|
+
let indexContent = readIndexFile();
|
|
1607
|
+
if (!indexContent.includes('connectDB()')) {
|
|
1608
|
+
const appExpress = "const app = express();";
|
|
1609
|
+
const idx = indexContent.indexOf(appExpress);
|
|
1610
|
+
if (idx !== -1) {
|
|
1611
|
+
indexContent = indexContent.slice(0, idx) + 'connectDB();\n' + indexContent.slice(idx);
|
|
1612
|
+
writeIndexFile(indexContent);
|
|
1613
|
+
}
|
|
1614
|
+
}
|
|
1615
|
+
} catch (err) {
|
|
1616
|
+
if (!res.headersSent) res.status(500).json({ error: err.message });
|
|
1617
|
+
}
|
|
1618
|
+
});
|
|
1619
|
+
|
|
1620
|
+
// ---------------------------------------------------------------------------
|
|
1621
|
+
// POST /api/add-login
|
|
1622
|
+
// ---------------------------------------------------------------------------
|
|
1623
|
+
router.post('/api/add-login', async (req, res) => {
|
|
1624
|
+
try {
|
|
1625
|
+
const { routeFile, modelName, fields, tokenExpiry } = req.body;
|
|
1626
|
+
const pascalModel = toPascalCase(modelName);
|
|
1627
|
+
const hasPassword = fields.includes('password');
|
|
1628
|
+
|
|
1629
|
+
// Install packages FIRST before modifying files
|
|
1630
|
+
const packages = hasPassword ? 'jsonwebtoken bcrypt' : 'jsonwebtoken';
|
|
1631
|
+
await execAsync(`npm install ${packages}`, { cwd: process.cwd() });
|
|
1632
|
+
|
|
1633
|
+
// Create src/middleware/auth.js if it doesn't exist
|
|
1634
|
+
const middlewareDir = path.join(process.cwd(), 'src', 'middleware');
|
|
1635
|
+
const authFilePath = path.join(middlewareDir, 'auth.js');
|
|
1636
|
+
if (!fs.existsSync(authFilePath)) {
|
|
1637
|
+
if (!fs.existsSync(middlewareDir)) {
|
|
1638
|
+
fs.mkdirSync(middlewareDir, { recursive: true });
|
|
1639
|
+
}
|
|
1640
|
+
const authContent = `import jwt from 'jsonwebtoken';
|
|
1641
|
+
|
|
1642
|
+
const auth = (req, res, next) => {
|
|
1643
|
+
const token = req.header('Authorization')?.replace('Bearer ', '');
|
|
1644
|
+
if (!token) {
|
|
1645
|
+
return res.status(401).json({ message: 'No token, authorization denied' });
|
|
1646
|
+
}
|
|
1647
|
+
try {
|
|
1648
|
+
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
|
1649
|
+
req.user = decoded;
|
|
1650
|
+
next();
|
|
1651
|
+
} catch (err) {
|
|
1652
|
+
res.status(401).json({ message: 'Token is not valid' });
|
|
1653
|
+
}
|
|
1654
|
+
};
|
|
1655
|
+
|
|
1656
|
+
export default auth;
|
|
1657
|
+
`;
|
|
1658
|
+
fs.writeFileSync(authFilePath, authContent, 'utf8');
|
|
1659
|
+
}
|
|
1660
|
+
|
|
1661
|
+
// Build login endpoint code
|
|
1662
|
+
const fieldsDestructure = fields.join(', ');
|
|
1663
|
+
|
|
1664
|
+
let passwordCheck = '';
|
|
1665
|
+
if (hasPassword) {
|
|
1666
|
+
passwordCheck = `
|
|
1667
|
+
const isMatch = await bcrypt.compare(password, user.password);
|
|
1668
|
+
if (!isMatch) {
|
|
1669
|
+
return res.status(400).json({ message: 'Invalid credentials' });
|
|
1670
|
+
}`;
|
|
1671
|
+
}
|
|
1672
|
+
|
|
1673
|
+
let tokenCode = '';
|
|
1674
|
+
if (tokenExpiry) {
|
|
1675
|
+
tokenCode = ` const token = jwt.sign(payload, process.env.JWT_SECRET, { expiresIn: '15m' });
|
|
1676
|
+
const refreshToken = jwt.sign(payload, process.env.JWT_SECRET, { expiresIn: '7d' });
|
|
1677
|
+
res.json({ token, refreshToken });`;
|
|
1678
|
+
} else {
|
|
1679
|
+
tokenCode = ` const token = jwt.sign(payload, process.env.JWT_SECRET);
|
|
1680
|
+
res.json({ token });`;
|
|
1681
|
+
}
|
|
1682
|
+
|
|
1683
|
+
const loginCode = `
|
|
1684
|
+
router.post('/login', async (req, res) => {
|
|
1685
|
+
const { ${fieldsDestructure} } = req.body;
|
|
1686
|
+
try {
|
|
1687
|
+
const user = await ${pascalModel}.findOne({ ${fields[0]}: ${fields[0]} });
|
|
1688
|
+
if (!user) {
|
|
1689
|
+
return res.status(404).json({ message: 'User not found' });
|
|
1690
|
+
}${passwordCheck}
|
|
1691
|
+
const payload = { id: user._id };
|
|
1692
|
+
${tokenCode}
|
|
1693
|
+
} catch (err) {
|
|
1694
|
+
console.error(err);
|
|
1695
|
+
res.status(500).json({ message: 'Server error' });
|
|
1696
|
+
}
|
|
1697
|
+
});
|
|
1698
|
+
`;
|
|
1699
|
+
|
|
1700
|
+
// Add login endpoint to the selected route file
|
|
1701
|
+
const routeFilePath = path.join(process.cwd(), 'src', 'routes', routeFile);
|
|
1702
|
+
if (!fs.existsSync(routeFilePath)) {
|
|
1703
|
+
return res.status(404).json({ error: `Route file "${routeFile}" not found.` });
|
|
1704
|
+
}
|
|
1705
|
+
|
|
1706
|
+
// Add imports to route file
|
|
1707
|
+
addImportToRoute(routeFilePath, "import jwt from 'jsonwebtoken';");
|
|
1708
|
+
if (hasPassword) {
|
|
1709
|
+
addImportToRoute(routeFilePath, "import bcrypt from 'bcrypt';");
|
|
1710
|
+
}
|
|
1711
|
+
addImportToRoute(routeFilePath, `import ${pascalModel} from '../models/${modelName}.js';`);
|
|
1712
|
+
|
|
1713
|
+
// Insert login code
|
|
1714
|
+
insertCodeIntoRoute(routeFilePath, loginCode);
|
|
1715
|
+
|
|
1716
|
+
// If tokenExpiry, create refresh-token route
|
|
1717
|
+
if (tokenExpiry) {
|
|
1718
|
+
const refreshRouteDir = path.join(process.cwd(), 'src', 'routes');
|
|
1719
|
+
const refreshFilePath = path.join(refreshRouteDir, 'refresh-token.js');
|
|
1720
|
+
if (!fs.existsSync(refreshFilePath)) {
|
|
1721
|
+
const refreshContent = `import express from 'express';
|
|
1722
|
+
import jwt from 'jsonwebtoken';
|
|
1723
|
+
|
|
1724
|
+
const router = express.Router();
|
|
1725
|
+
|
|
1726
|
+
router.post('/', async (req, res) => {
|
|
1727
|
+
const { refreshToken } = req.body;
|
|
1728
|
+
if (!refreshToken) {
|
|
1729
|
+
return res.status(401).json({ message: 'Refresh token required' });
|
|
1730
|
+
}
|
|
1731
|
+
try {
|
|
1732
|
+
const decoded = jwt.verify(refreshToken, process.env.JWT_SECRET);
|
|
1733
|
+
const payload = { id: decoded.id };
|
|
1734
|
+
const token = jwt.sign(payload, process.env.JWT_SECRET, { expiresIn: '15m' });
|
|
1735
|
+
res.json({ token });
|
|
1736
|
+
} catch (err) {
|
|
1737
|
+
res.status(401).json({ message: 'Invalid refresh token' });
|
|
1738
|
+
}
|
|
1739
|
+
});
|
|
1740
|
+
|
|
1741
|
+
export default router;
|
|
1742
|
+
`;
|
|
1743
|
+
fs.writeFileSync(refreshFilePath, refreshContent, 'utf8');
|
|
1744
|
+
|
|
1745
|
+
// Register in index.js
|
|
1746
|
+
addImportToIndex("import refreshTokenRouter from './src/routes/refresh-token.js';");
|
|
1747
|
+
const routeReg = "app.use('/api/refresh-token', refreshTokenRouter);";
|
|
1748
|
+
let idxContent = readIndexFile();
|
|
1749
|
+
if (!idxContent.includes(routeReg)) {
|
|
1750
|
+
insertBeforeListen(routeReg);
|
|
1751
|
+
}
|
|
1752
|
+
}
|
|
1753
|
+
}
|
|
1754
|
+
|
|
1755
|
+
// Generate JWT secrets and update env files
|
|
1756
|
+
const devSecret = crypto.randomBytes(32).toString('hex');
|
|
1757
|
+
const prodSecret = crypto.randomBytes(32).toString('hex');
|
|
1758
|
+
const devEnv = path.join(process.cwd(), '.env.development');
|
|
1759
|
+
const prodEnv = path.join(process.cwd(), '.env.production');
|
|
1760
|
+
updateEnvFile(devEnv, 'JWT_SECRET', devSecret);
|
|
1761
|
+
updateEnvFile(prodEnv, 'JWT_SECRET', prodSecret);
|
|
1762
|
+
|
|
1763
|
+
const filesModified = ['src/middleware/auth.js', `src/routes/${routeFile}`, '.env.development', '.env.production'];
|
|
1764
|
+
if (tokenExpiry) filesModified.push('src/routes/refresh-token.js');
|
|
1765
|
+
|
|
1766
|
+
// Send response BEFORE index.js modifications (nodemon restart kills connection)
|
|
1767
|
+
res.json({
|
|
1768
|
+
message: 'JWT authentication setup complete.',
|
|
1769
|
+
files: filesModified
|
|
1770
|
+
});
|
|
1771
|
+
} catch (err) {
|
|
1772
|
+
if (!res.headersSent) res.status(500).json({ error: err.message });
|
|
1773
|
+
}
|
|
1774
|
+
});
|
|
1775
|
+
|
|
1776
|
+
// ---------------------------------------------------------------------------
|
|
1777
|
+
// POST /api/add-socket — create src/services/socket.js
|
|
1778
|
+
// ---------------------------------------------------------------------------
|
|
1779
|
+
router.post('/api/add-socket', async (req, res) => {
|
|
1780
|
+
try {
|
|
1781
|
+
const { corsOrigins } = req.body;
|
|
1782
|
+
|
|
1783
|
+
// Install packages first
|
|
1784
|
+
const currentContent = readIndexFile();
|
|
1785
|
+
const hasWebSocket = currentContent.includes('setupWebSocket') || currentContent.includes('WebSocketServer');
|
|
1786
|
+
|
|
1787
|
+
if (hasWebSocket) {
|
|
1788
|
+
try { await execAsync('npm uninstall ws && npm install socket.io', { cwd: process.cwd() }); } catch {
|
|
1789
|
+
try { await execAsync('npm install socket.io', { cwd: process.cwd() }); } catch {}
|
|
1790
|
+
}
|
|
1791
|
+
} else {
|
|
1792
|
+
await execAsync('npm install socket.io', { cwd: process.cwd() });
|
|
1793
|
+
}
|
|
1794
|
+
|
|
1795
|
+
// Send response before modifying files
|
|
1796
|
+
res.json({ message: 'Socket.IO setup complete.', files: ['src/services/socket.js', 'index.js'] });
|
|
1797
|
+
|
|
1798
|
+
// Build cors origin value
|
|
1799
|
+
let originValue;
|
|
1800
|
+
if (corsOrigins === '*') originValue = '"*"';
|
|
1801
|
+
else originValue = JSON.stringify(corsOrigins.split(',').map(s => s.trim()));
|
|
1802
|
+
|
|
1803
|
+
// Create src/services/socket.js
|
|
1804
|
+
const servicesDir = path.join(process.cwd(), 'src', 'services');
|
|
1805
|
+
if (!fs.existsSync(servicesDir)) fs.mkdirSync(servicesDir, { recursive: true });
|
|
1806
|
+
|
|
1807
|
+
const socketFile = `import { Server } from "socket.io";
|
|
1808
|
+
import http from "http";
|
|
1809
|
+
|
|
1810
|
+
export function setupSocketIO(app) {
|
|
1811
|
+
const server = http.createServer(app);
|
|
1812
|
+
|
|
1813
|
+
const io = new Server(server, {
|
|
1814
|
+
cors: {
|
|
1815
|
+
origin: ${originValue},
|
|
1816
|
+
methods: ["GET", "POST"],
|
|
1817
|
+
},
|
|
1818
|
+
});
|
|
1819
|
+
|
|
1820
|
+
io.on("connection", (socket) => {
|
|
1821
|
+
console.log("A user connected");
|
|
1822
|
+
socket.on("disconnect", () => {
|
|
1823
|
+
console.log("User disconnected");
|
|
1824
|
+
});
|
|
1825
|
+
});
|
|
1826
|
+
|
|
1827
|
+
// Make io accessible in routes via req.io
|
|
1828
|
+
app.use((req, res, next) => {
|
|
1829
|
+
req.io = io;
|
|
1830
|
+
next();
|
|
1831
|
+
});
|
|
1832
|
+
|
|
1833
|
+
return { server, io };
|
|
1834
|
+
}
|
|
1835
|
+
`;
|
|
1836
|
+
|
|
1837
|
+
fs.writeFileSync(path.join(servicesDir, 'socket.js'), socketFile, 'utf8');
|
|
1838
|
+
|
|
1839
|
+
// Remove old WebSocket service file if present
|
|
1840
|
+
const wsFile = path.join(servicesDir, 'websocket.js');
|
|
1841
|
+
if (fs.existsSync(wsFile)) fs.unlinkSync(wsFile);
|
|
1842
|
+
|
|
1843
|
+
// Update index.js — clean approach
|
|
1844
|
+
let content = readIndexFile();
|
|
1845
|
+
|
|
1846
|
+
// Remove old inline socket.io/websocket code
|
|
1847
|
+
content = content.replace(/import\s*\{\s*Server\s*\}\s*from\s*["']socket\.io["'];\s*\n?/g, '');
|
|
1848
|
+
content = content.replace(/import\s*\{\s*WebSocketServer\s*\}\s*from\s*['"]ws['"];\s*\n?/g, '');
|
|
1849
|
+
content = content.replace(/import\s+http\s+from\s*["']http["'];\s*\n?/g, '');
|
|
1850
|
+
content = content.replace(/const\s+server\s*=\s*http\.createServer\(app\);\s*\n?/g, '');
|
|
1851
|
+
content = content.replace(/const\s+io\s*=\s*new\s+Server\(server[\s\S]*?\n\}\);\s*\n?/g, '');
|
|
1852
|
+
content = content.replace(/io\.on\(["']connection["'][\s\S]*?\n\}\);\s*\n?/g, '');
|
|
1853
|
+
content = content.replace(/app\.use\(\(req,\s*res,\s*next\)\s*=>\s*\{\s*\n?\s*req\.io\s*=\s*io;\s*\n?\s*next\(\);\s*\n?\s*\}\);\s*\n?/g, '');
|
|
1854
|
+
content = content.replace(/const\s+wss\s*=\s*new\s+WebSocketServer\(\{[\s\S]*?\n?\}\);\s*\n?/g, '');
|
|
1855
|
+
content = content.replace(/wss\.on\(['"]connection['"][\s\S]*?\n\}\);\s*\n?/g, '');
|
|
1856
|
+
content = content.replace(/console\.log\(['"]WebSocket server running on port.*?['"]?\);\s*\n?/g, '');
|
|
1857
|
+
// Remove old import lines for services
|
|
1858
|
+
content = content.replace(/import\s*\{\s*setupSocketIO\s*\}\s*from\s*['"]\.\/src\/services\/socket\.js['"];\s*\n?/g, '');
|
|
1859
|
+
content = content.replace(/import\s*\{\s*setupWebSocket\s*\}\s*from\s*['"]\.\/src\/services\/websocket\.js['"];\s*\n?/g, '');
|
|
1860
|
+
content = content.replace(/const\s*\{\s*server[^}]*\}\s*=\s*setupSocketIO\(app\);\s*\n?/g, '');
|
|
1861
|
+
content = content.replace(/const\s*\{\s*server[^}]*\}\s*=\s*setupWebSocket\(app\);\s*\n?/g, '');
|
|
1862
|
+
content = content.replace(/setupWebSocket\(app[^)]*\);\s*\n?/g, '');
|
|
1863
|
+
|
|
1864
|
+
// Add import for socket service
|
|
1865
|
+
const lastImport = content.lastIndexOf('import ');
|
|
1866
|
+
const lineEnd = content.indexOf('\n', lastImport);
|
|
1867
|
+
content = content.slice(0, lineEnd + 1) + 'import { setupSocketIO } from "./src/services/socket.js";\n' + content.slice(lineEnd + 1);
|
|
1868
|
+
|
|
1869
|
+
// Add setup call before listen
|
|
1870
|
+
const listenIdx = content.search(/\/\/ ── Start Server|async function startServer|function startServer/);
|
|
1871
|
+
if (listenIdx !== -1) {
|
|
1872
|
+
content = content.slice(0, listenIdx) + 'const { server } = setupSocketIO(app);\n\n' + content.slice(listenIdx);
|
|
1873
|
+
}
|
|
1874
|
+
|
|
1875
|
+
// Replace app.listen with server.listen in startServer
|
|
1876
|
+
content = content.replace('const listener = app.listen(tryPort)', 'const listener = server.listen(tryPort)');
|
|
1877
|
+
// Also handle direct app.listen
|
|
1878
|
+
if (content.includes('app.listen(port') && !content.includes('startServer')) {
|
|
1879
|
+
content = content.replace(/app\.listen\(port[\s\S]*?\}\);?\n?/m, 'server.listen(port, () => {\n console.log(`Server is running on port ${port}`);\n});\n');
|
|
1880
|
+
}
|
|
1881
|
+
|
|
1882
|
+
// Revert server.listen back to app.listen for old-style
|
|
1883
|
+
content = content.replace('server.listen(port', 'server.listen(port');
|
|
1884
|
+
|
|
1885
|
+
content = content.replace(/\n{3,}/g, '\n\n');
|
|
1886
|
+
writeIndexFile(content);
|
|
1887
|
+
} catch (err) {
|
|
1888
|
+
if (!res.headersSent) res.status(500).json({ error: err.message });
|
|
1889
|
+
}
|
|
1890
|
+
});
|
|
1891
|
+
|
|
1892
|
+
// ---------------------------------------------------------------------------
|
|
1893
|
+
// POST /api/add-websocket — create src/services/websocket.js
|
|
1894
|
+
// ---------------------------------------------------------------------------
|
|
1895
|
+
router.post('/api/add-websocket', async (req, res) => {
|
|
1896
|
+
try {
|
|
1897
|
+
const { samePort, wsPort } = req.body;
|
|
1898
|
+
|
|
1899
|
+
// Install packages first
|
|
1900
|
+
const currentContent = readIndexFile();
|
|
1901
|
+
const hasSocketIO = currentContent.includes('setupSocketIO') || currentContent.includes('socket.io');
|
|
1902
|
+
|
|
1903
|
+
if (hasSocketIO) {
|
|
1904
|
+
try { await execAsync('npm uninstall socket.io && npm install ws', { cwd: process.cwd() }); } catch {
|
|
1905
|
+
try { await execAsync('npm install ws', { cwd: process.cwd() }); } catch {}
|
|
1906
|
+
}
|
|
1907
|
+
} else {
|
|
1908
|
+
await execAsync('npm install ws', { cwd: process.cwd() });
|
|
1909
|
+
}
|
|
1910
|
+
|
|
1911
|
+
// Send response before modifying files
|
|
1912
|
+
res.json({ message: 'WebSocket setup complete.', files: ['src/services/websocket.js', 'index.js'] });
|
|
1913
|
+
|
|
1914
|
+
// Create src/services/websocket.js
|
|
1915
|
+
const servicesDir = path.join(process.cwd(), 'src', 'services');
|
|
1916
|
+
if (!fs.existsSync(servicesDir)) fs.mkdirSync(servicesDir, { recursive: true });
|
|
1917
|
+
|
|
1918
|
+
let wsFileContent;
|
|
1919
|
+
if (samePort) {
|
|
1920
|
+
wsFileContent = `import { WebSocketServer } from "ws";
|
|
1921
|
+
import http from "http";
|
|
1922
|
+
|
|
1923
|
+
export function setupWebSocket(app) {
|
|
1924
|
+
const server = http.createServer(app);
|
|
1925
|
+
const wss = new WebSocketServer({ server });
|
|
1926
|
+
|
|
1927
|
+
wss.on("connection", (ws) => {
|
|
1928
|
+
console.log("WebSocket client connected");
|
|
1929
|
+
ws.on("message", (message) => {
|
|
1930
|
+
console.log("Received:", message.toString());
|
|
1931
|
+
});
|
|
1932
|
+
ws.on("close", () => {
|
|
1933
|
+
console.log("WebSocket client disconnected");
|
|
1934
|
+
});
|
|
1935
|
+
});
|
|
1936
|
+
|
|
1937
|
+
return { server, wss };
|
|
1938
|
+
}
|
|
1939
|
+
`;
|
|
1940
|
+
} else {
|
|
1941
|
+
const p = wsPort || 8080;
|
|
1942
|
+
wsFileContent = `import { WebSocketServer } from "ws";
|
|
1943
|
+
|
|
1944
|
+
export function setupWebSocket() {
|
|
1945
|
+
const wss = new WebSocketServer({ port: ${p} });
|
|
1946
|
+
|
|
1947
|
+
wss.on("connection", (ws) => {
|
|
1948
|
+
console.log("WebSocket client connected");
|
|
1949
|
+
ws.on("message", (message) => {
|
|
1950
|
+
console.log("Received:", message.toString());
|
|
1951
|
+
});
|
|
1952
|
+
ws.on("close", () => {
|
|
1953
|
+
console.log("WebSocket client disconnected");
|
|
1954
|
+
});
|
|
1955
|
+
});
|
|
1956
|
+
|
|
1957
|
+
console.log("WebSocket server running on port ${p}");
|
|
1958
|
+
return { wss };
|
|
1959
|
+
}
|
|
1960
|
+
`;
|
|
1961
|
+
}
|
|
1962
|
+
|
|
1963
|
+
fs.writeFileSync(path.join(servicesDir, 'websocket.js'), wsFileContent, 'utf8');
|
|
1964
|
+
|
|
1965
|
+
// Remove old Socket.IO service file if present
|
|
1966
|
+
const sioFile = path.join(servicesDir, 'socket.js');
|
|
1967
|
+
if (fs.existsSync(sioFile)) fs.unlinkSync(sioFile);
|
|
1968
|
+
|
|
1969
|
+
// Update index.js — clean approach
|
|
1970
|
+
let content = readIndexFile();
|
|
1971
|
+
|
|
1972
|
+
// Remove old inline socket.io/websocket code
|
|
1973
|
+
content = content.replace(/import\s*\{\s*Server\s*\}\s*from\s*["']socket\.io["'];\s*\n?/g, '');
|
|
1974
|
+
content = content.replace(/import\s*\{\s*WebSocketServer\s*\}\s*from\s*['"]ws['"];\s*\n?/g, '');
|
|
1975
|
+
content = content.replace(/import\s+http\s+from\s*["']http["'];\s*\n?/g, '');
|
|
1976
|
+
content = content.replace(/const\s+server\s*=\s*http\.createServer\(app\);\s*\n?/g, '');
|
|
1977
|
+
content = content.replace(/const\s+io\s*=\s*new\s+Server\(server[\s\S]*?\n\}\);\s*\n?/g, '');
|
|
1978
|
+
content = content.replace(/io\.on\(["']connection["'][\s\S]*?\n\}\);\s*\n?/g, '');
|
|
1979
|
+
content = content.replace(/app\.use\(\(req,\s*res,\s*next\)\s*=>\s*\{\s*\n?\s*req\.io\s*=\s*io;\s*\n?\s*next\(\);\s*\n?\s*\}\);\s*\n?/g, '');
|
|
1980
|
+
content = content.replace(/const\s+wss\s*=\s*new\s+WebSocketServer\(\{[\s\S]*?\n?\}\);\s*\n?/g, '');
|
|
1981
|
+
content = content.replace(/wss\.on\(['"]connection['"][\s\S]*?\n\}\);\s*\n?/g, '');
|
|
1982
|
+
content = content.replace(/console\.log\(['"]WebSocket server running on port.*?['"]?\);\s*\n?/g, '');
|
|
1983
|
+
// Remove old service imports
|
|
1984
|
+
content = content.replace(/import\s*\{\s*setupSocketIO\s*\}\s*from\s*['"]\.\/src\/services\/socket\.js['"];\s*\n?/g, '');
|
|
1985
|
+
content = content.replace(/import\s*\{\s*setupWebSocket\s*\}\s*from\s*['"]\.\/src\/services\/websocket\.js['"];\s*\n?/g, '');
|
|
1986
|
+
content = content.replace(/const\s*\{\s*server[^}]*\}\s*=\s*setupSocketIO\(app\);\s*\n?/g, '');
|
|
1987
|
+
content = content.replace(/const\s*\{\s*server[^}]*\}\s*=\s*setupWebSocket\(app\);\s*\n?/g, '');
|
|
1988
|
+
content = content.replace(/setupWebSocket\(app[^)]*\);\s*\n?/g, '');
|
|
1989
|
+
content = content.replace(/setupWebSocket\(\);\s*\n?/g, '');
|
|
1990
|
+
|
|
1991
|
+
// Add import for websocket service
|
|
1992
|
+
const lastImport = content.lastIndexOf('import ');
|
|
1993
|
+
const lineEnd = content.indexOf('\n', lastImport);
|
|
1994
|
+
content = content.slice(0, lineEnd + 1) + 'import { setupWebSocket } from "./src/services/websocket.js";\n' + content.slice(lineEnd + 1);
|
|
1995
|
+
|
|
1996
|
+
// Add setup call before listen
|
|
1997
|
+
const listenIdx = content.search(/\/\/ ── Start Server|async function startServer|function startServer/);
|
|
1998
|
+
if (listenIdx !== -1) {
|
|
1999
|
+
if (samePort) {
|
|
2000
|
+
content = content.slice(0, listenIdx) + 'const { server } = setupWebSocket(app);\n\n' + content.slice(listenIdx);
|
|
2001
|
+
// Replace app.listen with server.listen
|
|
2002
|
+
content = content.replace('const listener = app.listen(tryPort)', 'const listener = server.listen(tryPort)');
|
|
2003
|
+
if (content.includes('app.listen(port') && !content.includes('startServer')) {
|
|
2004
|
+
content = content.replace(/app\.listen\(port[\s\S]*?\}\);?\n?/m, 'server.listen(port, () => {\n console.log(`Server is running on port ${port}`);\n});\n');
|
|
2005
|
+
}
|
|
2006
|
+
} else {
|
|
2007
|
+
content = content.slice(0, listenIdx) + 'setupWebSocket();\n\n' + content.slice(listenIdx);
|
|
2008
|
+
}
|
|
2009
|
+
}
|
|
2010
|
+
|
|
2011
|
+
content = content.replace(/\n{3,}/g, '\n\n');
|
|
2012
|
+
writeIndexFile(content);
|
|
2013
|
+
} catch (err) {
|
|
2014
|
+
if (!res.headersSent) res.status(500).json({ error: err.message });
|
|
2015
|
+
}
|
|
2016
|
+
});
|
|
2017
|
+
|
|
2018
|
+
// ---------------------------------------------------------------------------
|
|
2019
|
+
// GET /api/serialports — list configured serial ports from index.js
|
|
2020
|
+
// ---------------------------------------------------------------------------
|
|
2021
|
+
router.get('/api/serialports', (req, res) => {
|
|
2022
|
+
try {
|
|
2023
|
+
const indexPath = path.join(process.cwd(), 'index.js');
|
|
2024
|
+
if (!fs.existsSync(indexPath)) return res.json([]);
|
|
2025
|
+
const content = fs.readFileSync(indexPath, 'utf8');
|
|
2026
|
+
const ports = [];
|
|
2027
|
+
// Match: const varName = new SerialPort({ path: '...', baudRate: 9600 })
|
|
2028
|
+
const regex = /const\s+(\w+)\s*=\s*new\s+SerialPort\(\{\s*\n?\s*path:\s*(?:process\.env\.\w+\s*\|\|\s*)?['"]([^'"]+)['"]\s*,\s*\n?\s*baudRate:\s*(?:parseInt\(process\.env\.\w+\)\s*\|\|\s*)?(\d+)/g;
|
|
2029
|
+
let m;
|
|
2030
|
+
while ((m = regex.exec(content)) !== null) {
|
|
2031
|
+
ports.push({ name: m[1], path: m[2], baudRate: parseInt(m[3]) });
|
|
2032
|
+
}
|
|
2033
|
+
res.json(ports);
|
|
2034
|
+
} catch (err) {
|
|
2035
|
+
res.status(500).json({ error: err.message });
|
|
2036
|
+
}
|
|
2037
|
+
});
|
|
2038
|
+
|
|
2039
|
+
// ---------------------------------------------------------------------------
|
|
2040
|
+
// POST /api/add-serialport — add a named serial port connection
|
|
2041
|
+
// ---------------------------------------------------------------------------
|
|
2042
|
+
router.post('/api/add-serialport', async (req, res) => {
|
|
2043
|
+
try {
|
|
2044
|
+
const name = (req.body.name || 'serialPort').replace(/[^a-zA-Z0-9_]/g, '');
|
|
2045
|
+
const portPath = req.body.portPath || '/dev/ttyUSB0';
|
|
2046
|
+
const baud = req.body.baudRate || 9600;
|
|
2047
|
+
const parserName = name + 'Parser';
|
|
2048
|
+
const envPathKey = name.toUpperCase() + '_PATH';
|
|
2049
|
+
const envBaudKey = name.toUpperCase() + '_BAUD';
|
|
2050
|
+
|
|
2051
|
+
// Install FIRST before modifying index.js
|
|
2052
|
+
const indexContent = readIndexFile();
|
|
2053
|
+
if (!indexContent.includes("from 'serialport'")) {
|
|
2054
|
+
await execAsync('npm install serialport @serialport/parser-readline', { cwd: process.cwd() });
|
|
2055
|
+
}
|
|
2056
|
+
|
|
2057
|
+
// Update env files
|
|
2058
|
+
const devEnv = path.join(process.cwd(), '.env.development');
|
|
2059
|
+
const prodEnv = path.join(process.cwd(), '.env.production');
|
|
2060
|
+
updateEnvFile(devEnv, envPathKey, portPath);
|
|
2061
|
+
updateEnvFile(devEnv, envBaudKey, String(baud));
|
|
2062
|
+
updateEnvFile(prodEnv, envPathKey, portPath);
|
|
2063
|
+
updateEnvFile(prodEnv, envBaudKey, String(baud));
|
|
2064
|
+
|
|
2065
|
+
// Send response BEFORE modifying index.js
|
|
2066
|
+
res.json({
|
|
2067
|
+
message: 'Serial port "' + name + '" added.',
|
|
2068
|
+
files: ['index.js', '.env.development', '.env.production']
|
|
2069
|
+
});
|
|
2070
|
+
|
|
2071
|
+
// Modify index.js LAST
|
|
2072
|
+
addImportToIndex("import { SerialPort } from 'serialport';");
|
|
2073
|
+
addImportToIndex("import { ReadlineParser } from '@serialport/parser-readline';");
|
|
2074
|
+
|
|
2075
|
+
const serialCode = `const ${name} = new SerialPort({
|
|
2076
|
+
path: process.env.${envPathKey} || '${portPath}',
|
|
2077
|
+
baudRate: parseInt(process.env.${envBaudKey}) || ${baud}
|
|
2078
|
+
});
|
|
2079
|
+
|
|
2080
|
+
const ${parserName} = ${name}.pipe(new ReadlineParser({ delimiter: '\\r\\n' }));
|
|
2081
|
+
|
|
2082
|
+
${name}.on('open', () => {
|
|
2083
|
+
console.log('${name}: port opened');
|
|
2084
|
+
});
|
|
2085
|
+
|
|
2086
|
+
${parserName}.on('data', (data) => {
|
|
2087
|
+
console.log('${name} data:', data);
|
|
2088
|
+
});
|
|
2089
|
+
|
|
2090
|
+
${name}.on('error', (err) => {
|
|
2091
|
+
console.error('${name} error:', err.message);
|
|
2092
|
+
});`;
|
|
2093
|
+
|
|
2094
|
+
let current = readIndexFile();
|
|
2095
|
+
if (!current.includes(`const ${name} = new SerialPort`)) {
|
|
2096
|
+
insertBeforeListen(serialCode);
|
|
2097
|
+
}
|
|
2098
|
+
} catch (err) {
|
|
2099
|
+
if (!res.headersSent) res.status(500).json({ error: err.message });
|
|
2100
|
+
}
|
|
2101
|
+
});
|
|
2102
|
+
|
|
2103
|
+
// ---------------------------------------------------------------------------
|
|
2104
|
+
// DELETE /api/serialports/:name — remove a serial port from index.js
|
|
2105
|
+
// ---------------------------------------------------------------------------
|
|
2106
|
+
router.delete('/api/serialports/:name', (req, res) => {
|
|
2107
|
+
try {
|
|
2108
|
+
const name = req.params.name;
|
|
2109
|
+
const parserName = name + 'Parser';
|
|
2110
|
+
let content = readIndexFile();
|
|
2111
|
+
|
|
2112
|
+
// Remove the SerialPort block: const name = new SerialPort({...});
|
|
2113
|
+
content = content.replace(new RegExp(`const\\s+${name}\\s*=\\s*new\\s+SerialPort\\(\\{[\\s\\S]*?\\}\\);\\s*\\n?`, 'g'), '');
|
|
2114
|
+
// Remove parser line
|
|
2115
|
+
content = content.replace(new RegExp(`const\\s+${parserName}\\s*=\\s*${name}\\.pipe[\\s\\S]*?;\\s*\\n?`, 'g'), '');
|
|
2116
|
+
// Remove event handlers
|
|
2117
|
+
content = content.replace(new RegExp(`${name}\\.on\\(['"]open['"][\\s\\S]*?\\}\\);\\s*\\n?`, 'g'), '');
|
|
2118
|
+
content = content.replace(new RegExp(`${parserName}\\.on\\(['"]data['"][\\s\\S]*?\\}\\);\\s*\\n?`, 'g'), '');
|
|
2119
|
+
content = content.replace(new RegExp(`${name}\\.on\\(['"]error['"][\\s\\S]*?\\}\\);\\s*\\n?`, 'g'), '');
|
|
2120
|
+
|
|
2121
|
+
// If no more SerialPort instances, remove imports
|
|
2122
|
+
if (!content.includes('new SerialPort')) {
|
|
2123
|
+
content = content.replace(/import\s*\{\s*SerialPort\s*\}\s*from\s*['"]serialport['"];\s*\n?/g, '');
|
|
2124
|
+
content = content.replace(/import\s*\{\s*ReadlineParser\s*\}\s*from\s*['"]@serialport\/parser-readline['"];\s*\n?/g, '');
|
|
2125
|
+
}
|
|
2126
|
+
|
|
2127
|
+
// Clean up multiple blank lines
|
|
2128
|
+
content = content.replace(/\n{3,}/g, '\n\n');
|
|
2129
|
+
writeIndexFile(content);
|
|
2130
|
+
|
|
2131
|
+
// Remove env vars
|
|
2132
|
+
const envPathKey = name.toUpperCase() + '_PATH';
|
|
2133
|
+
const envBaudKey = name.toUpperCase() + '_BAUD';
|
|
2134
|
+
for (const envFile of ['.env.development', '.env.production']) {
|
|
2135
|
+
const envPath = path.join(process.cwd(), envFile);
|
|
2136
|
+
if (fs.existsSync(envPath)) {
|
|
2137
|
+
let envContent = fs.readFileSync(envPath, 'utf8');
|
|
2138
|
+
envContent = envContent.replace(new RegExp(`^${envPathKey}=.*\\n?`, 'm'), '');
|
|
2139
|
+
envContent = envContent.replace(new RegExp(`^${envBaudKey}=.*\\n?`, 'm'), '');
|
|
2140
|
+
fs.writeFileSync(envPath, envContent, 'utf8');
|
|
2141
|
+
}
|
|
2142
|
+
}
|
|
2143
|
+
|
|
2144
|
+
res.json({ message: 'Serial port "' + name + '" removed.' });
|
|
2145
|
+
} catch (err) {
|
|
2146
|
+
res.status(500).json({ error: err.message });
|
|
2147
|
+
}
|
|
2148
|
+
});
|
|
2149
|
+
|
|
2150
|
+
// ---------------------------------------------------------------------------
|
|
2151
|
+
// API Tester — field configs (stored in .4bnode/tester-fields.json)
|
|
2152
|
+
// ---------------------------------------------------------------------------
|
|
2153
|
+
const testerFieldsPath = path.join(process.cwd(), '.4bnode', 'tester-fields.json');
|
|
2154
|
+
|
|
2155
|
+
function loadTesterFields() {
|
|
2156
|
+
try {
|
|
2157
|
+
if (fs.existsSync(testerFieldsPath)) return JSON.parse(fs.readFileSync(testerFieldsPath, 'utf8'));
|
|
2158
|
+
} catch {}
|
|
2159
|
+
return {};
|
|
2160
|
+
}
|
|
2161
|
+
|
|
2162
|
+
function saveTesterFields(data) {
|
|
2163
|
+
const dir = path.dirname(testerFieldsPath);
|
|
2164
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
2165
|
+
fs.writeFileSync(testerFieldsPath, JSON.stringify(data, null, 2), 'utf8');
|
|
2166
|
+
}
|
|
2167
|
+
|
|
2168
|
+
const testerDataPath = path.join(process.cwd(), '.4bnode', 'tester-data.json');
|
|
2169
|
+
|
|
2170
|
+
function loadTesterData() {
|
|
2171
|
+
try {
|
|
2172
|
+
if (fs.existsSync(testerDataPath)) return JSON.parse(fs.readFileSync(testerDataPath, 'utf8'));
|
|
2173
|
+
} catch {}
|
|
2174
|
+
return { tests: [], responses: {} };
|
|
2175
|
+
}
|
|
2176
|
+
|
|
2177
|
+
function saveTesterData(data) {
|
|
2178
|
+
const dir = path.dirname(testerDataPath);
|
|
2179
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
2180
|
+
fs.writeFileSync(testerDataPath, JSON.stringify(data, null, 2), 'utf8');
|
|
2181
|
+
}
|
|
2182
|
+
|
|
2183
|
+
// GET /api/api-tester/data — load saved tests and responses
|
|
2184
|
+
router.get('/api/api-tester/data', (req, res) => {
|
|
2185
|
+
res.json(loadTesterData());
|
|
2186
|
+
});
|
|
2187
|
+
|
|
2188
|
+
// PUT /api/api-tester/data — save tests and responses
|
|
2189
|
+
router.put('/api/api-tester/data', (req, res) => {
|
|
2190
|
+
const { tests, responses } = req.body;
|
|
2191
|
+
saveTesterData({ tests: tests || [], responses: responses || {} });
|
|
2192
|
+
res.json({ message: 'Saved' });
|
|
2193
|
+
});
|
|
2194
|
+
|
|
2195
|
+
// GET /api/api-tester/fields/:routeKey — get saved fields for a route
|
|
2196
|
+
router.get('/api/api-tester/fields/:routeKey', (req, res) => {
|
|
2197
|
+
const all = loadTesterFields();
|
|
2198
|
+
const key = decodeURIComponent(req.params.routeKey);
|
|
2199
|
+
res.json({ fields: all[key] || null });
|
|
2200
|
+
});
|
|
2201
|
+
|
|
2202
|
+
// PUT /api/api-tester/fields/:routeKey — save fields for a route
|
|
2203
|
+
router.put('/api/api-tester/fields/:routeKey', (req, res) => {
|
|
2204
|
+
const { fields } = req.body;
|
|
2205
|
+
const key = decodeURIComponent(req.params.routeKey);
|
|
2206
|
+
const all = loadTesterFields();
|
|
2207
|
+
all[key] = fields;
|
|
2208
|
+
saveTesterFields(all);
|
|
2209
|
+
res.json({ message: 'Fields saved' });
|
|
2210
|
+
});
|
|
2211
|
+
|
|
2212
|
+
// ---------------------------------------------------------------------------
|
|
2213
|
+
// API Tester — proxy requests to avoid CORS issues
|
|
2214
|
+
// ---------------------------------------------------------------------------
|
|
2215
|
+
router.post('/api/api-tester/send', async (req, res) => {
|
|
2216
|
+
try {
|
|
2217
|
+
const { method, url, headers: reqHeaders, body: reqBody, bodyType = 'json', formFields = [] } = req.body;
|
|
2218
|
+
if (!url) return res.status(400).json({ error: 'URL is required' });
|
|
2219
|
+
|
|
2220
|
+
const fetchOpts = { method: (method || 'GET').toUpperCase() };
|
|
2221
|
+
const headers = { ...(reqHeaders || {}) };
|
|
2222
|
+
|
|
2223
|
+
if (['POST', 'PUT', 'PATCH'].includes(fetchOpts.method)) {
|
|
2224
|
+
if (bodyType === 'form-data' && formFields.length > 0) {
|
|
2225
|
+
// Build multipart form data
|
|
2226
|
+
const boundary = '----4bnode' + crypto.randomBytes(8).toString('hex');
|
|
2227
|
+
let bodyParts = [];
|
|
2228
|
+
for (const field of formFields) {
|
|
2229
|
+
if (!field.key?.trim()) continue;
|
|
2230
|
+
if (field.fileData) {
|
|
2231
|
+
// File field (base64 encoded data)
|
|
2232
|
+
const fileBuffer = Buffer.from(field.fileData, 'base64');
|
|
2233
|
+
bodyParts.push(
|
|
2234
|
+
`--${boundary}\r\nContent-Disposition: form-data; name="${field.key}"; filename="${field.fileName || 'file'}"\r\nContent-Type: ${field.fileType || 'application/octet-stream'}\r\n\r\n`
|
|
2235
|
+
);
|
|
2236
|
+
bodyParts.push(fileBuffer);
|
|
2237
|
+
bodyParts.push('\r\n');
|
|
2238
|
+
} else {
|
|
2239
|
+
bodyParts.push(
|
|
2240
|
+
`--${boundary}\r\nContent-Disposition: form-data; name="${field.key}"\r\n\r\n${field.value || ''}\r\n`
|
|
2241
|
+
);
|
|
2242
|
+
}
|
|
2243
|
+
}
|
|
2244
|
+
bodyParts.push(`--${boundary}--\r\n`);
|
|
2245
|
+
// Combine string and buffer parts
|
|
2246
|
+
const bufferParts = bodyParts.map(p => typeof p === 'string' ? Buffer.from(p) : p);
|
|
2247
|
+
fetchOpts.body = Buffer.concat(bufferParts);
|
|
2248
|
+
headers['Content-Type'] = `multipart/form-data; boundary=${boundary}`;
|
|
2249
|
+
} else if (bodyType === 'x-www-form-urlencoded' && formFields.length > 0) {
|
|
2250
|
+
fetchOpts.body = formFields
|
|
2251
|
+
.filter(f => f.key?.trim())
|
|
2252
|
+
.map(f => encodeURIComponent(f.key) + '=' + encodeURIComponent(f.value || ''))
|
|
2253
|
+
.join('&');
|
|
2254
|
+
headers['Content-Type'] = 'application/x-www-form-urlencoded';
|
|
2255
|
+
} else if (bodyType === 'binary' && reqBody) {
|
|
2256
|
+
// Binary body (base64 encoded)
|
|
2257
|
+
fetchOpts.body = Buffer.from(reqBody, 'base64');
|
|
2258
|
+
} else if (reqBody) {
|
|
2259
|
+
fetchOpts.body = typeof reqBody === 'string' ? reqBody : JSON.stringify(reqBody);
|
|
2260
|
+
}
|
|
2261
|
+
}
|
|
2262
|
+
|
|
2263
|
+
// Remove Content-Type for form-data (let boundary be set correctly)
|
|
2264
|
+
if (bodyType === 'form-data') {
|
|
2265
|
+
// Already set above with boundary
|
|
2266
|
+
}
|
|
2267
|
+
|
|
2268
|
+
fetchOpts.headers = headers;
|
|
2269
|
+
|
|
2270
|
+
const start = Date.now();
|
|
2271
|
+
const response = await fetch(url, fetchOpts);
|
|
2272
|
+
const elapsed = Date.now() - start;
|
|
2273
|
+
const text = await response.text();
|
|
2274
|
+
const respHeaders = {};
|
|
2275
|
+
response.headers.forEach((v, k) => { respHeaders[k] = v; });
|
|
2276
|
+
res.json({
|
|
2277
|
+
status: response.status,
|
|
2278
|
+
statusText: response.statusText,
|
|
2279
|
+
headers: respHeaders,
|
|
2280
|
+
body: text,
|
|
2281
|
+
time: elapsed,
|
|
2282
|
+
size: Buffer.byteLength(text),
|
|
2283
|
+
});
|
|
2284
|
+
} catch (err) {
|
|
2285
|
+
res.json({ status: 0, statusText: 'Connection Error', body: err.message, headers: {}, time: 0, size: 0 });
|
|
2286
|
+
}
|
|
2287
|
+
});
|
|
2288
|
+
|
|
2289
|
+
// GET /api/api-tester/routes — list all routes with endpoints for import
|
|
2290
|
+
router.get('/api/api-tester/routes', (req, res) => {
|
|
2291
|
+
try {
|
|
2292
|
+
const routes = listRoutes();
|
|
2293
|
+
const result = [];
|
|
2294
|
+
const indexPath = path.join(process.cwd(), 'index.js');
|
|
2295
|
+
const indexContent = fs.existsSync(indexPath) ? fs.readFileSync(indexPath, 'utf8') : '';
|
|
2296
|
+
|
|
2297
|
+
routes.forEach(routeFile => {
|
|
2298
|
+
const name = routeFile.replace('.js', '');
|
|
2299
|
+
const filePath = path.join(process.cwd(), 'src', 'routes', routeFile);
|
|
2300
|
+
if (!fs.existsSync(filePath)) return;
|
|
2301
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
2302
|
+
|
|
2303
|
+
let prefix = '/api/' + name;
|
|
2304
|
+
const prefixMatch = indexContent.match(new RegExp(`app\\.use\\(['"](\\/[^'"]+)['"].*${name}`));
|
|
2305
|
+
if (prefixMatch) prefix = prefixMatch[1];
|
|
2306
|
+
|
|
2307
|
+
const modelImportMatch = content.match(/import\s+(\w+)\s+from\s+['"]\.\.\/models\/(\w+)['"]/);
|
|
2308
|
+
let modelFields = [];
|
|
2309
|
+
if (modelImportMatch) {
|
|
2310
|
+
modelFields = getModelSchemaDetailed(modelImportMatch[2]);
|
|
2311
|
+
}
|
|
2312
|
+
|
|
2313
|
+
const endpoints = [];
|
|
2314
|
+
const epRegex = /router\.(get|post|put|patch|delete)\(\s*['"`](\/[^'"`]*?)['"`]\s*,([\s\S]*?)(?=\nrouter\.|$)/gi;
|
|
2315
|
+
let m;
|
|
2316
|
+
while ((m = epRegex.exec(content)) !== null) {
|
|
2317
|
+
const method = m[1].toUpperCase();
|
|
2318
|
+
const epath = m[2];
|
|
2319
|
+
const body = m[3] || '';
|
|
2320
|
+
const bodyFields = [];
|
|
2321
|
+
const queryFields = [];
|
|
2322
|
+
const fileFields = [];
|
|
2323
|
+
const bodyMatch = body.match(/const\s*\{\s*([^}]+)\}\s*=\s*req\.body/);
|
|
2324
|
+
if (bodyMatch) {
|
|
2325
|
+
bodyMatch[1].split(',').forEach(f => {
|
|
2326
|
+
const fname = f.trim();
|
|
2327
|
+
if (fname) {
|
|
2328
|
+
const meta = modelFields.find(mf => mf.name === fname);
|
|
2329
|
+
bodyFields.push({ name: fname, type: meta?.type || 'String', required: meta?.required || false, unique: meta?.unique || false });
|
|
2330
|
+
}
|
|
2331
|
+
});
|
|
2332
|
+
}
|
|
2333
|
+
const queryMatch = body.match(/const\s*\{\s*([^}]+)\}\s*=\s*req\.query/);
|
|
2334
|
+
if (queryMatch) {
|
|
2335
|
+
queryMatch[1].split(',').forEach(f => {
|
|
2336
|
+
const fname = f.trim();
|
|
2337
|
+
if (fname) queryFields.push({ name: fname, type: 'String' });
|
|
2338
|
+
});
|
|
2339
|
+
}
|
|
2340
|
+
// Detect file fields from multer middleware and req.file/req.files
|
|
2341
|
+
const uploadSingleMatch = body.match(/upload\.single\(\s*['"](\w+)['"]\s*\)/);
|
|
2342
|
+
if (uploadSingleMatch) fileFields.push({ name: uploadSingleMatch[1] });
|
|
2343
|
+
const uploadFieldsRegex = /name:\s*['"](\w+)['"]/g;
|
|
2344
|
+
const uploadFieldsBlock = body.match(/upload\.fields\(\s*\[([\s\S]*?)\]\s*\)/);
|
|
2345
|
+
if (uploadFieldsBlock) {
|
|
2346
|
+
let ufm;
|
|
2347
|
+
while ((ufm = uploadFieldsRegex.exec(uploadFieldsBlock[1])) !== null) {
|
|
2348
|
+
if (!fileFields.find(f => f.name === ufm[1])) fileFields.push({ name: ufm[1] });
|
|
2349
|
+
}
|
|
2350
|
+
}
|
|
2351
|
+
// Also detect from req.file / req.files assignments
|
|
2352
|
+
const singleFileMatch = body.match(/const\s+(\w+)\s*=\s*req\.file\b/);
|
|
2353
|
+
if (singleFileMatch && !fileFields.find(f => f.name === singleFileMatch[1])) fileFields.push({ name: singleFileMatch[1] });
|
|
2354
|
+
const multiFileRegex = /const\s+(\w+)\s*=\s*req\.files\??\[?\s*\[?['"]([\w]+)['"]\]?/g;
|
|
2355
|
+
let fm;
|
|
2356
|
+
while ((fm = multiFileRegex.exec(body)) !== null) {
|
|
2357
|
+
if (!fileFields.find(f => f.name === fm[1])) fileFields.push({ name: fm[1] });
|
|
2358
|
+
}
|
|
2359
|
+
|
|
2360
|
+
const hasAuth = /auth\s*,/.test(m[0]);
|
|
2361
|
+
const params = [];
|
|
2362
|
+
const paramRegex = /:(\w+)/g;
|
|
2363
|
+
let pm;
|
|
2364
|
+
while ((pm = paramRegex.exec(epath)) !== null) params.push(pm[1]);
|
|
2365
|
+
|
|
2366
|
+
endpoints.push({
|
|
2367
|
+
method,
|
|
2368
|
+
path: epath,
|
|
2369
|
+
fullPath: prefix + (epath === '/' ? '' : epath),
|
|
2370
|
+
bodyFields,
|
|
2371
|
+
queryFields,
|
|
2372
|
+
fileFields,
|
|
2373
|
+
params,
|
|
2374
|
+
hasAuth,
|
|
2375
|
+
});
|
|
2376
|
+
}
|
|
2377
|
+
const hasAuth = content.includes("import auth");
|
|
2378
|
+
result.push({ name, routeFile, prefix, endpoints, hasAuth });
|
|
2379
|
+
});
|
|
2380
|
+
res.json(result);
|
|
2381
|
+
} catch (err) {
|
|
2382
|
+
res.status(500).json({ error: err.message });
|
|
2383
|
+
}
|
|
2384
|
+
});
|
|
2385
|
+
|
|
2386
|
+
// ---------------------------------------------------------------------------
|
|
2387
|
+
// Share Collection — save and serve standalone shared page
|
|
2388
|
+
// ---------------------------------------------------------------------------
|
|
2389
|
+
const sharedDir = path.join(process.cwd(), '.4bnode', 'shared');
|
|
2390
|
+
|
|
2391
|
+
router.post('/api/api-tester/share-collection', (req, res) => {
|
|
2392
|
+
try {
|
|
2393
|
+
const { tests, responses } = req.body;
|
|
2394
|
+
if (!tests?.length) return res.status(400).json({ error: 'No tests to share' });
|
|
2395
|
+
if (!fs.existsSync(sharedDir)) fs.mkdirSync(sharedDir, { recursive: true });
|
|
2396
|
+
let appName = 'API Collection';
|
|
2397
|
+
try { appName = JSON.parse(fs.readFileSync(path.join(process.cwd(), 'package.json'), 'utf8')).name || appName; } catch {}
|
|
2398
|
+
// Use fixed ID so the share URL stays the same — always updates the same file
|
|
2399
|
+
const id = crypto.createHash('md5').update(appName).digest('hex').slice(0, 12);
|
|
2400
|
+
fs.writeFileSync(path.join(sharedDir, id + '.json'), JSON.stringify({ tests, responses, appName, createdAt: new Date().toISOString() }));
|
|
2401
|
+
res.json({ id });
|
|
2402
|
+
} catch (err) {
|
|
2403
|
+
res.status(500).json({ error: err.message });
|
|
2404
|
+
}
|
|
2405
|
+
});
|
|
2406
|
+
|
|
2407
|
+
router.get('/shared/:id', (req, res) => {
|
|
2408
|
+
try {
|
|
2409
|
+
const filePath = path.join(sharedDir, req.params.id + '.json');
|
|
2410
|
+
if (!fs.existsSync(filePath)) return res.status(404).send('Collection not found');
|
|
2411
|
+
const data = JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
2412
|
+
if (!data.tests) data.tests = [];
|
|
2413
|
+
if (!data.responses) data.responses = {};
|
|
2414
|
+
const rawAppName = data.appName || 'My App';
|
|
2415
|
+
// Convert kebab-case/snake_case to Title Case: "test-app" → "Test App"
|
|
2416
|
+
const appName = rawAppName.replace(/[-_]/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
|
|
2417
|
+
|
|
2418
|
+
const safeJson = (obj) => JSON.stringify(obj).replace(/<\//g, '<\\/').replace(/<!--/g, '<\\!--');
|
|
2419
|
+
const escHtml = (s) => String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
|
2420
|
+
const safeAppName = escHtml(appName);
|
|
2421
|
+
|
|
2422
|
+
const html = `<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
|
2423
|
+
<title>${safeAppName} - API Documentation</title>
|
|
2424
|
+
<style>
|
|
2425
|
+
*{margin:0;padding:0;box-sizing:border-box}
|
|
2426
|
+
body{font-family:'Inter',-apple-system,BlinkMacSystemFont,sans-serif;background:#0f1117;color:#e2e8f0;min-height:100vh;display:flex;flex-direction:column}
|
|
2427
|
+
.topbar{height:52px;background:#161b26;border-bottom:1px solid #1e2433;display:flex;align-items:center;padding:0 24px;gap:12px;flex-shrink:0}
|
|
2428
|
+
.topbar-name{font-size:16px;font-weight:700;background:linear-gradient(135deg,#a78bfa,#7c3aed);-webkit-background-clip:text;-webkit-text-fill-color:transparent}
|
|
2429
|
+
.topbar-sep{width:1px;height:22px;background:#1e2433;margin:0 8px}
|
|
2430
|
+
.topbar-label{font-size:12px;color:#64748b;font-weight:500}
|
|
2431
|
+
.topbar-count{font-size:10px;padding:3px 10px;border-radius:10px;background:rgba(124,58,237,0.15);color:#a78bfa;font-weight:600;margin-left:auto}
|
|
2432
|
+
.topbar-date{font-size:10px;color:#475569}
|
|
2433
|
+
.layout{display:flex;flex:1;overflow:hidden}
|
|
2434
|
+
.sidebar{width:300px;min-width:300px;background:#161b26;border-right:1px solid #1e2433;display:flex;flex-direction:column;overflow:hidden}
|
|
2435
|
+
.sidebar-header{padding:14px 18px;border-bottom:1px solid #1e2433;font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:0.8px;color:#475569;display:flex;justify-content:space-between;align-items:center}
|
|
2436
|
+
.sidebar-search{padding:8px 12px;border-bottom:1px solid #1e2433}
|
|
2437
|
+
.sidebar-search input{width:100%;padding:7px 12px;background:#0f1117;border:1px solid #1e2433;border-radius:6px;color:#e2e8f0;font-size:12px;outline:none}
|
|
2438
|
+
.sidebar-search input:focus{border-color:#7c3aed}
|
|
2439
|
+
.sidebar-search input::placeholder{color:#475569}
|
|
2440
|
+
.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}
|
|
2442
|
+
.sidebar-item:hover{background:rgba(124,58,237,0.06)}
|
|
2443
|
+
.sidebar-item.active{background:rgba(124,58,237,0.1);border-left-color:#7c3aed}
|
|
2444
|
+
.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}
|
|
2445
|
+
.sidebar-child:hover{background:rgba(124,58,237,0.06)}
|
|
2446
|
+
.sidebar-child.active{background:rgba(124,58,237,0.1);border-left-color:#7c3aed}
|
|
2447
|
+
.mb{font-size:9px;font-weight:700;padding:3px 7px;border-radius:4px;font-family:'SF Mono','Fira Code',monospace;text-transform:uppercase;flex-shrink:0;letter-spacing:0.3px}
|
|
2448
|
+
.mb-get{background:rgba(52,211,153,0.15);color:#34d399}
|
|
2449
|
+
.mb-post{background:rgba(245,158,11,0.15);color:#f59e0b}
|
|
2450
|
+
.mb-put{background:rgba(59,130,246,0.15);color:#3b82f6}
|
|
2451
|
+
.mb-patch{background:rgba(139,92,246,0.15);color:#8b5cf6}
|
|
2452
|
+
.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}
|
|
2454
|
+
.item-arrow{font-size:10px;color:#475569;width:14px;text-align:center;flex-shrink:0}
|
|
2455
|
+
.item-badge{font-size:9px;padding:2px 7px;border-radius:8px;background:rgba(124,58,237,0.15);color:#a78bfa;font-weight:600}
|
|
2456
|
+
.sb{font-size:9px;font-weight:700;padding:3px 7px;border-radius:4px;font-family:'SF Mono','Fira Code',monospace;flex-shrink:0}
|
|
2457
|
+
.s2{background:rgba(52,211,153,0.15);color:#34d399}
|
|
2458
|
+
.s3{background:rgba(96,165,250,0.15);color:#60a5fa}
|
|
2459
|
+
.s4{background:rgba(251,191,36,0.15);color:#fbbf24}
|
|
2460
|
+
.s5{background:rgba(248,113,113,0.15);color:#f87171}
|
|
2461
|
+
.s0{background:rgba(248,113,113,0.15);color:#f87171}
|
|
2462
|
+
.child-label{font-size:11px;color:#94a3b8;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
2463
|
+
.child-time{font-size:10px;color:#475569;flex-shrink:0}
|
|
2464
|
+
.main{flex:1;display:flex;flex-direction:column;overflow:hidden}
|
|
2465
|
+
.main-content{flex:1;overflow-y:auto;padding:24px}
|
|
2466
|
+
.empty-state{display:flex;flex-direction:column;align-items:center;justify-content:center;height:100%;color:#475569;gap:12px}
|
|
2467
|
+
.empty-state svg{width:48px;height:48px;stroke:#2d3548;stroke-width:1.5;fill:none}
|
|
2468
|
+
.card{background:#161b26;border:1px solid #1e2433;border-radius:10px;padding:18px;margin-bottom:16px}
|
|
2469
|
+
.card-header{display:flex;align-items:center;gap:10px;margin-bottom:16px;padding-bottom:14px;border-bottom:1px solid #1e2433}
|
|
2470
|
+
.endpoint-url{font-family:'SF Mono','Fira Code',monospace;font-size:13px;color:#e2e8f0;background:#0f1117;padding:10px 14px;border-radius:8px;border:1px solid #1e2433;margin-bottom:16px;display:flex;align-items:center;gap:10px;justify-content:space-between}
|
|
2471
|
+
.endpoint-url code{flex:1;word-break:break-all}
|
|
2472
|
+
.copy-btn{padding:5px 12px;border:1px solid #1e2433;border-radius:6px;background:#0f1117;color:#94a3b8;font-size:11px;cursor:pointer;font-family:inherit;transition:all 0.15s;white-space:nowrap}
|
|
2473
|
+
.copy-btn:hover{border-color:#7c3aed;color:#a78bfa}
|
|
2474
|
+
.stitle{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:0.8px;color:#475569;margin-bottom:10px}
|
|
2475
|
+
pre{font-family:'SF Mono','Fira Code',monospace;font-size:12px;line-height:1.7;color:#94a3b8;white-space:pre-wrap;word-break:break-word;background:#0f1117;padding:14px;border-radius:8px;border:1px solid #1e2433;position:relative}
|
|
2476
|
+
.field-row{display:flex;align-items:center;gap:8px;padding:8px 12px;background:#0f1117;border-radius:6px;margin-bottom:4px;border:1px solid #1e2433}
|
|
2477
|
+
.field-name{font-family:'SF Mono','Fira Code',monospace;font-size:12px;font-weight:500;color:#e2e8f0;min-width:120px}
|
|
2478
|
+
.field-type{font-size:10px;padding:2px 7px;border-radius:4px;background:rgba(124,58,237,0.12);color:#a78bfa;font-weight:500}
|
|
2479
|
+
.field-req{font-size:10px;padding:2px 7px;border-radius:4px;background:rgba(52,211,153,0.12);color:#34d399;font-weight:600}
|
|
2480
|
+
.field-uniq{font-size:10px;padding:2px 7px;border-radius:4px;background:rgba(251,191,36,0.12);color:#fbbf24;font-weight:600}
|
|
2481
|
+
.code-tabs{display:flex;gap:0;border-bottom:1px solid #1e2433;margin-bottom:0}
|
|
2482
|
+
.code-tab{padding:7px 16px;font-size:11px;font-weight:500;color:#475569;background:none;border:none;cursor:pointer;border-bottom:2px solid transparent;font-family:inherit;transition:all 0.15s}
|
|
2483
|
+
.code-tab:hover{color:#94a3b8}
|
|
2484
|
+
.code-tab.active{color:#a78bfa;border-bottom-color:#7c3aed;font-weight:600}
|
|
2485
|
+
.header-row{display:flex;padding:6px 12px;font-size:12px;gap:12px;border-bottom:1px solid rgba(30,36,51,0.5)}
|
|
2486
|
+
.header-key{font-family:'SF Mono','Fira Code',monospace;font-size:11px;font-weight:600;color:#a78bfa;min-width:160px}
|
|
2487
|
+
.header-val{font-family:'SF Mono','Fira Code',monospace;font-size:11px;color:#94a3b8;word-break:break-all}
|
|
2488
|
+
.type-sio{background:rgba(52,211,153,0.15);color:#34d399}
|
|
2489
|
+
.type-ws{background:rgba(96,165,250,0.15);color:#60a5fa}
|
|
2490
|
+
.dir-recv{background:rgba(52,211,153,0.15);color:#34d399}
|
|
2491
|
+
.dir-sent{background:rgba(245,158,11,0.15);color:#f59e0b}
|
|
2492
|
+
.dir-sys{background:rgba(148,163,184,0.15);color:#94a3b8}
|
|
2493
|
+
@keyframes spin{to{transform:rotate(360deg)}}
|
|
2494
|
+
.spinner{width:16px;height:16px;border:2px solid #1e2433;border-top-color:#7c3aed;border-radius:50%;animation:spin 0.6s linear infinite;display:inline-block}
|
|
2495
|
+
</style></head><body>
|
|
2496
|
+
<div class="topbar">
|
|
2497
|
+
<span class="topbar-name">${safeAppName}</span>
|
|
2498
|
+
<span class="topbar-sep"></span>
|
|
2499
|
+
<span class="topbar-label">API Documentation</span>
|
|
2500
|
+
<span class="topbar-count">${data.tests.length} endpoint${data.tests.length !== 1 ? 's' : ''}</span>
|
|
2501
|
+
<span class="topbar-date">Shared ${new Date(data.createdAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}</span>
|
|
2502
|
+
</div>
|
|
2503
|
+
<div class="layout">
|
|
2504
|
+
<div class="sidebar">
|
|
2505
|
+
<div class="sidebar-header"><span>Endpoints</span></div>
|
|
2506
|
+
<div class="sidebar-search"><input id="search-inp" placeholder="Search endpoints..." /></div>
|
|
2507
|
+
<div class="sidebar-list" id="sidebar-list"></div>
|
|
2508
|
+
</div>
|
|
2509
|
+
<div class="main">
|
|
2510
|
+
<div class="main-content" id="main-content">
|
|
2511
|
+
<div class="empty-state">
|
|
2512
|
+
<svg viewBox="0 0 24 24"><path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"/></svg>
|
|
2513
|
+
<span style="font-size:14px;font-weight:500">Select an endpoint from the sidebar</span>
|
|
2514
|
+
<span style="font-size:12px">View request details, example code, and saved responses</span>
|
|
2515
|
+
</div>
|
|
2516
|
+
</div>
|
|
2517
|
+
</div>
|
|
2518
|
+
</div>
|
|
2519
|
+
<script id="shared-data" type="application/json">${safeJson({ tests: data.tests, responses: data.responses || {} })}</script>
|
|
2520
|
+
<script>
|
|
2521
|
+
(function(){
|
|
2522
|
+
var _d = JSON.parse(document.getElementById('shared-data').textContent);
|
|
2523
|
+
var tests = _d.tests;
|
|
2524
|
+
var responses = _d.responses;
|
|
2525
|
+
var activeTestId = null;
|
|
2526
|
+
var activeRespId = null;
|
|
2527
|
+
var expanded = {};
|
|
2528
|
+
var activeCodeTab = {};
|
|
2529
|
+
var searchQuery = '';
|
|
2530
|
+
|
|
2531
|
+
tests.forEach(function(t){ t.id = String(t.id); });
|
|
2532
|
+
var normResp = {};
|
|
2533
|
+
Object.keys(responses).forEach(function(k){
|
|
2534
|
+
var key = String(k);
|
|
2535
|
+
var arr = responses[k];
|
|
2536
|
+
if (Array.isArray(arr)) arr.forEach(function(r){ r.id = String(r.id); });
|
|
2537
|
+
normResp[key] = arr;
|
|
2538
|
+
});
|
|
2539
|
+
responses = normResp;
|
|
2540
|
+
|
|
2541
|
+
function esc(s) { return (s||'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
|
2542
|
+
|
|
2543
|
+
// Copy via event delegation
|
|
2544
|
+
document.addEventListener('click', function(e) {
|
|
2545
|
+
var btn = e.target.closest('[data-copy]');
|
|
2546
|
+
if (!btn) return;
|
|
2547
|
+
var target = btn.getAttribute('data-copy');
|
|
2548
|
+
var text = '';
|
|
2549
|
+
if (target === 'prev-pre') {
|
|
2550
|
+
var pre = btn.closest('div').querySelector('pre');
|
|
2551
|
+
if (pre) text = pre.textContent;
|
|
2552
|
+
} else if (target === 'url') {
|
|
2553
|
+
var code = btn.closest('.endpoint-url');
|
|
2554
|
+
if (code) text = code.querySelector('code').textContent;
|
|
2555
|
+
}
|
|
2556
|
+
if (text) {
|
|
2557
|
+
navigator.clipboard.writeText(text);
|
|
2558
|
+
var orig = btn.textContent;
|
|
2559
|
+
btn.textContent = 'Copied!';
|
|
2560
|
+
btn.style.color = '#34d399';
|
|
2561
|
+
setTimeout(function(){ btn.textContent = orig; btn.style.color = ''; }, 1500);
|
|
2562
|
+
}
|
|
2563
|
+
});
|
|
2564
|
+
|
|
2565
|
+
// Generate code snippets
|
|
2566
|
+
var _isForm = function(t) { return (t.bodyType === 'form-data' || t.bodyType === 'x-www-form-urlencoded') && t.formFields && t.formFields.length > 0; };
|
|
2567
|
+
var _hasBody = function(t) { return ['POST','PUT','PATCH'].indexOf(t.method) !== -1; };
|
|
2568
|
+
|
|
2569
|
+
function genCurl(t) {
|
|
2570
|
+
var nl = String.fromCharCode(10);
|
|
2571
|
+
var bs = String.fromCharCode(92);
|
|
2572
|
+
var c = 'curl -X ' + t.method + " '" + t.url + "'";
|
|
2573
|
+
if (_isForm(t)) {
|
|
2574
|
+
t.formFields.forEach(function(f) {
|
|
2575
|
+
if (!f.key) return;
|
|
2576
|
+
if (f.type === 'file') {
|
|
2577
|
+
c += ' ' + bs + nl + " -F '" + f.key + "=@/path/to/" + (f.fileName || 'file') + "'";
|
|
2578
|
+
} else {
|
|
2579
|
+
c += ' ' + bs + nl + " -F '" + f.key + '=' + (f.value || '') + "'";
|
|
2580
|
+
}
|
|
2581
|
+
});
|
|
2582
|
+
} else {
|
|
2583
|
+
if (t.headers) t.headers.forEach(function(h) { if (h.key) c += ' ' + bs + nl + " -H '" + h.key + ': ' + (h.value||'') + "'"; });
|
|
2584
|
+
if (t.body && _hasBody(t)) {
|
|
2585
|
+
c += ' ' + bs + nl + " -d '" + t.body.replace(/'/g, "'") + "'";
|
|
2586
|
+
}
|
|
2587
|
+
}
|
|
2588
|
+
return c;
|
|
2589
|
+
}
|
|
2590
|
+
function genFetch(t) {
|
|
2591
|
+
var nl = String.fromCharCode(10);
|
|
2592
|
+
var lines = [];
|
|
2593
|
+
if (_isForm(t)) {
|
|
2594
|
+
lines.push('const formData = new FormData();');
|
|
2595
|
+
t.formFields.forEach(function(f) {
|
|
2596
|
+
if (!f.key) return;
|
|
2597
|
+
if (f.type === 'file') {
|
|
2598
|
+
lines.push("formData.append('" + f.key + "', fileInput.files[0]); // " + (f.fileName || 'file'));
|
|
2599
|
+
} else {
|
|
2600
|
+
lines.push("formData.append('" + f.key + "', '" + (f.value || '') + "');");
|
|
2601
|
+
}
|
|
2602
|
+
});
|
|
2603
|
+
lines.push('');
|
|
2604
|
+
lines.push("fetch('" + t.url + "', {");
|
|
2605
|
+
lines.push(" method: '" + t.method + "',");
|
|
2606
|
+
lines.push(' body: formData,');
|
|
2607
|
+
lines.push('})');
|
|
2608
|
+
} else {
|
|
2609
|
+
lines.push("fetch('" + t.url + "', {");
|
|
2610
|
+
lines.push(" method: '" + t.method + "',");
|
|
2611
|
+
var hdrs = {};
|
|
2612
|
+
if (t.headers) t.headers.forEach(function(h) { if (h.key) hdrs[h.key] = h.value || ''; });
|
|
2613
|
+
if (Object.keys(hdrs).length > 0) lines.push(' headers: ' + JSON.stringify(hdrs, null, 4) + ',');
|
|
2614
|
+
if (t.body && _hasBody(t)) {
|
|
2615
|
+
var formatted = t.body;
|
|
2616
|
+
try { formatted = JSON.stringify(JSON.parse(t.body)); } catch(e) {}
|
|
2617
|
+
lines.push(' body: JSON.stringify(' + formatted + '),');
|
|
2618
|
+
}
|
|
2619
|
+
lines.push('})');
|
|
2620
|
+
}
|
|
2621
|
+
lines.push('.then(res => res.json())');
|
|
2622
|
+
lines.push('.then(data => console.log(data))');
|
|
2623
|
+
lines.push('.catch(err => console.error(err));');
|
|
2624
|
+
return lines.join(nl);
|
|
2625
|
+
}
|
|
2626
|
+
function genAxios(t) {
|
|
2627
|
+
var nl = String.fromCharCode(10);
|
|
2628
|
+
var m = t.method.toLowerCase();
|
|
2629
|
+
var hdrs = {};
|
|
2630
|
+
if (t.headers) t.headers.forEach(function(h) { if (h.key) hdrs[h.key] = h.value || ''; });
|
|
2631
|
+
var lines = [];
|
|
2632
|
+
if (_isForm(t)) {
|
|
2633
|
+
lines.push('const formData = new FormData();');
|
|
2634
|
+
t.formFields.forEach(function(f) {
|
|
2635
|
+
if (!f.key) return;
|
|
2636
|
+
if (f.type === 'file') {
|
|
2637
|
+
lines.push("formData.append('" + f.key + "', fileInput.files[0]); // " + (f.fileName || 'file'));
|
|
2638
|
+
} else {
|
|
2639
|
+
lines.push("formData.append('" + f.key + "', '" + (f.value || '') + "');");
|
|
2640
|
+
}
|
|
2641
|
+
});
|
|
2642
|
+
lines.push('');
|
|
2643
|
+
lines.push("const { data } = await axios." + m + "('" + t.url + "', formData, {");
|
|
2644
|
+
lines.push(" headers: { 'Content-Type': 'multipart/form-data' },");
|
|
2645
|
+
lines.push('});');
|
|
2646
|
+
} else if (_hasBody(t) && t.body) {
|
|
2647
|
+
var d = t.body;
|
|
2648
|
+
try { d = JSON.stringify(JSON.parse(d), null, 2); } catch(e) {}
|
|
2649
|
+
lines.push('const { data } = await axios.' + m + '(');
|
|
2650
|
+
lines.push(" '" + t.url + "',");
|
|
2651
|
+
lines.push(' ' + d.split(nl).join(nl + ' '));
|
|
2652
|
+
if (Object.keys(hdrs).length > 0) lines.push(' , { headers: ' + JSON.stringify(hdrs) + ' }');
|
|
2653
|
+
lines.push(');');
|
|
2654
|
+
} else {
|
|
2655
|
+
lines.push("const { data } = await axios." + m + "('" + t.url + "'");
|
|
2656
|
+
if (Object.keys(hdrs).length > 0) lines.push(' , { headers: ' + JSON.stringify(hdrs) + ' }');
|
|
2657
|
+
lines.push(');');
|
|
2658
|
+
}
|
|
2659
|
+
lines.push('console.log(data);');
|
|
2660
|
+
return lines.join(nl);
|
|
2661
|
+
}
|
|
2662
|
+
|
|
2663
|
+
function filteredTests() {
|
|
2664
|
+
if (!searchQuery) return tests;
|
|
2665
|
+
var q = searchQuery.toLowerCase();
|
|
2666
|
+
return tests.filter(function(t) {
|
|
2667
|
+
return (t.name||'').toLowerCase().includes(q) || (t.url||'').toLowerCase().includes(q) || (t.method||'').toLowerCase().includes(q);
|
|
2668
|
+
});
|
|
2669
|
+
}
|
|
2670
|
+
|
|
2671
|
+
function renderSidebar() {
|
|
2672
|
+
var sb = document.getElementById('sidebar-list');
|
|
2673
|
+
var h = '';
|
|
2674
|
+
var ft = filteredTests();
|
|
2675
|
+
ft.forEach(function(t) {
|
|
2676
|
+
var resps = responses[t.id] || [];
|
|
2677
|
+
var isActive = activeTestId === t.id && !activeRespId;
|
|
2678
|
+
var isExp = expanded[t.id];
|
|
2679
|
+
var tt = t.type || 'rest';
|
|
2680
|
+
h += '<div class="sidebar-item' + (isActive ? ' active' : '') + '" data-test="' + t.id + '">';
|
|
2681
|
+
if (resps.length > 0) h += '<span class="item-arrow">' + (isExp ? '▼' : '▶') + '</span>';
|
|
2682
|
+
else h += '<span class="item-arrow"></span>';
|
|
2683
|
+
if (tt === 'socketio') h += '<span class="mb type-sio">SIO</span>';
|
|
2684
|
+
else if (tt === 'websocket') h += '<span class="mb type-ws">WS</span>';
|
|
2685
|
+
else h += '<span class="mb mb-' + (t.method||'GET').toLowerCase() + '">' + (t.method||'GET') + '</span>';
|
|
2686
|
+
h += '<span class="item-name">' + esc(t.name) + '</span>';
|
|
2687
|
+
if (resps.length > 0) h += '<span class="item-badge">' + resps.length + '</span>';
|
|
2688
|
+
h += '</div>';
|
|
2689
|
+
if (isExp && resps.length > 0) {
|
|
2690
|
+
resps.forEach(function(r) {
|
|
2691
|
+
var isRActive = activeRespId === r.id && activeTestId === t.id;
|
|
2692
|
+
var isSocket = r.dir;
|
|
2693
|
+
h += '<div class="sidebar-child' + (isRActive ? ' active' : '') + '" data-test="' + t.id + '" data-resp="' + r.id + '">';
|
|
2694
|
+
if (isSocket) {
|
|
2695
|
+
var dirCls = r.dir === 'received' ? 'dir-recv' : r.dir === 'sent' ? 'dir-sent' : 'dir-sys';
|
|
2696
|
+
h += '<span class="sb ' + dirCls + '">' + (r.dir === 'received' ? '↓' : r.dir === 'sent' ? '↑' : '●') + '</span>';
|
|
2697
|
+
} else {
|
|
2698
|
+
h += '<span class="sb s' + Math.floor(r.status/100) + '">' + r.status + '</span>';
|
|
2699
|
+
}
|
|
2700
|
+
h += '<span class="child-label">' + esc(r.label) + '</span>';
|
|
2701
|
+
if (!isSocket && r.time) h += '<span class="child-time">' + r.time + 'ms</span>';
|
|
2702
|
+
h += '</div>';
|
|
2703
|
+
});
|
|
2704
|
+
}
|
|
2705
|
+
});
|
|
2706
|
+
if (ft.length === 0) h = '<div style="padding:20px;text-align:center;color:#475569;font-size:12px">No endpoints found</div>';
|
|
2707
|
+
sb.innerHTML = h;
|
|
2708
|
+
}
|
|
2709
|
+
|
|
2710
|
+
// Search
|
|
2711
|
+
document.getElementById('search-inp').addEventListener('input', function(e) {
|
|
2712
|
+
searchQuery = e.target.value;
|
|
2713
|
+
renderSidebar();
|
|
2714
|
+
});
|
|
2715
|
+
|
|
2716
|
+
document.getElementById('sidebar-list').addEventListener('click', function(e) {
|
|
2717
|
+
var el = e.target.closest('.sidebar-item, .sidebar-child');
|
|
2718
|
+
if (!el) return;
|
|
2719
|
+
var testId = el.getAttribute('data-test');
|
|
2720
|
+
var respId = el.getAttribute('data-resp');
|
|
2721
|
+
if (respId) {
|
|
2722
|
+
activeTestId = testId; activeRespId = respId;
|
|
2723
|
+
renderSidebar(); renderRespView(testId, respId);
|
|
2724
|
+
} else {
|
|
2725
|
+
var wasActive = activeTestId === testId && !activeRespId;
|
|
2726
|
+
activeTestId = testId; activeRespId = null;
|
|
2727
|
+
var resps = responses[testId] || [];
|
|
2728
|
+
if (resps.length > 0) { if (wasActive) expanded[testId] = !expanded[testId]; else expanded[testId] = true; }
|
|
2729
|
+
renderSidebar(); renderTestView(testId);
|
|
2730
|
+
}
|
|
2731
|
+
});
|
|
2732
|
+
|
|
2733
|
+
function renderTestView(id) {
|
|
2734
|
+
var t = tests.find(function(x){ return x.id === id; });
|
|
2735
|
+
if (!t) return;
|
|
2736
|
+
var tt = t.type || 'rest';
|
|
2737
|
+
var main = document.getElementById('main-content');
|
|
2738
|
+
|
|
2739
|
+
if (tt === 'socketio' || tt === 'websocket') {
|
|
2740
|
+
var h = '<div class="card"><div class="card-header">';
|
|
2741
|
+
if (tt === 'socketio') h += '<span class="mb type-sio" style="font-size:11px;padding:4px 10px">Socket.IO</span>';
|
|
2742
|
+
else h += '<span class="mb type-ws" style="font-size:11px;padding:4px 10px">WebSocket</span>';
|
|
2743
|
+
h += '<span style="font-size:16px;font-weight:600">' + esc(t.name) + '</span></div>';
|
|
2744
|
+
h += '<div class="stitle">Connection URL</div>';
|
|
2745
|
+
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>'; }
|
|
2747
|
+
if (t.data || t.message) {
|
|
2748
|
+
h += '<div class="stitle">' + (tt === 'socketio' ? 'Event Data' : 'Message') + '</div>';
|
|
2749
|
+
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>';
|
|
2751
|
+
}
|
|
2752
|
+
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>'; });
|
|
2755
|
+
h += '</div>';
|
|
2756
|
+
}
|
|
2757
|
+
h += '</div>'; main.innerHTML = h; return;
|
|
2758
|
+
}
|
|
2759
|
+
|
|
2760
|
+
var mc = { GET:'#34d399', POST:'#f59e0b', PUT:'#3b82f6', PATCH:'#8b5cf6', DELETE:'#f87171' };
|
|
2761
|
+
var formatted = t.body || '';
|
|
2762
|
+
try { formatted = JSON.stringify(JSON.parse(formatted), null, 2); } catch(e) {}
|
|
2763
|
+
var hasBody = ['POST','PUT','PATCH'].indexOf(t.method) !== -1;
|
|
2764
|
+
var isFormData = t.bodyType === 'form-data' || t.bodyType === 'x-www-form-urlencoded';
|
|
2765
|
+
var hasFormFields = t.formFields && t.formFields.length > 0;
|
|
2766
|
+
var hasJsonBody = hasBody && t.body;
|
|
2767
|
+
var bodyFields = [];
|
|
2768
|
+
if (hasJsonBody && !isFormData) { try { var bo = JSON.parse(t.body); bodyFields = Object.keys(bo); } catch(e) {} }
|
|
2769
|
+
|
|
2770
|
+
var h = '';
|
|
2771
|
+
// Header
|
|
2772
|
+
h += '<div class="card"><div class="card-header">';
|
|
2773
|
+
h += '<span class="mb mb-' + t.method.toLowerCase() + '" style="font-size:12px;padding:4px 10px">' + t.method + '</span>';
|
|
2774
|
+
h += '<span style="font-size:16px;font-weight:600">' + esc(t.name) + '</span>';
|
|
2775
|
+
if (isFormData) h += '<span style="font-size:10px;padding:3px 8px;border-radius:4px;background:rgba(245,158,11,0.12);color:#f59e0b;font-weight:600;margin-left:8px">' + (t.bodyType === 'form-data' ? 'multipart/form-data' : 'x-www-form-urlencoded') + '</span>';
|
|
2776
|
+
h += '</div>';
|
|
2777
|
+
|
|
2778
|
+
// URL
|
|
2779
|
+
h += '<div class="endpoint-url"><code>' + esc(t.url) + '</code><button class="copy-btn" data-copy="url">Copy URL</button></div>';
|
|
2780
|
+
|
|
2781
|
+
// Headers
|
|
2782
|
+
if (t.headers && t.headers.length > 0) {
|
|
2783
|
+
h += '<div class="stitle">Headers</div>';
|
|
2784
|
+
h += '<div style="background:#0f1117;border:1px solid #1e2433;border-radius:8px;margin-bottom:16px;overflow:hidden">';
|
|
2785
|
+
t.headers.forEach(function(hdr) {
|
|
2786
|
+
if (!hdr.key) return;
|
|
2787
|
+
h += '<div class="header-row"><span class="header-key">' + esc(hdr.key) + '</span><span class="header-val">' + esc(hdr.value||'') + '</span></div>';
|
|
2788
|
+
});
|
|
2789
|
+
h += '</div>';
|
|
2790
|
+
}
|
|
2791
|
+
|
|
2792
|
+
// Form Data Fields
|
|
2793
|
+
if (hasFormFields) {
|
|
2794
|
+
h += '<div class="stitle">Request Fields <span style="font-weight:400;text-transform:none;letter-spacing:0;color:#64748b">(' + (t.bodyType || 'form-data') + ')</span></div>';
|
|
2795
|
+
t.formFields.forEach(function(f) {
|
|
2796
|
+
if (!f.key) return;
|
|
2797
|
+
var isFile = f.type === 'file';
|
|
2798
|
+
h += '<div class="field-row">';
|
|
2799
|
+
h += '<span class="field-name">' + esc(f.key) + '</span>';
|
|
2800
|
+
if (isFile) {
|
|
2801
|
+
h += '<span class="field-type" style="background:rgba(245,158,11,0.12);color:#f59e0b">File</span>';
|
|
2802
|
+
} else {
|
|
2803
|
+
h += '<span class="field-type">Text</span>';
|
|
2804
|
+
}
|
|
2805
|
+
if (f.value && !isFile) h += '<span style="font-size:11px;color:#64748b;font-family:monospace;margin-left:auto">' + esc(f.value) + '</span>';
|
|
2806
|
+
h += '</div>';
|
|
2807
|
+
});
|
|
2808
|
+
h += '<div style="margin-top:12px"></div>';
|
|
2809
|
+
}
|
|
2810
|
+
|
|
2811
|
+
// JSON Body Fields
|
|
2812
|
+
if (!isFormData && hasJsonBody && bodyFields.length > 0) {
|
|
2813
|
+
h += '<div class="stitle">Body Fields</div>';
|
|
2814
|
+
var bodyObj = JSON.parse(t.body);
|
|
2815
|
+
bodyFields.forEach(function(f) {
|
|
2816
|
+
var val = bodyObj[f];
|
|
2817
|
+
var type = typeof val === 'number' ? 'Number' : typeof val === 'boolean' ? 'Boolean' : 'String';
|
|
2818
|
+
h += '<div class="field-row"><span class="field-name">' + esc(f) + '</span><span class="field-type">' + type + '</span></div>';
|
|
2819
|
+
});
|
|
2820
|
+
h += '<div style="margin-top:12px"></div>';
|
|
2821
|
+
}
|
|
2822
|
+
|
|
2823
|
+
// Request Body Example (JSON only)
|
|
2824
|
+
if (!isFormData && hasJsonBody) {
|
|
2825
|
+
h += '<div class="stitle">Request Body Example</div>';
|
|
2826
|
+
h += '<pre style="margin-bottom:16px">' + esc(formatted) + '</pre>';
|
|
2827
|
+
}
|
|
2828
|
+
|
|
2829
|
+
// Code Snippets
|
|
2830
|
+
var codeTab = activeCodeTab[id] || 'curl';
|
|
2831
|
+
h += '<div class="stitle">Code Examples</div>';
|
|
2832
|
+
h += '<div style="background:#0f1117;border:1px solid #1e2433;border-radius:8px;overflow:hidden;margin-bottom:16px">';
|
|
2833
|
+
h += '<div class="code-tabs">';
|
|
2834
|
+
['curl','fetch','axios'].forEach(function(tab) {
|
|
2835
|
+
h += '<button class="code-tab' + (codeTab === tab ? ' active' : '') + '" data-codetab="' + tab + '" data-testid="' + id + '">' + (tab === 'curl' ? 'cURL' : tab.charAt(0).toUpperCase() + tab.slice(1)) + '</button>';
|
|
2836
|
+
});
|
|
2837
|
+
h += '</div>';
|
|
2838
|
+
var code = codeTab === 'curl' ? genCurl(t) : codeTab === 'fetch' ? genFetch(t) : genAxios(t);
|
|
2839
|
+
h += '<div style="position:relative"><pre style="border:none;border-radius:0;margin:0">' + esc(code) + '</pre>';
|
|
2840
|
+
h += '<button class="copy-btn" style="position:absolute;top:8px;right:8px" data-copy="prev-pre">Copy</button>';
|
|
2841
|
+
h += '</div></div>';
|
|
2842
|
+
|
|
2843
|
+
h += '</div>';
|
|
2844
|
+
|
|
2845
|
+
// Saved Responses
|
|
2846
|
+
var resps = responses[id] || [];
|
|
2847
|
+
if (resps.length > 0) {
|
|
2848
|
+
h += '<div class="stitle" style="margin-top:8px">Saved Responses (' + resps.length + ')</div>';
|
|
2849
|
+
resps.forEach(function(r) {
|
|
2850
|
+
var sc = Math.floor(r.status/100);
|
|
2851
|
+
var body = r.body || ''; try { body = JSON.stringify(JSON.parse(body), null, 2); } catch(e) {}
|
|
2852
|
+
h += '<div class="card" style="padding:0;overflow:hidden">';
|
|
2853
|
+
h += '<div style="padding:10px 16px;border-bottom:1px solid #1e2433;display:flex;align-items:center;gap:10px">';
|
|
2854
|
+
h += '<span class="sb s' + sc + '" style="font-size:11px;padding:3px 8px">' + r.status + ' ' + esc(r.statusText||'') + '</span>';
|
|
2855
|
+
h += '<span style="font-size:13px;font-weight:500">' + esc(r.label) + '</span>';
|
|
2856
|
+
if (r.time) h += '<span style="font-size:11px;color:#475569;margin-left:auto">' + r.time + 'ms</span>';
|
|
2857
|
+
h += '</div>';
|
|
2858
|
+
if (r.requestBody && r.requestBody.trim()) {
|
|
2859
|
+
var rb = r.requestBody; try { rb = JSON.stringify(JSON.parse(rb), null, 2); } catch(e) {}
|
|
2860
|
+
h += '<div style="padding:12px 16px;border-bottom:1px solid #1e2433"><div class="stitle" style="margin-bottom:6px">Request Body</div><pre style="margin:0">' + esc(rb) + '</pre></div>';
|
|
2861
|
+
}
|
|
2862
|
+
h += '<div style="padding:12px 16px"><div class="stitle" style="margin-bottom:6px">Response Body</div><pre style="margin:0">' + esc(body) + '</pre></div>';
|
|
2863
|
+
h += '</div>';
|
|
2864
|
+
});
|
|
2865
|
+
}
|
|
2866
|
+
|
|
2867
|
+
main.innerHTML = h;
|
|
2868
|
+
}
|
|
2869
|
+
|
|
2870
|
+
function renderRespView(testId, respId) {
|
|
2871
|
+
var resps = responses[testId] || [];
|
|
2872
|
+
var r = resps.find(function(x){ return x.id === respId; });
|
|
2873
|
+
if (!r) return;
|
|
2874
|
+
var main = document.getElementById('main-content');
|
|
2875
|
+
var t = tests.find(function(x){ return x.id === testId; });
|
|
2876
|
+
var isSocket = r.dir;
|
|
2877
|
+
|
|
2878
|
+
var h = '<div class="card"><div class="card-header">';
|
|
2879
|
+
if (isSocket) {
|
|
2880
|
+
var dirCls = r.dir === 'received' ? 'dir-recv' : r.dir === 'sent' ? 'dir-sent' : 'dir-sys';
|
|
2881
|
+
h += '<span class="sb ' + dirCls + '" style="font-size:12px;padding:4px 10px">' + (r.dir === 'received' ? 'Received' : r.dir === 'sent' ? 'Sent' : 'System') + '</span>';
|
|
2882
|
+
} else {
|
|
2883
|
+
h += '<span class="sb s' + Math.floor(r.status/100) + '" style="font-size:12px;padding:4px 10px">' + r.status + ' ' + esc(r.statusText||'') + '</span>';
|
|
2884
|
+
}
|
|
2885
|
+
h += '<span style="font-size:16px;font-weight:600">' + esc(r.label) + '</span>';
|
|
2886
|
+
if (!isSocket && r.time) h += '<span style="font-size:11px;color:#475569;margin-left:auto">' + r.time + 'ms</span>';
|
|
2887
|
+
h += '</div>';
|
|
2888
|
+
|
|
2889
|
+
if (t) { h += '<div class="endpoint-url" style="margin-bottom:16px"><code>' + esc(t.url) + '</code></div>'; }
|
|
2890
|
+
|
|
2891
|
+
if (!isSocket && r.requestBody && r.requestBody.trim()) {
|
|
2892
|
+
var rb = r.requestBody; try { rb = JSON.stringify(JSON.parse(rb), null, 2); } catch(e) {}
|
|
2893
|
+
h += '<div class="stitle">Request Body</div><pre style="margin-bottom:16px">' + esc(rb) + '</pre>';
|
|
2894
|
+
}
|
|
2895
|
+
var body = r.body || ''; try { body = JSON.stringify(JSON.parse(body), null, 2); } catch(e) {}
|
|
2896
|
+
h += '<div class="stitle">' + (isSocket ? 'Data' : 'Response Body') + '</div><pre>' + esc(body) + '</pre>';
|
|
2897
|
+
h += '</div>';
|
|
2898
|
+
|
|
2899
|
+
main.innerHTML = h;
|
|
2900
|
+
}
|
|
2901
|
+
|
|
2902
|
+
// Code tab switching
|
|
2903
|
+
document.getElementById('main-content').addEventListener('click', function(e) {
|
|
2904
|
+
var tab = e.target.closest('[data-codetab]');
|
|
2905
|
+
if (tab) {
|
|
2906
|
+
var testId = tab.getAttribute('data-testid');
|
|
2907
|
+
activeCodeTab[testId] = tab.getAttribute('data-codetab');
|
|
2908
|
+
renderTestView(testId);
|
|
2909
|
+
return;
|
|
2910
|
+
}
|
|
2911
|
+
});
|
|
2912
|
+
|
|
2913
|
+
renderSidebar();
|
|
2914
|
+
if (tests.length > 0) {
|
|
2915
|
+
activeTestId = tests[0].id;
|
|
2916
|
+
var firstResps = responses[tests[0].id] || [];
|
|
2917
|
+
if (firstResps.length > 0) expanded[tests[0].id] = true;
|
|
2918
|
+
renderSidebar();
|
|
2919
|
+
renderTestView(tests[0].id);
|
|
2920
|
+
}
|
|
2921
|
+
})();
|
|
2922
|
+
</script></body></html>`;
|
|
2923
|
+
|
|
2924
|
+
res.type('html').send(html);
|
|
2925
|
+
} catch (err) {
|
|
2926
|
+
res.status(500).send('Error loading collection');
|
|
2927
|
+
}
|
|
2928
|
+
});
|
|
2929
|
+
|
|
2930
|
+
export default router;
|