@doppelgangerdev/doppelganger 0.2.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/.dockerignore +9 -0
- package/.github/workflows/docker-publish.yml +59 -0
- package/CODE_OF_CONDUCT.md +28 -0
- package/CONTRIBUTING.md +42 -0
- package/Dockerfile +44 -0
- package/LICENSE +163 -0
- package/README.md +133 -0
- package/TERMS.md +16 -0
- package/THIRD_PARTY_LICENSES.md +3502 -0
- package/agent.js +1240 -0
- package/headful.js +171 -0
- package/index.html +21 -0
- package/n8n-nodes-doppelganger/LICENSE +201 -0
- package/n8n-nodes-doppelganger/README.md +42 -0
- package/n8n-nodes-doppelganger/package-lock.json +6128 -0
- package/n8n-nodes-doppelganger/package.json +36 -0
- package/n8n-nodes-doppelganger/src/credentials/DoppelgangerApi.credentials.ts +35 -0
- package/n8n-nodes-doppelganger/src/index.ts +4 -0
- package/n8n-nodes-doppelganger/src/nodes/Doppelganger/Doppelganger.node.ts +147 -0
- package/n8n-nodes-doppelganger/src/nodes/Doppelganger/icon.png +0 -0
- package/n8n-nodes-doppelganger/tsconfig.json +14 -0
- package/package.json +45 -0
- package/postcss.config.js +6 -0
- package/public/icon.png +0 -0
- package/public/novnc.html +151 -0
- package/public/styles.css +86 -0
- package/scrape.js +389 -0
- package/server.js +875 -0
- package/src/App.tsx +722 -0
- package/src/components/AuthScreen.tsx +95 -0
- package/src/components/CodeEditor.tsx +70 -0
- package/src/components/DashboardScreen.tsx +133 -0
- package/src/components/EditorScreen.tsx +1519 -0
- package/src/components/ExecutionDetailScreen.tsx +115 -0
- package/src/components/ExecutionsScreen.tsx +156 -0
- package/src/components/LoadingScreen.tsx +26 -0
- package/src/components/NotFoundScreen.tsx +34 -0
- package/src/components/RichInput.tsx +68 -0
- package/src/components/SettingsScreen.tsx +228 -0
- package/src/components/Sidebar.tsx +61 -0
- package/src/components/app/CenterAlert.tsx +44 -0
- package/src/components/app/CenterConfirm.tsx +33 -0
- package/src/components/app/EditorLoader.tsx +89 -0
- package/src/components/editor/ActionPalette.tsx +79 -0
- package/src/components/editor/JsonEditorPane.tsx +71 -0
- package/src/components/editor/ResultsPane.tsx +641 -0
- package/src/components/editor/actionCatalog.ts +23 -0
- package/src/components/settings/AgentAiPanel.tsx +105 -0
- package/src/components/settings/ApiKeyPanel.tsx +68 -0
- package/src/components/settings/CookiesPanel.tsx +154 -0
- package/src/components/settings/LayoutPanel.tsx +46 -0
- package/src/components/settings/ScreenshotsPanel.tsx +64 -0
- package/src/components/settings/SettingsHeader.tsx +28 -0
- package/src/components/settings/StoragePanel.tsx +35 -0
- package/src/index.css +287 -0
- package/src/main.tsx +13 -0
- package/src/types.ts +114 -0
- package/src/utils/syntaxHighlight.ts +140 -0
- package/start-vnc.sh +52 -0
- package/tailwind.config.js +22 -0
- package/tsconfig.json +39 -0
- package/tsconfig.node.json +12 -0
- package/vite.config.mts +27 -0
package/server.js
ADDED
|
@@ -0,0 +1,875 @@
|
|
|
1
|
+
const express = require('express');
|
|
2
|
+
const session = require('express-session');
|
|
3
|
+
const FileStore = require('session-file-store')(session);
|
|
4
|
+
const bcrypt = require('bcryptjs');
|
|
5
|
+
const crypto = require('crypto');
|
|
6
|
+
const fs = require('fs');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
const net = require('net');
|
|
9
|
+
const app = express();
|
|
10
|
+
const DEFAULT_PORT = 11345;
|
|
11
|
+
const port = Number(process.env.PORT) || DEFAULT_PORT;
|
|
12
|
+
const DIST_DIR = path.join(__dirname, 'dist');
|
|
13
|
+
const SESSION_SECRET_FILE = path.join(__dirname, 'data', 'session_secret.txt');
|
|
14
|
+
let SESSION_SECRET = process.env.SESSION_SECRET;
|
|
15
|
+
|
|
16
|
+
if (!SESSION_SECRET) {
|
|
17
|
+
try {
|
|
18
|
+
if (fs.existsSync(SESSION_SECRET_FILE)) {
|
|
19
|
+
SESSION_SECRET = fs.readFileSync(SESSION_SECRET_FILE, 'utf8').trim();
|
|
20
|
+
} else {
|
|
21
|
+
SESSION_SECRET = crypto.randomBytes(48).toString('hex');
|
|
22
|
+
fs.writeFileSync(SESSION_SECRET_FILE, SESSION_SECRET);
|
|
23
|
+
}
|
|
24
|
+
} catch (e) {
|
|
25
|
+
console.warn('Failed to load session secret from disk, falling back to process env only.');
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (!SESSION_SECRET) {
|
|
30
|
+
throw new Error('SESSION_SECRET environment variable is required');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const USERS_FILE = path.join(__dirname, 'data', 'users.json');
|
|
34
|
+
|
|
35
|
+
// Ensure data directory exists
|
|
36
|
+
if (!fs.existsSync(path.join(__dirname, 'data'))) {
|
|
37
|
+
fs.mkdirSync(path.join(__dirname, 'data'));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Ensure sessions directory exists
|
|
41
|
+
const SESSIONS_DIR = path.join(__dirname, 'data', 'sessions');
|
|
42
|
+
if (!fs.existsSync(SESSIONS_DIR)) {
|
|
43
|
+
fs.mkdirSync(SESSIONS_DIR, { recursive: true });
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Helper to load users
|
|
47
|
+
function loadUsers() {
|
|
48
|
+
if (!fs.existsSync(USERS_FILE)) return [];
|
|
49
|
+
try {
|
|
50
|
+
return JSON.parse(fs.readFileSync(USERS_FILE, 'utf8'));
|
|
51
|
+
} catch (e) {
|
|
52
|
+
return [];
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Helper to save users
|
|
57
|
+
function saveUsers(users) {
|
|
58
|
+
fs.writeFileSync(USERS_FILE, JSON.stringify(users, null, 2));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const TASKS_FILE = path.join(__dirname, 'data', 'tasks.json');
|
|
62
|
+
const API_KEY_FILE = path.join(__dirname, 'data', 'api_key.json');
|
|
63
|
+
const STORAGE_STATE_PATH = path.join(__dirname, 'storage_state.json');
|
|
64
|
+
const STORAGE_STATE_FILE = (() => {
|
|
65
|
+
try {
|
|
66
|
+
if (fs.existsSync(STORAGE_STATE_PATH)) {
|
|
67
|
+
const stat = fs.statSync(STORAGE_STATE_PATH);
|
|
68
|
+
if (stat.isDirectory()) {
|
|
69
|
+
return path.join(STORAGE_STATE_PATH, 'storage_state.json');
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
} catch {}
|
|
73
|
+
return STORAGE_STATE_PATH;
|
|
74
|
+
})();
|
|
75
|
+
const MAX_TASK_VERSIONS = 30;
|
|
76
|
+
const EXECUTIONS_FILE = path.join(__dirname, 'data', 'executions.json');
|
|
77
|
+
const MAX_EXECUTIONS = 500;
|
|
78
|
+
const executionStreams = new Map();
|
|
79
|
+
const stopRequests = new Set();
|
|
80
|
+
|
|
81
|
+
const sendExecutionUpdate = (runId, payload) => {
|
|
82
|
+
if (!runId) return;
|
|
83
|
+
const clients = executionStreams.get(runId);
|
|
84
|
+
if (!clients || clients.size === 0) return;
|
|
85
|
+
const data = `data: ${JSON.stringify(payload)}\n\n`;
|
|
86
|
+
clients.forEach((res) => {
|
|
87
|
+
try {
|
|
88
|
+
res.write(data);
|
|
89
|
+
} catch {
|
|
90
|
+
// ignore
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
// Helper to load tasks
|
|
96
|
+
function loadTasks() {
|
|
97
|
+
if (!fs.existsSync(TASKS_FILE)) return [];
|
|
98
|
+
try {
|
|
99
|
+
return JSON.parse(fs.readFileSync(TASKS_FILE, 'utf8'));
|
|
100
|
+
} catch (e) {
|
|
101
|
+
return [];
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Helper to save tasks
|
|
106
|
+
function saveTasks(tasks) {
|
|
107
|
+
fs.writeFileSync(TASKS_FILE, JSON.stringify(tasks, null, 2));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function cloneTaskForVersion(task) {
|
|
111
|
+
const copy = JSON.parse(JSON.stringify(task || {}));
|
|
112
|
+
if (copy.versions) delete copy.versions;
|
|
113
|
+
return copy;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function appendTaskVersion(task) {
|
|
117
|
+
if (!task) return;
|
|
118
|
+
if (!task.versions) task.versions = [];
|
|
119
|
+
const version = {
|
|
120
|
+
id: 'ver_' + Date.now(),
|
|
121
|
+
timestamp: Date.now(),
|
|
122
|
+
snapshot: cloneTaskForVersion(task)
|
|
123
|
+
};
|
|
124
|
+
task.versions.unshift(version);
|
|
125
|
+
if (task.versions.length > MAX_TASK_VERSIONS) {
|
|
126
|
+
task.versions = task.versions.slice(0, MAX_TASK_VERSIONS);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function loadExecutions() {
|
|
131
|
+
if (!fs.existsSync(EXECUTIONS_FILE)) return [];
|
|
132
|
+
try {
|
|
133
|
+
return JSON.parse(fs.readFileSync(EXECUTIONS_FILE, 'utf8'));
|
|
134
|
+
} catch (e) {
|
|
135
|
+
return [];
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function saveExecutions(executions) {
|
|
140
|
+
fs.writeFileSync(EXECUTIONS_FILE, JSON.stringify(executions, null, 2));
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function appendExecution(entry) {
|
|
144
|
+
const executions = loadExecutions();
|
|
145
|
+
executions.unshift(entry);
|
|
146
|
+
if (executions.length > MAX_EXECUTIONS) {
|
|
147
|
+
executions.length = MAX_EXECUTIONS;
|
|
148
|
+
}
|
|
149
|
+
saveExecutions(executions);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function registerExecution(req, res, baseMeta = {}) {
|
|
153
|
+
const start = Date.now();
|
|
154
|
+
const requestId = 'exec_' + start + '_' + Math.floor(Math.random() * 1000);
|
|
155
|
+
res.locals.executionId = requestId;
|
|
156
|
+
const originalJson = res.json.bind(res);
|
|
157
|
+
res.json = (body) => {
|
|
158
|
+
res.locals.executionResult = body;
|
|
159
|
+
return originalJson(body);
|
|
160
|
+
};
|
|
161
|
+
res.on('finish', () => {
|
|
162
|
+
const durationMs = Date.now() - start;
|
|
163
|
+
const body = req.body || {};
|
|
164
|
+
const entry = {
|
|
165
|
+
id: requestId,
|
|
166
|
+
timestamp: start,
|
|
167
|
+
method: req.method,
|
|
168
|
+
path: req.path,
|
|
169
|
+
status: res.statusCode,
|
|
170
|
+
durationMs,
|
|
171
|
+
source: body.runSource || req.query.runSource || baseMeta.source || 'unknown',
|
|
172
|
+
mode: body.mode || baseMeta.mode || 'unknown',
|
|
173
|
+
taskId: body.taskId || baseMeta.taskId || null,
|
|
174
|
+
taskName: body.name || baseMeta.taskName || null,
|
|
175
|
+
url: body.url || req.query.url || null,
|
|
176
|
+
taskSnapshot: body.taskSnapshot || null,
|
|
177
|
+
result: res.locals.executionResult || null
|
|
178
|
+
};
|
|
179
|
+
appendExecution(entry);
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Helper to load API key
|
|
184
|
+
function loadApiKey() {
|
|
185
|
+
let apiKey = null;
|
|
186
|
+
if (fs.existsSync(API_KEY_FILE)) {
|
|
187
|
+
try {
|
|
188
|
+
const data = JSON.parse(fs.readFileSync(API_KEY_FILE, 'utf8'));
|
|
189
|
+
apiKey = data && data.apiKey ? data.apiKey : null;
|
|
190
|
+
} catch (e) {
|
|
191
|
+
apiKey = null;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (!apiKey && fs.existsSync(USERS_FILE)) {
|
|
196
|
+
try {
|
|
197
|
+
const users = JSON.parse(fs.readFileSync(USERS_FILE, 'utf8'));
|
|
198
|
+
if (Array.isArray(users) && users.length > 0 && users[0].apiKey) {
|
|
199
|
+
apiKey = users[0].apiKey;
|
|
200
|
+
saveApiKey(apiKey);
|
|
201
|
+
}
|
|
202
|
+
} catch (e) {
|
|
203
|
+
// ignore
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return apiKey;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Helper to save API key
|
|
211
|
+
function saveApiKey(apiKey) {
|
|
212
|
+
fs.writeFileSync(API_KEY_FILE, JSON.stringify({ apiKey }, null, 2));
|
|
213
|
+
if (fs.existsSync(USERS_FILE)) {
|
|
214
|
+
try {
|
|
215
|
+
const users = JSON.parse(fs.readFileSync(USERS_FILE, 'utf8'));
|
|
216
|
+
if (Array.isArray(users) && users.length > 0) {
|
|
217
|
+
users[0].apiKey = apiKey;
|
|
218
|
+
fs.writeFileSync(USERS_FILE, JSON.stringify(users, null, 2));
|
|
219
|
+
}
|
|
220
|
+
} catch (e) {
|
|
221
|
+
// ignore
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function generateApiKey() {
|
|
227
|
+
return crypto.randomBytes(32).toString('hex');
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const { handleScrape } = require('./scrape');
|
|
231
|
+
const { handleAgent, setProgressReporter, setStopChecker } = require('./agent');
|
|
232
|
+
const { handleHeadful, stopHeadful } = require('./headful');
|
|
233
|
+
|
|
234
|
+
setProgressReporter(sendExecutionUpdate);
|
|
235
|
+
setStopChecker((runId) => {
|
|
236
|
+
if (!runId) return false;
|
|
237
|
+
if (stopRequests.has(runId)) {
|
|
238
|
+
stopRequests.delete(runId);
|
|
239
|
+
return true;
|
|
240
|
+
}
|
|
241
|
+
return false;
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
app.use(express.json());
|
|
245
|
+
const SESSION_TTL_SECONDS = 10 * 365 * 24 * 60 * 60; // 10 years
|
|
246
|
+
|
|
247
|
+
app.use(session({
|
|
248
|
+
store: new FileStore({
|
|
249
|
+
path: SESSIONS_DIR,
|
|
250
|
+
ttl: SESSION_TTL_SECONDS,
|
|
251
|
+
retries: 0
|
|
252
|
+
}),
|
|
253
|
+
secret: SESSION_SECRET,
|
|
254
|
+
resave: false,
|
|
255
|
+
saveUninitialized: false,
|
|
256
|
+
cookie: {
|
|
257
|
+
secure: false,
|
|
258
|
+
maxAge: SESSION_TTL_SECONDS * 1000
|
|
259
|
+
}
|
|
260
|
+
}));
|
|
261
|
+
|
|
262
|
+
// Auth Middleware
|
|
263
|
+
const requireAuth = (req, res, next) => {
|
|
264
|
+
console.log(`[AUTH] Path: ${req.path}, Session: ${req.session.user ? 'YES' : 'NO'}`);
|
|
265
|
+
if (req.session.user) {
|
|
266
|
+
next();
|
|
267
|
+
} else {
|
|
268
|
+
if (req.xhr || req.path.startsWith('/api/')) {
|
|
269
|
+
res.status(401).json({ error: 'UNAUTHORIZED' });
|
|
270
|
+
} else {
|
|
271
|
+
res.redirect('/login');
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
const requireAuthForSettings = (req, res, next) => {
|
|
277
|
+
if (process.env.NODE_ENV !== 'production') return next();
|
|
278
|
+
return requireAuth(req, res, next);
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
const requireApiKey = (req, res, next) => {
|
|
282
|
+
const headerKey = req.get('x-api-key') || req.get('key');
|
|
283
|
+
const authHeader = req.get('authorization');
|
|
284
|
+
const bearerKey = authHeader ? authHeader.replace(/^Bearer\s+/i, '') : '';
|
|
285
|
+
const bodyKey = typeof req.body === 'string' ? req.body : null;
|
|
286
|
+
const providedKey =
|
|
287
|
+
headerKey ||
|
|
288
|
+
bearerKey ||
|
|
289
|
+
req.query.apiKey ||
|
|
290
|
+
req.query.key ||
|
|
291
|
+
(req.body && (req.body.apiKey || req.body.key)) ||
|
|
292
|
+
bodyKey;
|
|
293
|
+
const storedKey = loadApiKey();
|
|
294
|
+
|
|
295
|
+
if (!storedKey) {
|
|
296
|
+
return res.status(403).json({ error: 'API_KEY_NOT_SET' });
|
|
297
|
+
}
|
|
298
|
+
if (!providedKey || providedKey !== storedKey) {
|
|
299
|
+
return res.status(401).json({ error: 'INVALID_API_KEY' });
|
|
300
|
+
}
|
|
301
|
+
next();
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
// --- AUTH API ---
|
|
305
|
+
app.get('/api/auth/check-setup', (req, res) => {
|
|
306
|
+
console.log("DEBUG: Hit check-setup");
|
|
307
|
+
try {
|
|
308
|
+
const users = loadUsers();
|
|
309
|
+
console.log("DEBUG: check-setup users length:", users.length);
|
|
310
|
+
res.json({ setupRequired: users.length === 0 });
|
|
311
|
+
} catch (e) {
|
|
312
|
+
console.error("DEBUG: check-setup error", e);
|
|
313
|
+
res.status(500).json({ error: e.message });
|
|
314
|
+
}
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
app.post('/api/auth/setup', async (req, res) => {
|
|
318
|
+
const users = loadUsers();
|
|
319
|
+
if (users.length > 0) return res.status(403).json({ error: 'ALREADY_SETUP' });
|
|
320
|
+
const { name, email, password } = req.body;
|
|
321
|
+
const normalizedEmail = String(email || '').trim().toLowerCase();
|
|
322
|
+
if (!name || !normalizedEmail || !password) return res.status(400).json({ error: 'MISSING_FIELDS' });
|
|
323
|
+
|
|
324
|
+
const hashedPassword = await bcrypt.hash(password, 10);
|
|
325
|
+
const newUser = { id: Date.now(), name, email: normalizedEmail, password: hashedPassword };
|
|
326
|
+
saveUsers([newUser]);
|
|
327
|
+
req.session.user = { id: newUser.id, name: newUser.name, email: newUser.email };
|
|
328
|
+
res.json({ success: true });
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
app.post('/api/auth/login', async (req, res) => {
|
|
332
|
+
const { email, password } = req.body;
|
|
333
|
+
const normalizedEmail = String(email || '').trim().toLowerCase();
|
|
334
|
+
const users = loadUsers();
|
|
335
|
+
const user = users.find(u => String(u.email || '').toLowerCase() === normalizedEmail);
|
|
336
|
+
if (user && await bcrypt.compare(password, user.password)) {
|
|
337
|
+
req.session.user = { id: user.id, name: user.name, email: user.email };
|
|
338
|
+
res.json({ success: true });
|
|
339
|
+
} else {
|
|
340
|
+
res.status(401).json({ error: 'INVALID' });
|
|
341
|
+
}
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
app.post('/api/auth/logout', (req, res) => {
|
|
345
|
+
req.session.destroy(() => {
|
|
346
|
+
res.clearCookie('connect.sid');
|
|
347
|
+
res.json({ success: true });
|
|
348
|
+
});
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
app.get('/api/auth/me', (req, res) => {
|
|
352
|
+
res.json(req.session.user ? { authenticated: true, user: req.session.user } : { authenticated: false });
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
// --- SETTINGS API ---
|
|
356
|
+
app.get('/api/settings/api-key', requireAuthForSettings, (req, res) => {
|
|
357
|
+
try {
|
|
358
|
+
const apiKey = loadApiKey();
|
|
359
|
+
res.json({ apiKey: apiKey || null });
|
|
360
|
+
} catch (e) {
|
|
361
|
+
console.error('[API_KEY] Load failed:', e);
|
|
362
|
+
res.status(500).json({ error: 'API_KEY_LOAD_FAILED' });
|
|
363
|
+
}
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
app.post('/api/settings/api-key', requireAuthForSettings, (req, res) => {
|
|
367
|
+
try {
|
|
368
|
+
const bodyKey = req.body && typeof req.body.apiKey === 'string' ? req.body.apiKey.trim() : '';
|
|
369
|
+
const apiKey = bodyKey || generateApiKey();
|
|
370
|
+
saveApiKey(apiKey);
|
|
371
|
+
res.json({ apiKey });
|
|
372
|
+
} catch (e) {
|
|
373
|
+
console.error('[API_KEY] Save failed:', e);
|
|
374
|
+
res.status(500).json({ error: 'API_KEY_SAVE_FAILED', message: e.message });
|
|
375
|
+
}
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
app.post('/api/clear-screenshots', requireAuth, (req, res) => {
|
|
380
|
+
const screenshotsDir = path.join(__dirname, 'public', 'screenshots');
|
|
381
|
+
if (fs.existsSync(screenshotsDir)) {
|
|
382
|
+
for (const entry of fs.readdirSync(screenshotsDir)) {
|
|
383
|
+
const entryPath = path.join(screenshotsDir, entry);
|
|
384
|
+
if (fs.statSync(entryPath).isFile()) {
|
|
385
|
+
fs.unlinkSync(entryPath);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
res.json({ success: true });
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
app.post('/api/clear-cookies', requireAuth, (req, res) => {
|
|
393
|
+
if (fs.existsSync(STORAGE_STATE_FILE)) {
|
|
394
|
+
fs.unlinkSync(STORAGE_STATE_FILE);
|
|
395
|
+
}
|
|
396
|
+
res.json({ success: true });
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
// --- TASKS API ---
|
|
400
|
+
app.get('/api/tasks', requireAuth, (req, res) => {
|
|
401
|
+
res.json(loadTasks());
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
app.post('/api/tasks', requireAuth, (req, res) => {
|
|
405
|
+
const tasks = loadTasks();
|
|
406
|
+
const newTask = req.body;
|
|
407
|
+
if (!newTask.id) newTask.id = 'task_' + Date.now();
|
|
408
|
+
|
|
409
|
+
const index = tasks.findIndex(t => t.id === newTask.id);
|
|
410
|
+
if (index > -1) {
|
|
411
|
+
appendTaskVersion(tasks[index]);
|
|
412
|
+
newTask.versions = tasks[index].versions || [];
|
|
413
|
+
tasks[index] = newTask;
|
|
414
|
+
} else {
|
|
415
|
+
newTask.versions = [];
|
|
416
|
+
tasks.push(newTask);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
saveTasks(tasks);
|
|
420
|
+
res.json(newTask);
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
app.post('/api/tasks/:id/touch', requireAuth, (req, res) => {
|
|
424
|
+
const tasks = loadTasks();
|
|
425
|
+
const index = tasks.findIndex(t => t.id === req.params.id);
|
|
426
|
+
if (index === -1) return res.status(404).json({ error: 'TASK_NOT_FOUND' });
|
|
427
|
+
tasks[index].last_opened = Date.now();
|
|
428
|
+
saveTasks(tasks);
|
|
429
|
+
res.json(tasks[index]);
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
app.delete('/api/tasks/:id', requireAuth, (req, res) => {
|
|
433
|
+
let tasks = loadTasks();
|
|
434
|
+
tasks = tasks.filter(t => t.id !== req.params.id);
|
|
435
|
+
saveTasks(tasks);
|
|
436
|
+
res.json({ success: true });
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
app.get('/api/executions', requireAuth, (req, res) => {
|
|
440
|
+
const executions = loadExecutions();
|
|
441
|
+
res.json({ executions });
|
|
442
|
+
});
|
|
443
|
+
app.get('/api/executions/stream', requireAuth, (req, res) => {
|
|
444
|
+
const runId = String(req.query.runId || '').trim();
|
|
445
|
+
if (!runId) return res.status(400).json({ error: 'MISSING_RUN_ID' });
|
|
446
|
+
res.setHeader('Content-Type', 'text/event-stream');
|
|
447
|
+
res.setHeader('Cache-Control', 'no-cache');
|
|
448
|
+
res.setHeader('Connection', 'keep-alive');
|
|
449
|
+
if (typeof res.flushHeaders === 'function') res.flushHeaders();
|
|
450
|
+
res.write('event: ready\ndata: {}\n\n');
|
|
451
|
+
|
|
452
|
+
let clients = executionStreams.get(runId);
|
|
453
|
+
if (!clients) {
|
|
454
|
+
clients = new Set();
|
|
455
|
+
executionStreams.set(runId, clients);
|
|
456
|
+
}
|
|
457
|
+
clients.add(res);
|
|
458
|
+
|
|
459
|
+
const keepAlive = setInterval(() => {
|
|
460
|
+
try {
|
|
461
|
+
res.write(':keep-alive\n\n');
|
|
462
|
+
} catch {
|
|
463
|
+
// ignore
|
|
464
|
+
}
|
|
465
|
+
}, 20000);
|
|
466
|
+
|
|
467
|
+
req.on('close', () => {
|
|
468
|
+
clearInterval(keepAlive);
|
|
469
|
+
clients.delete(res);
|
|
470
|
+
if (clients.size === 0) executionStreams.delete(runId);
|
|
471
|
+
});
|
|
472
|
+
});
|
|
473
|
+
app.get('/api/executions/:id', requireAuth, (req, res) => {
|
|
474
|
+
const executions = loadExecutions();
|
|
475
|
+
const exec = executions.find(e => e.id === req.params.id);
|
|
476
|
+
if (!exec) return res.status(404).json({ error: 'EXECUTION_NOT_FOUND' });
|
|
477
|
+
res.json({ execution: exec });
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
app.post('/api/executions/clear', requireAuth, (req, res) => {
|
|
481
|
+
saveExecutions([]);
|
|
482
|
+
res.json({ success: true });
|
|
483
|
+
try {
|
|
484
|
+
if (runId) sendExecutionUpdate(runId, { status: 'stop_requested' });
|
|
485
|
+
} catch {
|
|
486
|
+
// ignore
|
|
487
|
+
}
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
app.post('/api/executions/stop', requireAuth, (req, res) => {
|
|
491
|
+
const runId = String(req.body?.runId || '').trim();
|
|
492
|
+
if (!runId) return res.status(400).json({ error: 'MISSING_RUN_ID' });
|
|
493
|
+
stopRequests.add(runId);
|
|
494
|
+
res.json({ success: true });
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
app.delete('/api/executions/:id', requireAuth, (req, res) => {
|
|
498
|
+
const id = req.params.id;
|
|
499
|
+
const executions = loadExecutions().filter(e => e.id !== id);
|
|
500
|
+
saveExecutions(executions);
|
|
501
|
+
res.json({ success: true });
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
app.get('/api/tasks/:id/versions', requireAuth, (req, res) => {
|
|
506
|
+
const tasks = loadTasks();
|
|
507
|
+
const task = tasks.find(t => t.id === req.params.id);
|
|
508
|
+
if (!task) return res.status(404).json({ error: 'TASK_NOT_FOUND' });
|
|
509
|
+
const versions = (task.versions || []).map(v => ({
|
|
510
|
+
id: v.id,
|
|
511
|
+
timestamp: v.timestamp,
|
|
512
|
+
name: v.snapshot?.name || task.name,
|
|
513
|
+
mode: v.snapshot?.mode || task.mode
|
|
514
|
+
}));
|
|
515
|
+
res.json({ versions });
|
|
516
|
+
});
|
|
517
|
+
app.get('/api/tasks/:id/versions/:versionId', requireAuth, (req, res) => {
|
|
518
|
+
const tasks = loadTasks();
|
|
519
|
+
const task = tasks.find(t => t.id === req.params.id);
|
|
520
|
+
if (!task) return res.status(404).json({ error: 'TASK_NOT_FOUND' });
|
|
521
|
+
const versions = task.versions || [];
|
|
522
|
+
const version = versions.find(v => v.id === req.params.versionId);
|
|
523
|
+
if (!version || !version.snapshot) return res.status(404).json({ error: 'VERSION_NOT_FOUND' });
|
|
524
|
+
res.json({ snapshot: version.snapshot, metadata: { id: version.id, timestamp: version.timestamp } });
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
app.post('/api/tasks/:id/versions/clear', requireAuth, (req, res) => {
|
|
528
|
+
const tasks = loadTasks();
|
|
529
|
+
const index = tasks.findIndex(t => t.id === req.params.id);
|
|
530
|
+
if (index === -1) return res.status(404).json({ error: 'TASK_NOT_FOUND' });
|
|
531
|
+
tasks[index].versions = [];
|
|
532
|
+
saveTasks(tasks);
|
|
533
|
+
res.json({ success: true });
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
app.post('/api/tasks/:id/rollback', requireAuth, (req, res) => {
|
|
537
|
+
const { versionId } = req.body || {};
|
|
538
|
+
if (!versionId) return res.status(400).json({ error: 'MISSING_VERSION_ID' });
|
|
539
|
+
const tasks = loadTasks();
|
|
540
|
+
const index = tasks.findIndex(t => t.id === req.params.id);
|
|
541
|
+
if (index === -1) return res.status(404).json({ error: 'TASK_NOT_FOUND' });
|
|
542
|
+
const task = tasks[index];
|
|
543
|
+
const versions = task.versions || [];
|
|
544
|
+
const version = versions.find(v => v.id === versionId);
|
|
545
|
+
if (!version || !version.snapshot) return res.status(404).json({ error: 'VERSION_NOT_FOUND' });
|
|
546
|
+
|
|
547
|
+
appendTaskVersion(task);
|
|
548
|
+
const restored = { ...cloneTaskForVersion(version.snapshot), id: task.id, versions: task.versions };
|
|
549
|
+
restored.last_opened = Date.now();
|
|
550
|
+
tasks[index] = restored;
|
|
551
|
+
saveTasks(tasks);
|
|
552
|
+
res.json(restored);
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
app.get('/api/data/screenshots', requireAuth, (req, res) => {
|
|
556
|
+
const screenshotsDir = path.join(__dirname, 'public', 'screenshots');
|
|
557
|
+
if (!fs.existsSync(screenshotsDir)) return res.json({ screenshots: [] });
|
|
558
|
+
const entries = fs.readdirSync(screenshotsDir)
|
|
559
|
+
.filter(name => name.toLowerCase().endsWith('.png'))
|
|
560
|
+
.map((name) => {
|
|
561
|
+
const fullPath = path.join(screenshotsDir, name);
|
|
562
|
+
const stat = fs.statSync(fullPath);
|
|
563
|
+
return {
|
|
564
|
+
name,
|
|
565
|
+
url: `/screenshots/${name}`,
|
|
566
|
+
size: stat.size,
|
|
567
|
+
modified: stat.mtimeMs
|
|
568
|
+
};
|
|
569
|
+
})
|
|
570
|
+
.sort((a, b) => b.modified - a.modified);
|
|
571
|
+
res.json({ screenshots: entries });
|
|
572
|
+
});
|
|
573
|
+
|
|
574
|
+
app.delete('/api/data/screenshots/:name', requireAuth, (req, res) => {
|
|
575
|
+
const name = req.params.name;
|
|
576
|
+
if (name.includes('..') || name.includes('/') || name.includes('\\')) {
|
|
577
|
+
return res.status(400).json({ error: 'INVALID_NAME' });
|
|
578
|
+
}
|
|
579
|
+
const targetPath = path.join(__dirname, 'public', 'screenshots', name);
|
|
580
|
+
if (fs.existsSync(targetPath)) {
|
|
581
|
+
fs.unlinkSync(targetPath);
|
|
582
|
+
}
|
|
583
|
+
res.json({ success: true });
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
app.get('/api/data/cookies', requireAuth, (req, res) => {
|
|
587
|
+
if (!fs.existsSync(STORAGE_STATE_FILE)) return res.json({ cookies: [], origins: [] });
|
|
588
|
+
try {
|
|
589
|
+
const data = JSON.parse(fs.readFileSync(STORAGE_STATE_FILE, 'utf8'));
|
|
590
|
+
res.json({
|
|
591
|
+
cookies: Array.isArray(data.cookies) ? data.cookies : [],
|
|
592
|
+
origins: Array.isArray(data.origins) ? data.origins : []
|
|
593
|
+
});
|
|
594
|
+
} catch (e) {
|
|
595
|
+
res.json({ cookies: [], origins: [] });
|
|
596
|
+
}
|
|
597
|
+
});
|
|
598
|
+
|
|
599
|
+
app.post('/api/data/cookies/delete', requireAuth, (req, res) => {
|
|
600
|
+
const { name, domain, path: cookiePath } = req.body || {};
|
|
601
|
+
if (!name) return res.status(400).json({ error: 'MISSING_NAME' });
|
|
602
|
+
if (!fs.existsSync(STORAGE_STATE_FILE)) return res.json({ success: true });
|
|
603
|
+
try {
|
|
604
|
+
const data = JSON.parse(fs.readFileSync(STORAGE_STATE_FILE, 'utf8'));
|
|
605
|
+
const cookies = Array.isArray(data.cookies) ? data.cookies : [];
|
|
606
|
+
const filtered = cookies.filter((cookie) => {
|
|
607
|
+
if (cookie.name !== name) return true;
|
|
608
|
+
if (domain && cookie.domain !== domain) return true;
|
|
609
|
+
if (cookiePath && cookie.path !== cookiePath) return true;
|
|
610
|
+
return false;
|
|
611
|
+
});
|
|
612
|
+
data.cookies = filtered;
|
|
613
|
+
fs.writeFileSync(STORAGE_STATE_FILE, JSON.stringify(data, null, 2));
|
|
614
|
+
res.json({ success: true });
|
|
615
|
+
} catch (e) {
|
|
616
|
+
res.status(500).json({ error: 'DELETE_FAILED' });
|
|
617
|
+
}
|
|
618
|
+
});
|
|
619
|
+
|
|
620
|
+
// --- TASK API EXECUTION ---
|
|
621
|
+
app.post('/tasks/:id/api', requireApiKey, async (req, res) => {
|
|
622
|
+
const tasks = loadTasks();
|
|
623
|
+
const task = tasks.find(t => String(t.id) === String(req.params.id));
|
|
624
|
+
if (!task) return res.status(404).json({ error: 'TASK_NOT_FOUND' });
|
|
625
|
+
|
|
626
|
+
const normalizeVariables = (vars) => {
|
|
627
|
+
const normalized = {};
|
|
628
|
+
if (!vars || typeof vars !== 'object') return normalized;
|
|
629
|
+
for (const [key, value] of Object.entries(vars)) {
|
|
630
|
+
if (value && typeof value === 'object' && 'value' in value) {
|
|
631
|
+
normalized[key] = value.value;
|
|
632
|
+
} else {
|
|
633
|
+
normalized[key] = value;
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
return normalized;
|
|
637
|
+
};
|
|
638
|
+
|
|
639
|
+
const baseVars = normalizeVariables(task.variables);
|
|
640
|
+
const overrideVars = normalizeVariables(req.body.variables || req.body.taskVariables || {});
|
|
641
|
+
const mergedVars = { ...baseVars, ...overrideVars };
|
|
642
|
+
|
|
643
|
+
const resolveTemplate = (input) => {
|
|
644
|
+
if (typeof input !== 'string') return input;
|
|
645
|
+
return input.replace(/\{\$(\w+)\}/g, (_match, name) => {
|
|
646
|
+
if (name === 'now') return new Date().toISOString();
|
|
647
|
+
const value = mergedVars[name];
|
|
648
|
+
if (value === undefined || value === null || value === '') return '';
|
|
649
|
+
return String(value);
|
|
650
|
+
});
|
|
651
|
+
};
|
|
652
|
+
|
|
653
|
+
const resolvedTask = {
|
|
654
|
+
...task,
|
|
655
|
+
url: resolveTemplate(task.url || ''),
|
|
656
|
+
selector: resolveTemplate(task.selector),
|
|
657
|
+
extractionScript: resolveTemplate(task.extractionScript || ''),
|
|
658
|
+
extractionFormat: task.extractionFormat || 'json',
|
|
659
|
+
includeShadowDom: task.includeShadowDom !== undefined ? task.includeShadowDom : true,
|
|
660
|
+
actions: Array.isArray(task.actions)
|
|
661
|
+
? task.actions.map((action) => ({
|
|
662
|
+
...action,
|
|
663
|
+
selector: resolveTemplate(action.selector),
|
|
664
|
+
value: resolveTemplate(action.value),
|
|
665
|
+
key: resolveTemplate(action.key)
|
|
666
|
+
}))
|
|
667
|
+
: []
|
|
668
|
+
};
|
|
669
|
+
|
|
670
|
+
req.body = {
|
|
671
|
+
...resolvedTask,
|
|
672
|
+
taskVariables: mergedVars,
|
|
673
|
+
variables: mergedVars,
|
|
674
|
+
runSource: 'api',
|
|
675
|
+
taskId: task.id,
|
|
676
|
+
taskName: task.name
|
|
677
|
+
};
|
|
678
|
+
|
|
679
|
+
const mode = resolvedTask.mode || 'scrape';
|
|
680
|
+
registerExecution(req, res, { source: 'api', mode, taskId: task.id, taskName: task.name });
|
|
681
|
+
if (mode === 'scrape') return handleScrape(req, res);
|
|
682
|
+
if (mode === 'agent') return handleAgent(req, res);
|
|
683
|
+
if (mode === 'headful') return handleHeadful(req, res);
|
|
684
|
+
return res.status(400).json({ error: 'UNSUPPORTED_MODE' });
|
|
685
|
+
});
|
|
686
|
+
|
|
687
|
+
// --- ROUTES ---
|
|
688
|
+
// Login page
|
|
689
|
+
app.get('/login', (req, res) => {
|
|
690
|
+
// Check if already logged in
|
|
691
|
+
if (req.session.user) {
|
|
692
|
+
return res.redirect('/');
|
|
693
|
+
}
|
|
694
|
+
// Check if setup is needed
|
|
695
|
+
const users = loadUsers();
|
|
696
|
+
if (users.length === 0) {
|
|
697
|
+
return res.redirect('/signup');
|
|
698
|
+
}
|
|
699
|
+
res.sendFile(path.join(DIST_DIR, 'index.html'));
|
|
700
|
+
});
|
|
701
|
+
|
|
702
|
+
// Signup/setup page
|
|
703
|
+
app.get('/signup', (req, res) => {
|
|
704
|
+
const users = loadUsers();
|
|
705
|
+
if (users.length > 0) {
|
|
706
|
+
return res.redirect('/login');
|
|
707
|
+
}
|
|
708
|
+
res.sendFile(path.join(DIST_DIR, 'index.html'));
|
|
709
|
+
});
|
|
710
|
+
|
|
711
|
+
// Dashboard (home)
|
|
712
|
+
app.get('/', requireAuth, (req, res) => {
|
|
713
|
+
res.sendFile(path.join(DIST_DIR, 'index.html'));
|
|
714
|
+
});
|
|
715
|
+
|
|
716
|
+
// Dashboard alias
|
|
717
|
+
app.get('/dashboard', requireAuth, (req, res) => {
|
|
718
|
+
res.sendFile(path.join(DIST_DIR, 'index.html'));
|
|
719
|
+
});
|
|
720
|
+
|
|
721
|
+
// Task editor - new task
|
|
722
|
+
app.get('/tasks/new', requireAuth, (req, res) => {
|
|
723
|
+
res.sendFile(path.join(DIST_DIR, 'index.html'));
|
|
724
|
+
});
|
|
725
|
+
|
|
726
|
+
// Task editor - existing task
|
|
727
|
+
app.get('/tasks/:id', requireAuth, (req, res) => {
|
|
728
|
+
console.log(`[ROUTE] /tasks/:id matched with id: ${req.params.id}`);
|
|
729
|
+
res.sendFile(path.join(DIST_DIR, 'index.html'));
|
|
730
|
+
});
|
|
731
|
+
|
|
732
|
+
// Settings
|
|
733
|
+
app.get('/settings', requireAuth, (req, res) => {
|
|
734
|
+
res.sendFile(path.join(DIST_DIR, 'index.html'));
|
|
735
|
+
});
|
|
736
|
+
|
|
737
|
+
// Executions (SPA routes)
|
|
738
|
+
app.get('/executions', requireAuth, (req, res) => {
|
|
739
|
+
res.sendFile(path.join(DIST_DIR, 'index.html'));
|
|
740
|
+
});
|
|
741
|
+
app.get('/executions/:id', requireAuth, (req, res) => {
|
|
742
|
+
res.sendFile(path.join(DIST_DIR, 'index.html'));
|
|
743
|
+
});
|
|
744
|
+
|
|
745
|
+
// Execution endpoints
|
|
746
|
+
app.all('/scrape', requireAuth, (req, res) => {
|
|
747
|
+
registerExecution(req, res, { mode: 'scrape' });
|
|
748
|
+
return handleScrape(req, res);
|
|
749
|
+
});
|
|
750
|
+
app.all('/scraper', requireAuth, (req, res) => {
|
|
751
|
+
registerExecution(req, res, { mode: 'scrape' });
|
|
752
|
+
return handleScrape(req, res);
|
|
753
|
+
});
|
|
754
|
+
app.all('/agent', requireAuth, (req, res) => {
|
|
755
|
+
registerExecution(req, res, { mode: 'agent' });
|
|
756
|
+
try {
|
|
757
|
+
const runId = String((req.body && req.body.runId) || req.query.runId || '').trim();
|
|
758
|
+
if (runId) {
|
|
759
|
+
sendExecutionUpdate(runId, { status: 'started' });
|
|
760
|
+
}
|
|
761
|
+
} catch {
|
|
762
|
+
// ignore
|
|
763
|
+
}
|
|
764
|
+
return handleAgent(req, res);
|
|
765
|
+
});
|
|
766
|
+
app.post('/headful', requireAuth, (req, res) => {
|
|
767
|
+
registerExecution(req, res, { mode: 'headful' });
|
|
768
|
+
if (req.body && typeof req.body.url === 'string') {
|
|
769
|
+
const vars = req.body.taskVariables || req.body.variables || {};
|
|
770
|
+
req.body.url = req.body.url.replace(/\{\$(\w+)\}/g, (_match, name) => {
|
|
771
|
+
const value = vars[name];
|
|
772
|
+
if (value === undefined || value === null) return '';
|
|
773
|
+
return String(value);
|
|
774
|
+
});
|
|
775
|
+
}
|
|
776
|
+
return handleHeadful(req, res);
|
|
777
|
+
});
|
|
778
|
+
app.post('/headful/stop', requireAuth, stopHeadful);
|
|
779
|
+
|
|
780
|
+
// Ensure public/screenshots directory exists
|
|
781
|
+
const screenshotsDir = path.join(__dirname, 'public', 'screenshots');
|
|
782
|
+
if (!fs.existsSync(screenshotsDir)) {
|
|
783
|
+
fs.mkdirSync(screenshotsDir, { recursive: true });
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
const novncDirCandidates = [
|
|
787
|
+
'/opt/novnc',
|
|
788
|
+
'/usr/share/novnc'
|
|
789
|
+
];
|
|
790
|
+
const novncDir = novncDirCandidates.find((candidate) => {
|
|
791
|
+
try {
|
|
792
|
+
return fs.existsSync(candidate);
|
|
793
|
+
} catch {
|
|
794
|
+
return false;
|
|
795
|
+
}
|
|
796
|
+
});
|
|
797
|
+
const novncEnabled = !!novncDir;
|
|
798
|
+
if (novncDir) {
|
|
799
|
+
app.use('/novnc', express.static(novncDir));
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
app.use('/screenshots', express.static(screenshotsDir));
|
|
803
|
+
app.use(express.static(DIST_DIR));
|
|
804
|
+
|
|
805
|
+
app.get('/api/headful/status', (req, res) => {
|
|
806
|
+
const useNovnc = !!process.env.DISPLAY && novncEnabled;
|
|
807
|
+
res.json({ useNovnc });
|
|
808
|
+
});
|
|
809
|
+
|
|
810
|
+
const tryBind = (host, port) => new Promise((resolve, reject) => {
|
|
811
|
+
const tester = net.createServer();
|
|
812
|
+
tester.unref();
|
|
813
|
+
tester.once('error', (err) => {
|
|
814
|
+
tester.close(() => reject(err));
|
|
815
|
+
});
|
|
816
|
+
tester.once('listening', () => {
|
|
817
|
+
tester.close(() => resolve(true));
|
|
818
|
+
});
|
|
819
|
+
tester.listen({ port, host });
|
|
820
|
+
});
|
|
821
|
+
|
|
822
|
+
const isPortAvailable = async (port) => {
|
|
823
|
+
try {
|
|
824
|
+
await tryBind('127.0.0.1', port);
|
|
825
|
+
} catch (err) {
|
|
826
|
+
if (err && err.code === 'EADDRINUSE') return false;
|
|
827
|
+
throw err;
|
|
828
|
+
}
|
|
829
|
+
try {
|
|
830
|
+
await tryBind('::1', port);
|
|
831
|
+
} catch (err) {
|
|
832
|
+
if (err && err.code === 'EADDRINUSE') return false;
|
|
833
|
+
if (err && (err.code === 'EADDRNOTAVAIL' || err.code === 'EAFNOSUPPORT')) return true;
|
|
834
|
+
throw err;
|
|
835
|
+
}
|
|
836
|
+
return true;
|
|
837
|
+
};
|
|
838
|
+
|
|
839
|
+
const findAvailablePort = (startPort, maxAttempts = 20) => new Promise((resolve, reject) => {
|
|
840
|
+
let currentPort = startPort;
|
|
841
|
+
const tryPort = async () => {
|
|
842
|
+
try {
|
|
843
|
+
const available = await isPortAvailable(currentPort);
|
|
844
|
+
if (available) return resolve(currentPort);
|
|
845
|
+
} catch (err) {
|
|
846
|
+
return reject(err);
|
|
847
|
+
}
|
|
848
|
+
if (currentPort < startPort + maxAttempts) {
|
|
849
|
+
currentPort += 1;
|
|
850
|
+
return tryPort();
|
|
851
|
+
}
|
|
852
|
+
return reject(new Error('No available port found'));
|
|
853
|
+
};
|
|
854
|
+
tryPort();
|
|
855
|
+
});
|
|
856
|
+
|
|
857
|
+
findAvailablePort(port, 20)
|
|
858
|
+
.then((availablePort) => {
|
|
859
|
+
if (availablePort !== port) {
|
|
860
|
+
console.log(`Port ${port} in use, switched to ${availablePort}.`);
|
|
861
|
+
}
|
|
862
|
+
const server = app.listen(availablePort, '0.0.0.0', () => {
|
|
863
|
+
const address = server.address();
|
|
864
|
+
const displayPort = typeof address === 'object' && address ? address.port : availablePort;
|
|
865
|
+
console.log(`Server running at http://localhost:${displayPort}`);
|
|
866
|
+
});
|
|
867
|
+
server.on('error', (err) => {
|
|
868
|
+
console.error('Server failed to start:', err.message || err);
|
|
869
|
+
process.exit(1);
|
|
870
|
+
});
|
|
871
|
+
})
|
|
872
|
+
.catch((err) => {
|
|
873
|
+
console.error('Server failed to start:', err.message || err);
|
|
874
|
+
process.exit(1);
|
|
875
|
+
});
|