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.
Files changed (3) hide show
  1. package/README.md +58 -0
  2. package/package.json +3 -2
  3. 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.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
- * Trouve le prochain port disponible
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
- const route = registry.routes.get(path);
98
- if (route) {
99
- registry.routes.delete(path);
100
- registry.usedPorts.delete(route.port);
101
- console.log(`[HEALTH CHECK] Unregistered ${path} (was on port ${route.port}) - server unhealthy`);
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
- try {
163
- const { path, name, port: requestedPort } = req.body;
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
- if (!path) {
166
- return res.status(400).json({
167
- success: false,
168
- error: 'Path is required'
169
- });
170
- }
231
+ if (!path) {
232
+ return res.status(400).json({
233
+ success: false,
234
+ error: 'Path is required'
235
+ });
236
+ }
171
237
 
172
- // Normalise le path (doit commencer par /)
173
- const normalizedPath = path.startsWith('/') ? path : `/${path}`;
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
- try {
239
- const { path } = req.body;
318
+ app.delete('/proxy/unregister', async (req, res) => {
319
+ const { path } = req.body;
240
320
 
241
- if (!path) {
242
- return res.status(400).json({
243
- success: false,
244
- error: 'Path is required'
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
- const normalizedPath = path.startsWith('/') ? path : `/${path}`;
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
+ '&': '&amp;',
637
+ '<': '&lt;',
638
+ '>': '&gt;',
639
+ '"': '&quot;',
640
+ "'": '&#039;'
641
+ };
642
+ return String(text).replace(/[&<>"']/g, char => map[char]);
643
+ }
644
+
314
645
  // ============================================
315
646
  // PROXY MIDDLEWARE
316
647
  // ============================================