dynamic-self-register-proxy 1.0.1 → 1.0.3

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 (2) hide show
  1. package/package.json +1 -1
  2. package/proxy.js +381 -33
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dynamic-self-register-proxy",
3
- "version": "1.0.1",
3
+ "version": "1.0.3",
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": {
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
+ // ============================================
30
+ /**
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
+
27
72
  /**
28
- * Trouve le prochain port disponible
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
+ }
237
+
238
+ // Normalise le path (doit commencer par /)
239
+ const normalizedPath = path.startsWith('/') ? path : `/${path}`;
171
240
 
172
- // Normalise le path (doit commencer par /)
173
- const normalizedPath = path.startsWith('/') ? path : `/${path}`;
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,267 @@ 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
+ * Retourne du JSON si le client l'accepte (API), sinon du HTML (navigateur)
405
+ */
406
+ app.get('/', (req, res) => {
407
+ const routes = [];
408
+ registry.routes.forEach((value, path) => {
409
+ routes.push({
410
+ path,
411
+ port: value.port,
412
+ name: value.name,
413
+ registeredAt: value.registeredAt,
414
+ target: `http://localhost:${value.port}`
415
+ });
416
+ });
417
+
418
+ // Tri par nom
419
+ routes.sort((a, b) => a.name.localeCompare(b.name));
420
+
421
+ // Si le client accepte JSON (et pas spécifiquement HTML), retourne du JSON
422
+ const acceptHeader = req.get('Accept') || '';
423
+ const wantsJson = acceptHeader.includes('application/json') ||
424
+ (acceptHeader.includes('*/*') && !acceptHeader.includes('text/html'));
425
+
426
+ if (wantsJson) {
427
+ return res.json({
428
+ status: 'healthy',
429
+ uptime: process.uptime(),
430
+ count: routes.length,
431
+ routes,
432
+ availablePorts: (INTERNAL_PORT_END - INTERNAL_PORT_START) - registry.usedPorts.size
433
+ });
434
+ }
435
+
436
+ const html = `
437
+ <!DOCTYPE html>
438
+ <html lang="fr">
439
+ <head>
440
+ <meta charset="UTF-8">
441
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
442
+ <title>Proxy Server - Services disponibles</title>
443
+ <style>
444
+ * {
445
+ margin: 0;
446
+ padding: 0;
447
+ box-sizing: border-box;
448
+ }
449
+ body {
450
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
451
+ background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
452
+ min-height: 100vh;
453
+ color: #e4e4e7;
454
+ padding: 2rem;
455
+ }
456
+ .container {
457
+ max-width: 800px;
458
+ margin: 0 auto;
459
+ }
460
+ header {
461
+ text-align: center;
462
+ margin-bottom: 3rem;
463
+ }
464
+ h1 {
465
+ font-size: 2.5rem;
466
+ background: linear-gradient(90deg, #6366f1, #8b5cf6);
467
+ -webkit-background-clip: text;
468
+ -webkit-text-fill-color: transparent;
469
+ background-clip: text;
470
+ margin-bottom: 0.5rem;
471
+ }
472
+ .subtitle {
473
+ color: #a1a1aa;
474
+ font-size: 1.1rem;
475
+ }
476
+ .stats {
477
+ display: flex;
478
+ justify-content: center;
479
+ gap: 2rem;
480
+ margin-top: 1.5rem;
481
+ }
482
+ .stat {
483
+ background: rgba(255, 255, 255, 0.05);
484
+ padding: 1rem 1.5rem;
485
+ border-radius: 12px;
486
+ text-align: center;
487
+ }
488
+ .stat-value {
489
+ font-size: 1.5rem;
490
+ font-weight: bold;
491
+ color: #8b5cf6;
492
+ }
493
+ .stat-label {
494
+ font-size: 0.85rem;
495
+ color: #71717a;
496
+ margin-top: 0.25rem;
497
+ }
498
+ .services-list {
499
+ display: flex;
500
+ flex-direction: column;
501
+ gap: 1rem;
502
+ }
503
+ .service-card {
504
+ background: rgba(255, 255, 255, 0.05);
505
+ border: 1px solid rgba(255, 255, 255, 0.1);
506
+ border-radius: 12px;
507
+ padding: 1.5rem;
508
+ transition: all 0.2s ease;
509
+ }
510
+ .service-card:hover {
511
+ background: rgba(255, 255, 255, 0.08);
512
+ border-color: rgba(139, 92, 246, 0.5);
513
+ transform: translateY(-2px);
514
+ }
515
+ .service-header {
516
+ display: flex;
517
+ justify-content: space-between;
518
+ align-items: center;
519
+ margin-bottom: 0.75rem;
520
+ }
521
+ .service-name {
522
+ font-size: 1.25rem;
523
+ font-weight: 600;
524
+ color: #f4f4f5;
525
+ }
526
+ .service-path {
527
+ font-family: 'SF Mono', 'Fira Code', monospace;
528
+ font-size: 0.9rem;
529
+ color: #8b5cf6;
530
+ background: rgba(139, 92, 246, 0.15);
531
+ padding: 0.25rem 0.75rem;
532
+ border-radius: 6px;
533
+ }
534
+ .service-meta {
535
+ display: flex;
536
+ gap: 1.5rem;
537
+ color: #71717a;
538
+ font-size: 0.85rem;
539
+ margin-bottom: 1rem;
540
+ }
541
+ .service-link {
542
+ display: inline-flex;
543
+ align-items: center;
544
+ gap: 0.5rem;
545
+ background: linear-gradient(90deg, #6366f1, #8b5cf6);
546
+ color: white;
547
+ text-decoration: none;
548
+ padding: 0.5rem 1rem;
549
+ border-radius: 8px;
550
+ font-weight: 500;
551
+ transition: opacity 0.2s;
552
+ }
553
+ .service-link:hover {
554
+ opacity: 0.9;
555
+ }
556
+ .empty-state {
557
+ text-align: center;
558
+ padding: 4rem 2rem;
559
+ background: rgba(255, 255, 255, 0.03);
560
+ border-radius: 12px;
561
+ border: 1px dashed rgba(255, 255, 255, 0.1);
562
+ }
563
+ .empty-state h2 {
564
+ color: #a1a1aa;
565
+ font-size: 1.25rem;
566
+ margin-bottom: 1rem;
567
+ }
568
+ .empty-state p {
569
+ color: #71717a;
570
+ margin-bottom: 0.5rem;
571
+ }
572
+ .code {
573
+ font-family: 'SF Mono', 'Fira Code', monospace;
574
+ background: rgba(0, 0, 0, 0.3);
575
+ padding: 0.2rem 0.5rem;
576
+ border-radius: 4px;
577
+ font-size: 0.9rem;
578
+ }
579
+ footer {
580
+ margin-top: 3rem;
581
+ text-align: center;
582
+ color: #52525b;
583
+ font-size: 0.85rem;
584
+ }
585
+ footer a {
586
+ color: #8b5cf6;
587
+ text-decoration: none;
588
+ }
589
+ footer a:hover {
590
+ text-decoration: underline;
591
+ }
592
+ </style>
593
+ </head>
594
+ <body>
595
+ <div class="container">
596
+ <header>
597
+ <h1>🚀 Proxy Server</h1>
598
+ <p class="subtitle">Services disponibles sur ce serveur</p>
599
+ <div class="stats">
600
+ <div class="stat">
601
+ <div class="stat-value">${routes.length}</div>
602
+ <div class="stat-label">Services actifs</div>
603
+ </div>
604
+ <div class="stat">
605
+ <div class="stat-value">${(INTERNAL_PORT_END - INTERNAL_PORT_START) - registry.usedPorts.size}</div>
606
+ <div class="stat-label">Ports disponibles</div>
607
+ </div>
608
+ </div>
609
+ </header>
610
+
611
+ ${routes.length > 0 ? `
612
+ <div class="services-list">
613
+ ${routes.map(route => `
614
+ <div class="service-card">
615
+ <div class="service-header">
616
+ <span class="service-name">${escapeHtml(route.name)}</span>
617
+ <span class="service-path">${escapeHtml(route.path)}</span>
618
+ </div>
619
+ <div class="service-meta">
620
+ <span>Port interne: ${route.port}</span>
621
+ <span>Enregistré: ${new Date(route.registeredAt).toLocaleString('fr-FR')}</span>
622
+ </div>
623
+ <a href="${escapeHtml(route.path)}" class="service-link">
624
+ Accéder au service →
625
+ </a>
626
+ </div>
627
+ `).join('')}
628
+ </div>
629
+ ` : `
630
+ <div class="empty-state">
631
+ <h2>Aucun service enregistré</h2>
632
+ <p>Enregistrez un service avec l'API:</p>
633
+ <p><span class="code">POST /proxy/register</span></p>
634
+ </div>
635
+ `}
636
+
637
+ <footer>
638
+ <p>API: <a href="/proxy/routes">/proxy/routes</a> | <a href="/proxy/health">/proxy/health</a></p>
639
+ </footer>
640
+ </div>
641
+ </body>
642
+ </html>
643
+ `.trim();
644
+
645
+ res.type('html').send(html);
646
+ });
647
+
648
+ /**
649
+ * Échappe les caractères HTML pour éviter les injections XSS
650
+ */
651
+ function escapeHtml(text) {
652
+ const map = {
653
+ '&': '&amp;',
654
+ '<': '&lt;',
655
+ '>': '&gt;',
656
+ '"': '&quot;',
657
+ "'": '&#039;'
658
+ };
659
+ return String(text).replace(/[&<>"']/g, char => map[char]);
660
+ }
661
+
314
662
  // ============================================
315
663
  // PROXY MIDDLEWARE
316
664
  // ============================================