dynamic-self-register-proxy 1.0.0 → 1.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 +58 -0
- package/package.json +3 -2
- package/proxy.js +364 -33
package/README.md
CHANGED
|
@@ -337,6 +337,64 @@ proxy.setupHealthRoute(app, {
|
|
|
337
337
|
- Si la réponse n'est pas un code **200**, le serveur est automatiquement désenregistré
|
|
338
338
|
- Les erreurs de connexion (serveur arrêté, timeout) entraînent aussi le désenregistrement
|
|
339
339
|
|
|
340
|
+
## Configuration MCP (Cursor / Claude Desktop)
|
|
341
|
+
|
|
342
|
+
Pour utiliser ce proxy comme serveur MCP, ajoutez la configuration suivante :
|
|
343
|
+
|
|
344
|
+
### Cursor
|
|
345
|
+
|
|
346
|
+
Dans votre fichier `.cursor/mcp.json` :
|
|
347
|
+
|
|
348
|
+
```json
|
|
349
|
+
{
|
|
350
|
+
"mcpServers": {
|
|
351
|
+
"dynamic-proxy": {
|
|
352
|
+
"command": "npx",
|
|
353
|
+
"args": ["dynamic-self-register-proxy"],
|
|
354
|
+
"env": {
|
|
355
|
+
"PROXY_PORT": "3000"
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
```
|
|
361
|
+
|
|
362
|
+
### Claude Desktop
|
|
363
|
+
|
|
364
|
+
Dans votre fichier de configuration Claude Desktop (`claude_desktop_config.json`) :
|
|
365
|
+
|
|
366
|
+
```json
|
|
367
|
+
{
|
|
368
|
+
"mcpServers": {
|
|
369
|
+
"dynamic-proxy": {
|
|
370
|
+
"command": "npx",
|
|
371
|
+
"args": ["dynamic-self-register-proxy"],
|
|
372
|
+
"env": {
|
|
373
|
+
"PROXY_PORT": "3000"
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
```
|
|
379
|
+
|
|
380
|
+
### Avec variables d'environnement personnalisées
|
|
381
|
+
|
|
382
|
+
```json
|
|
383
|
+
{
|
|
384
|
+
"mcpServers": {
|
|
385
|
+
"dynamic-proxy": {
|
|
386
|
+
"command": "npx",
|
|
387
|
+
"args": ["dynamic-self-register-proxy"],
|
|
388
|
+
"env": {
|
|
389
|
+
"PROXY_PORT": "8080",
|
|
390
|
+
"HEALTH_CHECK_INTERVAL": "60000",
|
|
391
|
+
"HEALTH_CHECK_TIMEOUT": "10000"
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
```
|
|
397
|
+
|
|
340
398
|
## Configuration
|
|
341
399
|
|
|
342
400
|
| Variable | Défaut | Description |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dynamic-self-register-proxy",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.2",
|
|
4
4
|
"description": "Dynamic reverse proxy with self-registration API - applications can register themselves and receive an automatically assigned port",
|
|
5
5
|
"main": "proxy-client.js",
|
|
6
6
|
"bin": {
|
|
@@ -21,7 +21,8 @@
|
|
|
21
21
|
"scripts": {
|
|
22
22
|
"test": "echo \"Error: no test specified\" && exit 1",
|
|
23
23
|
"start": "node proxy.js",
|
|
24
|
-
"example": "node example-app.js"
|
|
24
|
+
"example": "node example-app.js",
|
|
25
|
+
"release": "npm version patch && npm publish --access public && git push origin main --follow-tags"
|
|
25
26
|
},
|
|
26
27
|
"keywords": [
|
|
27
28
|
"proxy",
|
package/proxy.js
CHANGED
|
@@ -24,8 +24,55 @@ const registry = {
|
|
|
24
24
|
nextPort: INTERNAL_PORT_START
|
|
25
25
|
};
|
|
26
26
|
|
|
27
|
+
// ============================================
|
|
28
|
+
// MUTEX POUR L'ATTRIBUTION DES PORTS
|
|
29
|
+
// ============================================
|
|
27
30
|
/**
|
|
28
|
-
*
|
|
31
|
+
* Simple mutex pour sérialiser les opérations critiques
|
|
32
|
+
* Évite les race conditions lors d'inscriptions simultanées
|
|
33
|
+
*/
|
|
34
|
+
class Mutex {
|
|
35
|
+
constructor() {
|
|
36
|
+
this._locked = false;
|
|
37
|
+
this._queue = [];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Acquiert le verrou (attend si nécessaire)
|
|
42
|
+
* @returns {Promise<void>}
|
|
43
|
+
*/
|
|
44
|
+
async acquire() {
|
|
45
|
+
if (!this._locked) {
|
|
46
|
+
this._locked = true;
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Attend que le verrou soit libéré
|
|
51
|
+
return new Promise(resolve => {
|
|
52
|
+
this._queue.push(resolve);
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Libère le verrou
|
|
58
|
+
*/
|
|
59
|
+
release() {
|
|
60
|
+
if (this._queue.length > 0) {
|
|
61
|
+
// Donne le verrou au prochain en attente
|
|
62
|
+
const next = this._queue.shift();
|
|
63
|
+
next();
|
|
64
|
+
} else {
|
|
65
|
+
this._locked = false;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const registrationMutex = new Mutex();
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Trouve le prochain port disponible et le réserve atomiquement
|
|
74
|
+
* DOIT être appelé avec le mutex acquis
|
|
75
|
+
* @returns {number} Le port réservé
|
|
29
76
|
*/
|
|
30
77
|
function getNextAvailablePort() {
|
|
31
78
|
while (registry.usedPorts.has(registry.nextPort)) {
|
|
@@ -39,10 +86,20 @@ function getNextAvailablePort() {
|
|
|
39
86
|
}
|
|
40
87
|
}
|
|
41
88
|
const port = registry.nextPort;
|
|
89
|
+
// Réservation atomique du port
|
|
90
|
+
registry.usedPorts.add(port);
|
|
42
91
|
registry.nextPort++;
|
|
43
92
|
return port;
|
|
44
93
|
}
|
|
45
94
|
|
|
95
|
+
/**
|
|
96
|
+
* Libère un port réservé (en cas d'échec d'enregistrement)
|
|
97
|
+
* @param {number} port - Le port à libérer
|
|
98
|
+
*/
|
|
99
|
+
function releasePort(port) {
|
|
100
|
+
registry.usedPorts.delete(port);
|
|
101
|
+
}
|
|
102
|
+
|
|
46
103
|
/**
|
|
47
104
|
* Trouve la route correspondant à un chemin de requête
|
|
48
105
|
*/
|
|
@@ -91,14 +148,21 @@ async function checkServerHealth(path, route) {
|
|
|
91
148
|
|
|
92
149
|
/**
|
|
93
150
|
* Désenregistre un serveur (utilisé par le health check)
|
|
151
|
+
* Utilise le mutex pour éviter les conflits avec les inscriptions
|
|
94
152
|
* @param {string} path - Le chemin à désenregistrer
|
|
153
|
+
* @returns {Promise<void>}
|
|
95
154
|
*/
|
|
96
|
-
function unregisterServer(path) {
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
registry.routes.
|
|
100
|
-
|
|
101
|
-
|
|
155
|
+
async function unregisterServer(path) {
|
|
156
|
+
await registrationMutex.acquire();
|
|
157
|
+
try {
|
|
158
|
+
const route = registry.routes.get(path);
|
|
159
|
+
if (route) {
|
|
160
|
+
registry.routes.delete(path);
|
|
161
|
+
registry.usedPorts.delete(route.port);
|
|
162
|
+
console.log(`[HEALTH CHECK] Unregistered ${path} (was on port ${route.port}) - server unhealthy`);
|
|
163
|
+
}
|
|
164
|
+
} finally {
|
|
165
|
+
registrationMutex.release();
|
|
102
166
|
}
|
|
103
167
|
}
|
|
104
168
|
|
|
@@ -128,7 +192,7 @@ async function performHealthChecks() {
|
|
|
128
192
|
|
|
129
193
|
for (const { path, isHealthy } of results) {
|
|
130
194
|
if (!isHealthy) {
|
|
131
|
-
unregisterServer(path);
|
|
195
|
+
await unregisterServer(path);
|
|
132
196
|
}
|
|
133
197
|
}
|
|
134
198
|
|
|
@@ -157,21 +221,29 @@ function startHealthCheckPolling() {
|
|
|
157
221
|
* Enregistre une nouvelle route
|
|
158
222
|
* Body: { path: "/myapp", name: "My Application", port?: 4001 }
|
|
159
223
|
* Response: { success: true, path: "/myapp", port: 4001 }
|
|
224
|
+
*
|
|
225
|
+
* Utilise un mutex pour éviter les race conditions lors d'inscriptions simultanées
|
|
160
226
|
*/
|
|
161
|
-
app.post('/proxy/register', (req, res) => {
|
|
162
|
-
|
|
163
|
-
|
|
227
|
+
app.post('/proxy/register', async (req, res) => {
|
|
228
|
+
// Validation préliminaire (avant d'acquérir le mutex)
|
|
229
|
+
const { path, name, port: requestedPort } = req.body;
|
|
164
230
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
231
|
+
if (!path) {
|
|
232
|
+
return res.status(400).json({
|
|
233
|
+
success: false,
|
|
234
|
+
error: 'Path is required'
|
|
235
|
+
});
|
|
236
|
+
}
|
|
171
237
|
|
|
172
|
-
|
|
173
|
-
|
|
238
|
+
// Normalise le path (doit commencer par /)
|
|
239
|
+
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
|
|
240
|
+
|
|
241
|
+
// Acquiert le mutex pour la section critique
|
|
242
|
+
await registrationMutex.acquire();
|
|
243
|
+
|
|
244
|
+
let port = null;
|
|
174
245
|
|
|
246
|
+
try {
|
|
175
247
|
// Vérifie si le path existe déjà
|
|
176
248
|
if (registry.routes.has(normalizedPath)) {
|
|
177
249
|
const existing = registry.routes.get(normalizedPath);
|
|
@@ -187,7 +259,6 @@ app.post('/proxy/register', (req, res) => {
|
|
|
187
259
|
}
|
|
188
260
|
|
|
189
261
|
// Attribution du port
|
|
190
|
-
let port;
|
|
191
262
|
if (requestedPort) {
|
|
192
263
|
// Port spécifique demandé
|
|
193
264
|
if (registry.usedPorts.has(requestedPort)) {
|
|
@@ -197,18 +268,18 @@ app.post('/proxy/register', (req, res) => {
|
|
|
197
268
|
});
|
|
198
269
|
}
|
|
199
270
|
port = requestedPort;
|
|
271
|
+
registry.usedPorts.add(port);
|
|
200
272
|
} else {
|
|
201
|
-
// Attribution automatique
|
|
273
|
+
// Attribution automatique (le port est réservé atomiquement)
|
|
202
274
|
port = getNextAvailablePort();
|
|
203
275
|
}
|
|
204
276
|
|
|
205
|
-
// Enregistrement
|
|
277
|
+
// Enregistrement de la route
|
|
206
278
|
registry.routes.set(normalizedPath, {
|
|
207
279
|
port,
|
|
208
280
|
name: name || normalizedPath,
|
|
209
281
|
registeredAt: new Date().toISOString()
|
|
210
282
|
});
|
|
211
|
-
registry.usedPorts.add(port);
|
|
212
283
|
|
|
213
284
|
console.log(`[REGISTER] ${normalizedPath} -> localhost:${port} (${name || 'unnamed'})`);
|
|
214
285
|
|
|
@@ -221,11 +292,19 @@ app.post('/proxy/register', (req, res) => {
|
|
|
221
292
|
});
|
|
222
293
|
|
|
223
294
|
} catch (error) {
|
|
295
|
+
// En cas d'erreur, libère le port s'il a été réservé
|
|
296
|
+
if (port !== null) {
|
|
297
|
+
releasePort(port);
|
|
298
|
+
}
|
|
299
|
+
|
|
224
300
|
console.error('[REGISTER ERROR]', error.message);
|
|
225
301
|
res.status(500).json({
|
|
226
302
|
success: false,
|
|
227
303
|
error: error.message
|
|
228
304
|
});
|
|
305
|
+
} finally {
|
|
306
|
+
// Libère toujours le mutex
|
|
307
|
+
registrationMutex.release();
|
|
229
308
|
}
|
|
230
309
|
});
|
|
231
310
|
|
|
@@ -233,20 +312,25 @@ app.post('/proxy/register', (req, res) => {
|
|
|
233
312
|
* DELETE /proxy/unregister
|
|
234
313
|
* Supprime une route
|
|
235
314
|
* Body: { path: "/myapp" }
|
|
315
|
+
*
|
|
316
|
+
* Utilise un mutex pour éviter les conflits avec les inscriptions simultanées
|
|
236
317
|
*/
|
|
237
|
-
app.delete('/proxy/unregister', (req, res) => {
|
|
238
|
-
|
|
239
|
-
const { path } = req.body;
|
|
318
|
+
app.delete('/proxy/unregister', async (req, res) => {
|
|
319
|
+
const { path } = req.body;
|
|
240
320
|
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
321
|
+
if (!path) {
|
|
322
|
+
return res.status(400).json({
|
|
323
|
+
success: false,
|
|
324
|
+
error: 'Path is required'
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
|
|
247
329
|
|
|
248
|
-
|
|
330
|
+
// Acquiert le mutex pour la section critique
|
|
331
|
+
await registrationMutex.acquire();
|
|
249
332
|
|
|
333
|
+
try {
|
|
250
334
|
if (!registry.routes.has(normalizedPath)) {
|
|
251
335
|
return res.status(404).json({
|
|
252
336
|
success: false,
|
|
@@ -272,6 +356,9 @@ app.delete('/proxy/unregister', (req, res) => {
|
|
|
272
356
|
success: false,
|
|
273
357
|
error: error.message
|
|
274
358
|
});
|
|
359
|
+
} finally {
|
|
360
|
+
// Libère toujours le mutex
|
|
361
|
+
registrationMutex.release();
|
|
275
362
|
}
|
|
276
363
|
});
|
|
277
364
|
|
|
@@ -311,6 +398,250 @@ app.get('/proxy/health', (req, res) => {
|
|
|
311
398
|
});
|
|
312
399
|
});
|
|
313
400
|
|
|
401
|
+
/**
|
|
402
|
+
* GET /
|
|
403
|
+
* Page d'accueil listant tous les serveurs disponibles
|
|
404
|
+
*/
|
|
405
|
+
app.get('/', (req, res) => {
|
|
406
|
+
const routes = [];
|
|
407
|
+
registry.routes.forEach((value, path) => {
|
|
408
|
+
routes.push({
|
|
409
|
+
path,
|
|
410
|
+
port: value.port,
|
|
411
|
+
name: value.name,
|
|
412
|
+
registeredAt: value.registeredAt
|
|
413
|
+
});
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
// Tri par nom
|
|
417
|
+
routes.sort((a, b) => a.name.localeCompare(b.name));
|
|
418
|
+
|
|
419
|
+
const html = `
|
|
420
|
+
<!DOCTYPE html>
|
|
421
|
+
<html lang="fr">
|
|
422
|
+
<head>
|
|
423
|
+
<meta charset="UTF-8">
|
|
424
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
425
|
+
<title>Proxy Server - Services disponibles</title>
|
|
426
|
+
<style>
|
|
427
|
+
* {
|
|
428
|
+
margin: 0;
|
|
429
|
+
padding: 0;
|
|
430
|
+
box-sizing: border-box;
|
|
431
|
+
}
|
|
432
|
+
body {
|
|
433
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
|
434
|
+
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
|
|
435
|
+
min-height: 100vh;
|
|
436
|
+
color: #e4e4e7;
|
|
437
|
+
padding: 2rem;
|
|
438
|
+
}
|
|
439
|
+
.container {
|
|
440
|
+
max-width: 800px;
|
|
441
|
+
margin: 0 auto;
|
|
442
|
+
}
|
|
443
|
+
header {
|
|
444
|
+
text-align: center;
|
|
445
|
+
margin-bottom: 3rem;
|
|
446
|
+
}
|
|
447
|
+
h1 {
|
|
448
|
+
font-size: 2.5rem;
|
|
449
|
+
background: linear-gradient(90deg, #6366f1, #8b5cf6);
|
|
450
|
+
-webkit-background-clip: text;
|
|
451
|
+
-webkit-text-fill-color: transparent;
|
|
452
|
+
background-clip: text;
|
|
453
|
+
margin-bottom: 0.5rem;
|
|
454
|
+
}
|
|
455
|
+
.subtitle {
|
|
456
|
+
color: #a1a1aa;
|
|
457
|
+
font-size: 1.1rem;
|
|
458
|
+
}
|
|
459
|
+
.stats {
|
|
460
|
+
display: flex;
|
|
461
|
+
justify-content: center;
|
|
462
|
+
gap: 2rem;
|
|
463
|
+
margin-top: 1.5rem;
|
|
464
|
+
}
|
|
465
|
+
.stat {
|
|
466
|
+
background: rgba(255, 255, 255, 0.05);
|
|
467
|
+
padding: 1rem 1.5rem;
|
|
468
|
+
border-radius: 12px;
|
|
469
|
+
text-align: center;
|
|
470
|
+
}
|
|
471
|
+
.stat-value {
|
|
472
|
+
font-size: 1.5rem;
|
|
473
|
+
font-weight: bold;
|
|
474
|
+
color: #8b5cf6;
|
|
475
|
+
}
|
|
476
|
+
.stat-label {
|
|
477
|
+
font-size: 0.85rem;
|
|
478
|
+
color: #71717a;
|
|
479
|
+
margin-top: 0.25rem;
|
|
480
|
+
}
|
|
481
|
+
.services-list {
|
|
482
|
+
display: flex;
|
|
483
|
+
flex-direction: column;
|
|
484
|
+
gap: 1rem;
|
|
485
|
+
}
|
|
486
|
+
.service-card {
|
|
487
|
+
background: rgba(255, 255, 255, 0.05);
|
|
488
|
+
border: 1px solid rgba(255, 255, 255, 0.1);
|
|
489
|
+
border-radius: 12px;
|
|
490
|
+
padding: 1.5rem;
|
|
491
|
+
transition: all 0.2s ease;
|
|
492
|
+
}
|
|
493
|
+
.service-card:hover {
|
|
494
|
+
background: rgba(255, 255, 255, 0.08);
|
|
495
|
+
border-color: rgba(139, 92, 246, 0.5);
|
|
496
|
+
transform: translateY(-2px);
|
|
497
|
+
}
|
|
498
|
+
.service-header {
|
|
499
|
+
display: flex;
|
|
500
|
+
justify-content: space-between;
|
|
501
|
+
align-items: center;
|
|
502
|
+
margin-bottom: 0.75rem;
|
|
503
|
+
}
|
|
504
|
+
.service-name {
|
|
505
|
+
font-size: 1.25rem;
|
|
506
|
+
font-weight: 600;
|
|
507
|
+
color: #f4f4f5;
|
|
508
|
+
}
|
|
509
|
+
.service-path {
|
|
510
|
+
font-family: 'SF Mono', 'Fira Code', monospace;
|
|
511
|
+
font-size: 0.9rem;
|
|
512
|
+
color: #8b5cf6;
|
|
513
|
+
background: rgba(139, 92, 246, 0.15);
|
|
514
|
+
padding: 0.25rem 0.75rem;
|
|
515
|
+
border-radius: 6px;
|
|
516
|
+
}
|
|
517
|
+
.service-meta {
|
|
518
|
+
display: flex;
|
|
519
|
+
gap: 1.5rem;
|
|
520
|
+
color: #71717a;
|
|
521
|
+
font-size: 0.85rem;
|
|
522
|
+
margin-bottom: 1rem;
|
|
523
|
+
}
|
|
524
|
+
.service-link {
|
|
525
|
+
display: inline-flex;
|
|
526
|
+
align-items: center;
|
|
527
|
+
gap: 0.5rem;
|
|
528
|
+
background: linear-gradient(90deg, #6366f1, #8b5cf6);
|
|
529
|
+
color: white;
|
|
530
|
+
text-decoration: none;
|
|
531
|
+
padding: 0.5rem 1rem;
|
|
532
|
+
border-radius: 8px;
|
|
533
|
+
font-weight: 500;
|
|
534
|
+
transition: opacity 0.2s;
|
|
535
|
+
}
|
|
536
|
+
.service-link:hover {
|
|
537
|
+
opacity: 0.9;
|
|
538
|
+
}
|
|
539
|
+
.empty-state {
|
|
540
|
+
text-align: center;
|
|
541
|
+
padding: 4rem 2rem;
|
|
542
|
+
background: rgba(255, 255, 255, 0.03);
|
|
543
|
+
border-radius: 12px;
|
|
544
|
+
border: 1px dashed rgba(255, 255, 255, 0.1);
|
|
545
|
+
}
|
|
546
|
+
.empty-state h2 {
|
|
547
|
+
color: #a1a1aa;
|
|
548
|
+
font-size: 1.25rem;
|
|
549
|
+
margin-bottom: 1rem;
|
|
550
|
+
}
|
|
551
|
+
.empty-state p {
|
|
552
|
+
color: #71717a;
|
|
553
|
+
margin-bottom: 0.5rem;
|
|
554
|
+
}
|
|
555
|
+
.code {
|
|
556
|
+
font-family: 'SF Mono', 'Fira Code', monospace;
|
|
557
|
+
background: rgba(0, 0, 0, 0.3);
|
|
558
|
+
padding: 0.2rem 0.5rem;
|
|
559
|
+
border-radius: 4px;
|
|
560
|
+
font-size: 0.9rem;
|
|
561
|
+
}
|
|
562
|
+
footer {
|
|
563
|
+
margin-top: 3rem;
|
|
564
|
+
text-align: center;
|
|
565
|
+
color: #52525b;
|
|
566
|
+
font-size: 0.85rem;
|
|
567
|
+
}
|
|
568
|
+
footer a {
|
|
569
|
+
color: #8b5cf6;
|
|
570
|
+
text-decoration: none;
|
|
571
|
+
}
|
|
572
|
+
footer a:hover {
|
|
573
|
+
text-decoration: underline;
|
|
574
|
+
}
|
|
575
|
+
</style>
|
|
576
|
+
</head>
|
|
577
|
+
<body>
|
|
578
|
+
<div class="container">
|
|
579
|
+
<header>
|
|
580
|
+
<h1>🚀 Proxy Server</h1>
|
|
581
|
+
<p class="subtitle">Services disponibles sur ce serveur</p>
|
|
582
|
+
<div class="stats">
|
|
583
|
+
<div class="stat">
|
|
584
|
+
<div class="stat-value">${routes.length}</div>
|
|
585
|
+
<div class="stat-label">Services actifs</div>
|
|
586
|
+
</div>
|
|
587
|
+
<div class="stat">
|
|
588
|
+
<div class="stat-value">${(INTERNAL_PORT_END - INTERNAL_PORT_START) - registry.usedPorts.size}</div>
|
|
589
|
+
<div class="stat-label">Ports disponibles</div>
|
|
590
|
+
</div>
|
|
591
|
+
</div>
|
|
592
|
+
</header>
|
|
593
|
+
|
|
594
|
+
${routes.length > 0 ? `
|
|
595
|
+
<div class="services-list">
|
|
596
|
+
${routes.map(route => `
|
|
597
|
+
<div class="service-card">
|
|
598
|
+
<div class="service-header">
|
|
599
|
+
<span class="service-name">${escapeHtml(route.name)}</span>
|
|
600
|
+
<span class="service-path">${escapeHtml(route.path)}</span>
|
|
601
|
+
</div>
|
|
602
|
+
<div class="service-meta">
|
|
603
|
+
<span>Port interne: ${route.port}</span>
|
|
604
|
+
<span>Enregistré: ${new Date(route.registeredAt).toLocaleString('fr-FR')}</span>
|
|
605
|
+
</div>
|
|
606
|
+
<a href="${escapeHtml(route.path)}" class="service-link">
|
|
607
|
+
Accéder au service →
|
|
608
|
+
</a>
|
|
609
|
+
</div>
|
|
610
|
+
`).join('')}
|
|
611
|
+
</div>
|
|
612
|
+
` : `
|
|
613
|
+
<div class="empty-state">
|
|
614
|
+
<h2>Aucun service enregistré</h2>
|
|
615
|
+
<p>Enregistrez un service avec l'API:</p>
|
|
616
|
+
<p><span class="code">POST /proxy/register</span></p>
|
|
617
|
+
</div>
|
|
618
|
+
`}
|
|
619
|
+
|
|
620
|
+
<footer>
|
|
621
|
+
<p>API: <a href="/proxy/routes">/proxy/routes</a> | <a href="/proxy/health">/proxy/health</a></p>
|
|
622
|
+
</footer>
|
|
623
|
+
</div>
|
|
624
|
+
</body>
|
|
625
|
+
</html>
|
|
626
|
+
`.trim();
|
|
627
|
+
|
|
628
|
+
res.type('html').send(html);
|
|
629
|
+
});
|
|
630
|
+
|
|
631
|
+
/**
|
|
632
|
+
* Échappe les caractères HTML pour éviter les injections XSS
|
|
633
|
+
*/
|
|
634
|
+
function escapeHtml(text) {
|
|
635
|
+
const map = {
|
|
636
|
+
'&': '&',
|
|
637
|
+
'<': '<',
|
|
638
|
+
'>': '>',
|
|
639
|
+
'"': '"',
|
|
640
|
+
"'": '''
|
|
641
|
+
};
|
|
642
|
+
return String(text).replace(/[&<>"']/g, char => map[char]);
|
|
643
|
+
}
|
|
644
|
+
|
|
314
645
|
// ============================================
|
|
315
646
|
// PROXY MIDDLEWARE
|
|
316
647
|
// ============================================
|