smart-stick-loadbalancer 1.0.5 → 1.1.0

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.
@@ -0,0 +1,55 @@
1
+ # Contributing
2
+
3
+ Thanks for your interest in contributing to smart-stick-loadbalancer. This is a small, focused package — contributions that keep it simple and beginner-friendly are most welcome.
4
+
5
+ ## What's welcome
6
+
7
+ - Bug fixes
8
+ - Documentation improvements
9
+ - New routing strategies (add a `case` in `router.js`)
10
+ - Health check improvements
11
+ - Better error messages
12
+
13
+ ## What to discuss first
14
+
15
+ Open an issue before starting work on anything large — new features, breaking config changes, or architectural shifts. Keeps everyone from wasting time.
16
+
17
+ ## Getting started
18
+
19
+ ```bash
20
+ git clone https://github.com/SwayamGupta12345/smart-stick-loadbalancer.git
21
+ cd smart-stick-loadbalancer
22
+ npm install
23
+ ```
24
+
25
+ Create two simple backend servers (see the Quick Start in the README) and run `node src/index.js` against a local config to verify your changes work end-to-end.
26
+
27
+ ## Submitting a pull request
28
+
29
+ 1. Fork the repo and create a branch: `git checkout -b fix/your-fix-name`
30
+ 2. Make your changes
31
+ 3. Test manually with at least two backends
32
+ 4. Update the README if you're changing behaviour or adding config options
33
+ 5. Open a pull request with a clear description of what changed and why
34
+
35
+ ## Adding a routing strategy
36
+
37
+ All routing logic lives in `src/router.js`. Add a new `case` to `selectBackend`, handle the `nextIndex` return value correctly, and document it in the README under **Routing Strategies**.
38
+
39
+ ## Style
40
+
41
+ - No external linting setup — just match the style of the surrounding code
42
+ - Clear variable names over clever one-liners
43
+ - Add a comment if the logic isn't immediately obvious
44
+
45
+ ## Reporting bugs
46
+
47
+ Open a GitHub issue with:
48
+ - What you expected to happen
49
+ - What actually happened
50
+ - Your config (remove any credentials)
51
+ - Node.js version
52
+
53
+ ---
54
+
55
+ Small project, low friction. If something's unclear, just open an issue and ask.
package/README.md CHANGED
@@ -1,16 +1,94 @@
1
1
  # Smart Stick Load Balancer
2
2
 
3
- A lightweight **sticky-session load balancer** for Node.js. It distributes requests to multiple backends, supports sticky sessions via cookies, performs health checks, and can optionally send email alerts.
3
+ ![npm](https://img.shields.io/npm/v/smart-stick-loadbalancer)
4
+ ![npm downloads](https://img.shields.io/npm/dm/smart-stick-loadbalancer)
5
+ ![License](https://img.shields.io/npm/l/smart-stick-loadbalancer)
6
+ ![Node.js](https://img.shields.io/badge/node-%3E%3D18-green)
7
+
8
+ A lightweight sticky-session load balancer for Node.js with health checks, automatic failover, WebSocket support, configurable routing strategies, and optional email alerts.
9
+
10
+ Designed for developers who want a simple load-balancing solution directly inside the Node.js ecosystem - no Nginx, no HAProxy, no external configuration.
11
+
12
+ ---
13
+
14
+ ## Why this package?
15
+
16
+ Most Node developers don't want to configure
17
+ Nginx or HAProxy just to balance requests during
18
+ development or for small deployments.
19
+
20
+ Smart Stick Load Balancer provides:
21
+
22
+ - sticky sessions
23
+ - health monitoring
24
+ - automatic failover
25
+ - WebSocket support
26
+
27
+ directly inside your Node.js application with a simple JSON configuration.
28
+
29
+ ---
30
+
31
+ ## Table of Contents
32
+
33
+ - [Why this package?](#why-this-package)
34
+ - [Features](#features)
35
+ - [Architecture](#architecture)
36
+ - [Installation](#installation)
37
+ - [Project Structure](#project-structure)
38
+ - [Configuration](#configuration)
39
+ - [Usage](#usage)
40
+ - [Routing Strategies](#routing-strategies)
41
+ - [Health Checks](#health-checks)
42
+ - [Weights](#weights)
43
+ - [How It Works](#how-it-works)
44
+ - [Sticky Sessions](#sticky-sessions)
45
+ - [Local Development Demo](#local-development-demo)
46
+ - [Health Endpoint](#health-endpoint)
47
+ - [Email Alerts](#email-alerts)
48
+ - [Use Cases](#use-cases)
49
+ - [Notes](#notes)
50
+ - [License](#license)
4
51
 
5
52
  ---
6
53
 
7
54
  ## Features
8
55
 
9
- - Sticky sessions via cookies
10
- - Health checks with automatic failover
11
- - Optional email alerts
56
+ - Sticky sessions using cookies
57
+ - Three routing strategies: `round-robin`, `least-connections`, `random`
58
+ - Weight support - send more traffic to stronger backends
59
+ - Configurable health check path per backend
60
+ - Configurable health check algorithm (`http`, `http-status`, or your own function)
61
+ - Automatic backend health checks with configurable interval
62
+ - Automatic failover and recovery
63
+ - Active connection tracking per backend
12
64
  - WebSocket and HTTP support
13
- - Minimal setup
65
+ - Optional email alerts when backends go down or recover
66
+ - Simple JSON configuration
67
+ - Pure Node.js implementation with no dependency on external load balancers such as Nginx or HAProxy.
68
+
69
+ ---
70
+
71
+ ## Architecture
72
+
73
+ ```text
74
+ Client Requests
75
+
76
+
77
+ Smart Stick Load Balancer
78
+
79
+ ┌─────────────┼─────────────┐
80
+ │ │ │
81
+ ▼ ▼ ▼
82
+ Backend A Backend B Backend C
83
+ │ │ │
84
+ /health /ready /health
85
+
86
+ ▲ ▲ ▲
87
+ └──── Health Checker ─────────┘
88
+ ```
89
+
90
+ The load balancer continuously monitors backend health, routes new requests using the configured strategy, and preserves sticky sessions using cookies.
91
+ The cookie stores the assigned backend's identifier and is automatically updated if failover occurs.
14
92
 
15
93
  ---
16
94
 
@@ -20,105 +98,429 @@ A lightweight **sticky-session load balancer** for Node.js. It distributes reque
20
98
  npm install smart-stick-loadbalancer
21
99
  ```
22
100
 
101
+ ### Requirements
102
+ - Node.js **18 or later**
103
+ - npm
104
+
105
+ The package relies on modern Node.js features such as the built-in `fetch()` API.
106
+
107
+ ---
108
+
109
+ ## Project Structure
110
+
111
+ Create a `config.json` and `index.js` inside your own project folder:
112
+
113
+ ```text
114
+ my-project/
115
+ ├── config.json ← your configuration
116
+ └── index.js ← your entry point
117
+ ```
118
+
119
+ > ⚠️ **Never commit `config.json` to a public repository if it contains email credentials.**
120
+ > Add it to your `.gitignore`:
121
+ > ```
122
+ > config.json
123
+ > ```
124
+
23
125
  ---
24
126
 
25
- ## Example Configuration (`config.json`)
127
+ ## Configuration
128
+
129
+ ### config.json
26
130
 
27
131
  ```json
28
132
  {
29
133
  "port": 3001,
134
+ "strategy": "round-robin",
30
135
  "backends": [
31
136
  {
32
137
  "id": 0,
33
138
  "url": "http://localhost:5000",
34
- "owner": "example@example.com",
35
- "weight": 1
139
+ "weight": 2,
140
+ "healthPath": "/health",
141
+ "healthMethod": "GET",
142
+ "owner": "your-email@example.com"
36
143
  },
37
144
  {
38
145
  "id": 1,
39
146
  "url": "http://localhost:5001",
40
- "owner": "example@example.com",
41
- "weight": 1
147
+ "weight": 1,
148
+ "healthPath": "/ready",
149
+ "healthMethod": "HEAD",
150
+ "healthTimeout": 5000,
151
+ "owner": "your-email@example.com"
42
152
  }
43
153
  ],
44
- "health": { "interval": 10000, "timeout": 2000 },
154
+ "health": {
155
+ "interval": 10000,
156
+ "timeout": 2000,
157
+ "algorithm": "http-status"
158
+ },
45
159
  "email": {
46
160
  "service": "gmail",
47
- "auth": { "user": "<your-email@gmail.com>", "pass": "<your-app-password>" }
161
+ "auth": {
162
+ "user": "your-email@gmail.com",
163
+ "pass": "your-app-password"
164
+ }
48
165
  }
49
166
  }
50
167
  ```
51
168
 
169
+ ### Config fields
170
+
171
+ | Field | Required | Default | Description |
172
+ |---|---|---|---|
173
+ | `port` | Yes | - | Port the load balancer listens on |
174
+ | `strategy` | No | `"round-robin"` | Routing strategy: `"round-robin"`, `"least-connections"`, `"random"` |
175
+ | `backends` | Yes | - | Array of backend servers (minimum 1) |
176
+ | `backends[].id` | Yes | - | Unique integer ID for each backend |
177
+ | `backends[].url` | Yes | - | Full URL of the backend server |
178
+ | `backends[].weight` | No | `1` | Relative traffic weight (higher = more requests) |
179
+ | `backends[].owner` | No | - | Email to notify when this backend changes state (requires `email` config) |
180
+ | `backends[].healthPath` | No | `"/"` | Health check path - overrides global `health.path` |
181
+ | `backends[].healthMethod` | No | `"GET"` | HTTP method for health checks - overrides global `health.method` |
182
+ | `backends[].healthAlgorithm` | No | `"http-status"` | Health algorithm - overrides global `health.algorithm` |
183
+ | `backends[].healthTimeout` | No | `2000` | Timeout in ms - overrides global `health.timeout` |
184
+ | `backends[].healthHeaders` | No | `{}` | Headers sent with health check requests - overrides global `health.headers` |
185
+ | `backends[].healthCheck` | No | - | Custom check function for this backend - overrides global `health.check` (JS only) |
186
+ | `health.interval` | No | `10000` | How often to run health checks, in ms (no per-backend override) |
187
+ | `health.path` | No | `"/"` | Default health check path for all backends |
188
+ | `health.method` | No | `"GET"` | Default HTTP method for all health checks |
189
+ | `health.algorithm` | No | `"http-status"` | Default algorithm: `"http"` (any response) or `"http-status"` (2xx only) |
190
+ | `health.timeout` | No | `2000` | Default timeout in ms for health check requests |
191
+ | `health.headers` | No | `{}` | Default headers sent with all health check requests |
192
+ | `health.check` | No | - | Default custom check function for all backends (JS only) |
193
+ | `email` | No | - | Nodemailer config - omit entirely to disable email alerts |
194
+
52
195
  ---
53
196
 
54
197
  ## Usage
55
198
 
199
+ ### index.js
200
+
56
201
  ```js
57
202
  const { createStickyProxy } = require("smart-stick-loadbalancer");
58
203
  const config = require("./config.json");
59
204
 
60
205
  const lb = createStickyProxy(config);
61
- lb.start(); // starts the load balancer
206
+ lb.start();
207
+ ```
208
+
209
+ ---
210
+
211
+ ## Routing Strategies
212
+
213
+ ### `round-robin` (default)
214
+
215
+ Requests cycle through backends in order. Weight controls how many slots each backend gets in the rotation.
216
+
217
+ ```
218
+ Backend A (weight: 2), Backend B (weight: 1)
219
+ Request order: A → A → B → A → A → B → ...
220
+ ```
221
+
222
+ Recommended for: general use, backends with similar but not identical capacity.
223
+
224
+ ### `least-connections`
225
+
226
+ Each request goes to the backend currently handling the fewest active connections.
227
+
228
+ ```json
229
+ "strategy": "least-connections"
230
+ ```
231
+
232
+ Recommended for: backends where some requests take much longer than others - file uploads, long polling, slow queries. Naturally balances load without needing weights.
233
+
234
+ ### `random`
235
+
236
+ Picks a backend at random. Weight controls probability - a backend with `weight: 3` is 3x more likely to be chosen than one with `weight: 1`.
237
+
238
+ ```json
239
+ "strategy": "random"
62
240
  ```
63
241
 
64
- - Requests to `http://localhost:3001` will be forwarded to the backends.
65
- - Sticky sessions are automatically managed using cookies.
66
- - Health checks run periodically and remove unhealthy backends from rotation.
242
+ Recommended for: testing, simple distribution, situations where order doesn't matter.
67
243
 
68
244
  ---
69
245
 
70
- ## Quick Start Demo
246
+ ## Health Checks
71
247
 
72
- Create two simple backend servers:
248
+ Every health check setting can be set globally under `health:` and overridden per backend. Per-backend always wins.
73
249
 
74
- // server5000.js
250
+ ### Per-backend health path
251
+
252
+ By default the health checker hits `/` on each backend. Point each backend to its own endpoint:
253
+
254
+ ```json
255
+ "backends": [
256
+ { "id": 0, "url": "http://localhost:5000", "healthPath": "/health" },
257
+ { "id": 1, "url": "http://localhost:5001", "healthPath": "/ready" },
258
+ { "id": 2, "url": "http://localhost:5002", "healthPath": "/ping" }
259
+ ]
260
+ ```
261
+
262
+ Or set a global default that all backends fall back to:
263
+
264
+ ```json
265
+ "health": { "path": "/health" }
266
+ ```
267
+
268
+ ### Per-backend HTTP method
269
+
270
+ Some health endpoints respond only to `HEAD` (faster, no body). Others need `GET`. Set per backend or globally:
271
+
272
+ ```json
273
+ "backends": [
274
+ { "id": 0, "url": "http://localhost:5000", "healthMethod": "HEAD" },
275
+ { "id": 1, "url": "http://localhost:5001", "healthMethod": "GET" }
276
+ ]
277
+ ```
278
+
279
+ ### Per-backend algorithm
280
+
281
+ **`"http-status"` (default)** - healthy only if the response is `2xx`. Recommended.
282
+
283
+ **`"http"`** - healthy if any response comes back at all, regardless of status code. Use this for backends that return non-2xx on their health path but are actually running.
284
+
285
+ ```json
286
+ "backends": [
287
+ { "id": 0, "url": "http://localhost:5000", "healthAlgorithm": "http-status" },
288
+ { "id": 1, "url": "http://localhost:5001", "healthAlgorithm": "http" }
289
+ ]
290
+ ```
291
+
292
+ ### Per-backend timeout
293
+
294
+ A slow database-heavy backend might need a longer timeout before being declared unhealthy:
295
+
296
+ ```json
297
+ "backends": [
298
+ { "id": 0, "url": "http://localhost:5000", "healthTimeout": 1000 },
299
+ { "id": 1, "url": "http://localhost:5001", "healthTimeout": 5000 }
300
+ ]
301
+ ```
302
+
303
+ ### Per-backend health headers
304
+
305
+ Some backends require an auth token or API key to respond to health checks:
306
+
307
+ ```json
308
+ "backends": [
309
+ {
310
+ "id": 0,
311
+ "url": "http://localhost:5000",
312
+ "healthHeaders": { "X-Health-Token": "secret123" }
313
+ }
314
+ ]
75
315
  ```
76
- const express = require('express');
316
+
317
+ Or set global headers for all backends:
318
+
319
+ ```json
320
+ "health": {
321
+ "headers": { "X-Internal": "true" }
322
+ }
323
+ ```
324
+
325
+ ### Custom check function
326
+
327
+ For full control, provide a `check` function in JS config. Receives the backend object, returns `true` (healthy) or `false` (unhealthy). Can be set globally or per backend.
328
+
329
+ **Global** - applies to all backends that don't have their own `healthCheck`:
330
+
331
+ ```js
332
+ const config = {
333
+ port: 3001,
334
+ backends: [
335
+ { id: 0, url: "http://localhost:5000" },
336
+ { id: 1, url: "http://localhost:5001" }
337
+ ],
338
+ health: {
339
+ interval: 10000,
340
+ check: async (backend) => {
341
+ const res = await fetch(`${backend.url}/health`);
342
+ const data = await res.json();
343
+ return data.status === "ok" && data.dbConnected === true;
344
+ }
345
+ }
346
+ };
347
+ ```
348
+
349
+ **Per-backend** - override the global function for one specific backend:
350
+
351
+ ```js
352
+ const config = {
353
+ port: 3001,
354
+ backends: [
355
+ {
356
+ id: 0,
357
+ url: "http://localhost:5000",
358
+ healthCheck: async (backend) => {
359
+ // This backend needs a DB check
360
+ const res = await fetch(`${backend.url}/health`);
361
+ const data = await res.json();
362
+ return data.status === "ok" && data.dbConnected === true;
363
+ }
364
+ },
365
+ {
366
+ id: 1,
367
+ url: "http://localhost:5001",
368
+ // No healthCheck - falls back to global health.check or built-in algorithm
369
+ }
370
+ ],
371
+ health: { interval: 10000 }
372
+ };
373
+ ```
374
+
375
+ > Custom check functions are only available when passing config as a JS object. They cannot be expressed in JSON.
376
+
377
+ ---
378
+
379
+ ## Weights
380
+
381
+ The `weight` field controls how much traffic a backend receives relative to others.
382
+
383
+ ```json
384
+ "backends": [
385
+ { "id": 0, "url": "http://localhost:5000", "weight": 3 },
386
+ { "id": 1, "url": "http://localhost:5001", "weight": 1 }
387
+ ]
388
+ ```
389
+
390
+ Backend 0 receives 3x as many requests as backend 1. Useful when servers have different capacity.
391
+
392
+ Weight works with `round-robin` and `random`. For `least-connections`, traffic naturally flows to the least-busy backend without needing weights.
393
+
394
+ ---
395
+
396
+ ## How It Works
397
+
398
+ 1. An incoming request arrives at the load balancer.
399
+ 2. If the client has a sticky session cookie pointing to a healthy backend, that backend is used.
400
+ 3. If no valid sticky cookie exists, a backend is selected using the configured strategy.
401
+ 4. A cookie is set so future requests from the same client go to the same backend.
402
+ 5. Health checks run in the background at the configured interval, hitting each backend's `healthPath`.
403
+ 6. When a backend goes down, it is removed from rotation and the weighted pool is rebuilt.
404
+ 7. When a backend recovers, it is added back to rotation.
405
+ 8. If all backends go down, the load balancer returns `503 No healthy backends available`.
406
+
407
+ ---
408
+
409
+ ## Sticky Sessions
410
+
411
+ To keep users connected to the same backend across multiple requests, the load balancer uses an HTTP cookie.
412
+
413
+ ### How it works
414
+
415
+ 1. A client's first request is routed using the configured strategy.
416
+ 2. The selected backend's ID is stored in a cookie.
417
+ 3. Future requests from the same client are sent to the same backend while it remains healthy.
418
+ 4. If that backend becomes unavailable, the client is automatically reassigned to another healthy backend and the cookie is updated.
419
+
420
+ Sticky sessions improve compatibility with applications that store user state in memory, such as login sessions, shopping carts, or WebSocket connections.
421
+
422
+ > If all backends become unavailable, the load balancer returns **503 Service Unavailable** until a healthy backend is detected.
423
+
424
+ ---
425
+
426
+ ## Local Development Demo
427
+
428
+ ### Step 1 - Create two backend servers
429
+
430
+ ```js
431
+ // server5000.js
432
+ const express = require("express");
77
433
  const app = express();
78
- app.get('/', (req, res) => res.send('Hello from 5000'));
79
- app.listen(5000, () => console.log('Server 5000 running'));
434
+ app.get("/", (req, res) => res.send("Hello from Server 5000"));
435
+ app.get("/health", (req, res) => res.json({ status: "ok" }));
436
+ app.listen(5000, () => console.log("Server 5000 running"));
80
437
  ```
81
438
 
439
+ ```js
82
440
  // server5001.js
83
- ```
84
- const express = require('express');
441
+ const express = require("express");
85
442
  const app = express();
86
- app.get('/', (req, res) => res.send('Hello from 5001'));
87
- app.listen(5001, () => console.log('Server 5001 running'));
443
+ app.get("/", (req, res) => res.send("Hello from Server 5001"));
444
+ app.get("/health", (req, res) => res.json({ status: "ok" }));
445
+ app.listen(5001, () => console.log("Server 5001 running"));
88
446
  ```
89
447
 
90
- Start the load balancer:
448
+ ### Step 2 - Start the backends
91
449
 
450
+ ```bash
451
+ node server5000.js
452
+ node server5001.js
92
453
  ```
454
+
455
+ ### Step 3 - Start the load balancer
456
+
457
+ ```bash
93
458
  node index.js
94
459
  ```
95
460
 
96
- Open in browser: http://localhost:3001
97
- Requests should alternate between the two backends (sticky sessions maintained per client).
98
-
99
- Check health status:
461
+ ### Step 4 - Open in your browser
100
462
 
101
463
  ```
102
- curl http://localhost:3001/_lb/health
464
+ http://localhost:3001
103
465
  ```
104
466
 
467
+ Your session stays on the same backend. Check the `x-backend-id` response header to see which backend handled each request.
468
+
469
+ ---
470
+
105
471
  ## Health Endpoint
106
472
 
107
- ```http
108
- GET /_lb/health
473
+ This endpoint is intended for monitoring and debugging only.
474
+ Check the live status of all backends:
475
+
476
+ ```bash
477
+ curl http://localhost:3001/_lb/health
109
478
  ```
110
479
 
111
- Response example:
480
+ Example response:
112
481
 
113
482
  ```json
114
483
  {
115
484
  "timestamp": "2025-12-28T12:00:00.000Z",
485
+ "strategy": "round-robin",
116
486
  "total": 2,
117
487
  "healthy": 2,
118
488
  "unhealthy": 0,
119
489
  "backends": [
120
- { "id": 0, "url": "http://localhost:5000", "healthy": true, "requests": 5 },
121
- { "id": 1, "url": "http://localhost:5001", "healthy": true, "requests": 3 }
490
+ {
491
+ "id": 0,
492
+ "url": "http://localhost:5000",
493
+ "healthy": true,
494
+ "weight": 2,
495
+ "requests": 10,
496
+ "activeConnections": 1,
497
+ "lastChecked": "2025-12-28T12:00:00.000Z",
498
+ "healthCheck": {
499
+ "path": "/health",
500
+ "method": "GET",
501
+ "algorithm": "http-status",
502
+ "timeout": 2000,
503
+ "hasCustomHeaders": false,
504
+ "hasCustomFunction": false
505
+ }
506
+ },
507
+ {
508
+ "id": 1,
509
+ "url": "http://localhost:5001",
510
+ "healthy": true,
511
+ "weight": 1,
512
+ "requests": 5,
513
+ "activeConnections": 0,
514
+ "lastChecked": "2025-12-28T12:00:00.000Z",
515
+ "healthCheck": {
516
+ "path": "/ready",
517
+ "method": "HEAD",
518
+ "algorithm": "http-status",
519
+ "timeout": 5000,
520
+ "hasCustomHeaders": true,
521
+ "hasCustomFunction": false
522
+ }
523
+ }
122
524
  ]
123
525
  }
124
526
  ```
@@ -127,11 +529,35 @@ Response example:
127
529
 
128
530
  ## Email Alerts
129
531
 
130
- - Alerts are sent when a backend goes down or comes back up.
131
- - Requires valid Gmail credentials (or any supported email service).
532
+ When `email` is present in the config, an alert is sent to each backend's `owner` address when it goes down or comes back online.
533
+
534
+ - Email alerts are implemented using [Nodemailer](https://nodemailer.com/).
535
+ - Supports Gmail and other compatible SMTP providers.
536
+ - For Gmail, use an [App Password](https://support.google.com/accounts/answer/185833) rather than your account password
537
+ ### Attention
538
+ - Email alerts are optional.
539
+ - Some hosting providers may restrict SMTP connections.
540
+ - If email alerts are unavailable in your environment, simply omit the `email` configuration and the load balancer will continue to function normally.
541
+
542
+ ## Use Cases
543
+
544
+ - Learning how load balancers and routing strategies work
545
+ - Local development with multiple backend instances
546
+ - Internal tools and prototypes
547
+ - Small Node.js deployments that don't need Nginx
548
+
549
+ ---
550
+
551
+ ## Notes
552
+
553
+ - Backend `id` values must be unique integers.
554
+ - Sticky sessions take priority over strategy - a client always returns to their assigned backend if it is still healthy.
555
+ - WebSocket proxying is supported out of the box.
556
+ - If all backends go down, the load balancer returns `503 No healthy backends available`.
557
+ - The `custom check` function is only available when passing config as a JS object - it cannot be expressed in JSON.
132
558
 
133
559
  ---
134
560
 
135
561
  ## License
136
562
 
137
- MIT
563
+ MIT
@@ -0,0 +1,30 @@
1
+ # Code of Conduct
2
+
3
+ ## Our Pledge
4
+
5
+ Everyone who contributes to or uses this project is expected to keep interactions respectful and constructive. This is a learning-friendly project — beginners are welcome.
6
+
7
+ ## Expected Behaviour
8
+
9
+ - Be kind and patient, especially with newcomers
10
+ - Give constructive feedback on issues and pull requests
11
+ - Accept feedback gracefully
12
+ - Focus on what's best for the project
13
+
14
+ ## Unacceptable Behaviour
15
+
16
+ - Harassment, insults, or personal attacks
17
+ - Dismissing questions as "too basic"
18
+ - Spam or off-topic promotion
19
+
20
+ ## Reporting
21
+
22
+ If something feels wrong, open a private issue or contact the maintainer directly via GitHub. All reports will be handled with discretion.
23
+
24
+ ## Enforcement
25
+
26
+ The maintainer reserves the right to close issues, remove comments, or block contributors who repeatedly violate this code.
27
+
28
+ ---
29
+
30
+ This project follows the spirit of the [Contributor Covenant](https://www.contributor-covenant.org/).
package/package.json CHANGED
@@ -1,11 +1,8 @@
1
1
  {
2
2
  "name": "smart-stick-loadbalancer",
3
- "version": "1.0.5",
3
+ "version": "1.1.0",
4
4
  "description": "A lightweight sticky-session load balancer for Node.js with health checks and optional email alerts.",
5
5
  "main": "src/index.js",
6
- "bin": {
7
- "smart-stick-lb": "src/index.js"
8
- },
9
6
  "scripts": {
10
7
  "start": "node src/index.js"
11
8
  },
@@ -13,11 +10,11 @@
13
10
  "load-balancer",
14
11
  "sticky-session",
15
12
  "proxy",
16
- "Node.js",
13
+ "nodejs",
17
14
  "http-proxy",
18
15
  "health-check"
19
16
  ],
20
- "author": "SWAYAM GUPTA",
17
+ "author": "Swayam Gupta",
21
18
  "license": "MIT",
22
19
  "dependencies": {
23
20
  "axios": "^1.10.0",
@@ -26,5 +23,9 @@
26
23
  "http-proxy-middleware": "^3.0.5",
27
24
  "nodemailer": "^7.0.5",
28
25
  "socket.io": "^4.8.3"
26
+ },
27
+ "repository": {
28
+ "type": "git",
29
+ "url": "https://github.com/SwayamGupta12345/smart-stick-loadbalancer.git"
29
30
  }
30
31
  }
@@ -1,32 +1,77 @@
1
1
  const axios = require("axios");
2
+ function resolveHealthConfig(server, globalConfig = {}) {
3
+ return {
4
+ path: server.healthPath ?? globalConfig.path ?? "/",
5
+ method: server.healthMethod ?? globalConfig.method ?? "GET",
6
+ algorithm: server.healthAlgorithm ?? globalConfig.algorithm ?? "http-status",
7
+ timeout: server.healthTimeout ?? globalConfig.timeout ?? 2000,
8
+ headers: server.healthHeaders ?? globalConfig.headers ?? {},
9
+ check: server.healthCheck ?? globalConfig.check ?? null,
10
+ };
11
+ }
12
+
13
+ async function checkHealth(server, globalConfig = {}) {
14
+ const cfg = resolveHealthConfig(server, globalConfig);
15
+ const url = server.url.replace(/\/$/, "") + cfg.path;
16
+
17
+ // Custom function — per-backend or global
18
+ if (typeof cfg.check === "function") {
19
+ try {
20
+ const result = await cfg.check(server);
21
+ return Boolean(result);
22
+ } catch {
23
+ return false;
24
+ }
25
+ }
2
26
 
3
- async function checkHealth(server, timeout = 2000) {
4
27
  try {
5
- await axios.get(server.url, { timeout });
6
- return true;
28
+ const response = await axios.request({
29
+ method: cfg.method,
30
+ url,
31
+ timeout: cfg.timeout,
32
+ headers: cfg.headers,
33
+ // Never throw on status — we inspect it ourselves
34
+ validateStatus: () => true,
35
+ });
36
+
37
+ if (cfg.algorithm === "http") {
38
+ // Any response at all = healthy
39
+ return true;
40
+ }
41
+
42
+ // "http-status" — only 2xx
43
+ return response.status >= 200 && response.status < 300;
7
44
  } catch {
45
+ // Network error, timeout, DNS failure — always unhealthy
8
46
  return false;
9
47
  }
10
48
  }
11
49
 
12
- function startHealthChecker(servers, config, onDown, onUp) {
50
+ function startHealthChecker(servers, globalConfig, onDown, onUp) {
13
51
  const lastStatus = new Map();
14
- const interval = config?.interval || 10000;
15
- const timeout = config?.timeout || 2000;
52
+ const interval = globalConfig?.interval || 10000;
16
53
 
17
54
  setInterval(async () => {
18
55
  for (const server of servers) {
19
- const isUp = await checkHealth(server, timeout);
20
- const wasUp = lastStatus.get(server.url) ?? true;
21
- server.healthy = isUp;
22
- server.lastChecked = new Date().toISOString();
56
+ try {
57
+ const isUp = await checkHealth(server, globalConfig);
58
+ const wasUp = lastStatus.get(server.url) ?? true;
59
+ server.healthy = isUp;
60
+ server.lastChecked = new Date().toISOString();
23
61
 
24
- if (!isUp && wasUp && onDown) onDown(server);
25
- if (isUp && !wasUp && onUp) onUp(server);
62
+ if (!isUp && wasUp && onDown) onDown(server);
63
+ if (isUp && !wasUp && onUp) onUp(server);
26
64
 
27
- lastStatus.set(server.url, isUp);
65
+ lastStatus.set(server.url, isUp);
66
+ } catch (err) {
67
+ // Safety net — a bug in checkHealth should never kill the interval
68
+ console.error(
69
+ `[smart-stick-loadbalancer] Health check error for ${server.url}:`,
70
+ err.message
71
+ );
72
+ }
28
73
  }
29
74
  }, interval);
30
75
  }
31
76
 
32
- module.exports = { checkHealth, startHealthChecker };
77
+ module.exports = { checkHealth, startHealthChecker, resolveHealthConfig };
package/src/index.js CHANGED
@@ -3,43 +3,81 @@ const http = require("http");
3
3
  const { createProxyMiddleware } = require("http-proxy-middleware");
4
4
  const cookieParser = require("cookie-parser");
5
5
  const socketIo = require("socket.io");
6
- const { startHealthChecker } = require("./healthChecker");
6
+ const { startHealthChecker, resolveHealthConfig } = require("./healthChecker");
7
7
  const { sendDownAlert, sendUpAlert } = require("./notifier");
8
+ const { buildWeightedPool, selectBackend } = require("./router");
8
9
 
9
10
  function createStickyProxy(options = {}) {
10
- if (!options.port || !options.backends) {
11
- throw new Error("Port and backends must be provided");
11
+ if (!options.port) {
12
+ throw new Error("[smart-stick-loadbalancer] 'port' is required in config.");
13
+ }
14
+ if (!options.backends || !Array.isArray(options.backends) || options.backends.length === 0) {
15
+ throw new Error("[smart-stick-loadbalancer] 'backends' must be a non-empty array in config.");
16
+ }
17
+
18
+ const strategy = options.strategy || "round-robin";
19
+ const validStrategies = ["round-robin", "least-connections", "random"];
20
+ if (!validStrategies.includes(strategy)) {
21
+ throw new Error(
22
+ `[smart-stick-loadbalancer] Unknown strategy '${strategy}'. Valid options: ${validStrategies.join(", ")}`
23
+ );
12
24
  }
13
25
 
14
26
  const app = express();
15
27
  const server = http.createServer(app);
16
28
  const io = socketIo(server);
17
29
 
18
- let current = 0;
19
30
  const servers = options.backends.map((b) => ({
20
31
  ...b,
32
+ weight: b.weight ?? 1,
21
33
  healthy: true,
22
34
  requests: 0,
35
+ activeConnections: 0,
23
36
  lastChecked: null,
24
37
  }));
25
38
 
39
+ // Pre-build weighted pool for round-robin (rebuilt when health changes)
40
+ let weightedPool = buildWeightedPool(servers.filter((s) => s.healthy));
41
+ let poolIndex = 0;
42
+
43
+ function rebuildPool() {
44
+ weightedPool = buildWeightedPool(servers.filter((s) => s.healthy));
45
+ poolIndex = 0;
46
+ }
47
+
26
48
  // Middleware
27
49
  app.use(cookieParser());
28
50
 
29
51
  // Health endpoint
52
+ const healthConfig = options.health || {};
30
53
  app.get("/_lb/health", (req, res) => {
31
54
  res.json({
32
55
  timestamp: new Date().toISOString(),
56
+ strategy,
33
57
  total: servers.length,
34
58
  healthy: servers.filter((s) => s.healthy).length,
35
59
  unhealthy: servers.filter((s) => !s.healthy).length,
36
- backends: servers.map((s) => ({
37
- id: s.id,
38
- url: s.url,
39
- healthy: s.healthy,
40
- requests: s.requests,
41
- lastChecked: s.lastChecked,
42
- })),
60
+ backends: servers.map((s) => {
61
+ const resolved = resolveHealthConfig(s, healthConfig);
62
+ return {
63
+ id: s.id,
64
+ url: s.url,
65
+ healthy: s.healthy,
66
+ weight: s.weight,
67
+ requests: s.requests,
68
+ activeConnections: s.activeConnections,
69
+ lastChecked: s.lastChecked,
70
+ healthCheck: {
71
+ path: resolved.path,
72
+ method: resolved.method,
73
+ algorithm: resolved.algorithm,
74
+ timeout: resolved.timeout,
75
+ // Don't expose headers (may contain auth tokens) or function source
76
+ hasCustomHeaders: Object.keys(resolved.headers).length > 0,
77
+ hasCustomFunction: typeof resolved.check === "function",
78
+ },
79
+ };
80
+ }),
43
81
  });
44
82
  });
45
83
 
@@ -48,15 +86,21 @@ function createStickyProxy(options = {}) {
48
86
  const healthyBackends = servers.filter((s) => s.healthy);
49
87
  if (!healthyBackends.length) return null;
50
88
 
51
- let backendId = parseInt(req.cookies["X-Backend-ID"]);
52
- if (!isNaN(backendId) && servers[backendId]?.healthy) {
53
- return servers[backendId];
89
+ // Honour sticky cookie if backend is still healthy
90
+ const cookieId = parseInt(req.cookies["X-Backend-ID"]);
91
+ if (!isNaN(cookieId)) {
92
+ const sticky = servers.find((s) => s.id === cookieId && s.healthy);
93
+ if (sticky) return sticky;
54
94
  }
55
95
 
56
- const selected = healthyBackends[current % healthyBackends.length];
57
- current++;
58
- res.cookie("X-Backend-ID", selected.id, { httpOnly: true });
59
- return selected;
96
+ // No valid sticky select via chosen strategy
97
+ const result = selectBackend(strategy, healthyBackends, weightedPool, poolIndex);
98
+ if (strategy === "round-robin") {
99
+ poolIndex = result.nextIndex;
100
+ }
101
+
102
+ res.cookie("X-Backend-ID", result.backend.id, { httpOnly: true });
103
+ return result.backend;
60
104
  }
61
105
 
62
106
  // Proxy instances
@@ -68,8 +112,19 @@ function createStickyProxy(options = {}) {
68
112
  target: backend.url,
69
113
  changeOrigin: true,
70
114
  ws: true,
71
- onProxyRes(proxyRes, req, res) {
72
- res.setHeader("x-backend", backend.url);
115
+ on: {
116
+ proxyRes(proxyRes, req, res) {
117
+ if (!res.headersSent) {
118
+ res.setHeader("x-backend", backend.url);
119
+ res.setHeader("x-backend-id", backend.id);
120
+ }
121
+ },
122
+ error(err, req, res) {
123
+ backend.activeConnections = Math.max(0, backend.activeConnections - 1);
124
+ if (!res.headersSent) {
125
+ res.status(502).json({ error: "Bad gateway — backend did not respond." });
126
+ }
127
+ },
73
128
  },
74
129
  })
75
130
  );
@@ -78,31 +133,48 @@ function createStickyProxy(options = {}) {
78
133
  // Proxy handler
79
134
  app.use((req, res, next) => {
80
135
  const backend = getHealthyBackend(req, res);
81
- if (!backend) return res.status(503).send("All servers down.");
136
+ if (!backend) {
137
+ return res.status(503).json({ error: "No healthy backends available." });
138
+ }
82
139
 
83
140
  backend.requests++;
84
- io.emit("request", { ip: req.ip, to: backend.url });
85
- io.emit("update", servers);
141
+ backend.activeConnections++;
142
+
143
+ // Decrement active connections when response finishes
144
+ res.on("finish", () => {
145
+ backend.activeConnections = Math.max(0, backend.activeConnections - 1);
146
+ });
147
+
148
+ // Emit per-request event for live dashboards — lightweight, no full server list
149
+ io.emit("request", { ip: req.ip, to: backend.url, backendId: backend.id, strategy });
86
150
 
87
151
  const proxy = proxies.get(backend.id);
88
152
  proxy(req, res, next);
89
153
  });
90
154
 
91
- // Health checks with optional email alerts
155
+ // Health checks rebuild weighted pool on status change
92
156
  startHealthChecker(
93
157
  servers,
94
158
  options.health || { interval: 10000, timeout: 2000 },
95
- options.email ? (server) => { sendDownAlert(server, options.email); io.emit("update", servers); } : null,
96
- options.email ? (server) => { sendUpAlert(server, options.email); io.emit("update", servers); } : null
159
+ (server) => {
160
+ rebuildPool();
161
+ io.emit("update", servers);
162
+ if (options.email) sendDownAlert(server, options.email);
163
+ },
164
+ (server) => {
165
+ rebuildPool();
166
+ io.emit("update", servers);
167
+ if (options.email) sendUpAlert(server, options.email);
168
+ }
97
169
  );
98
170
 
99
171
  function start() {
100
172
  server.listen(options.port, () =>
101
- console.log(`Sticky Proxy running on port ${options.port}`)
173
+ console.log(`[smart-stick-loadbalancer] Running on port ${options.port} | strategy: ${strategy}`)
102
174
  );
103
175
  }
104
176
 
105
177
  return { app, server, io, start, servers };
106
178
  }
107
179
 
108
- module.exports = { createStickyProxy };
180
+ module.exports = { createStickyProxy };
package/src/notifier.js CHANGED
@@ -1,33 +1,48 @@
1
1
  const nodemailer = require("nodemailer");
2
2
 
3
- function createTransport(emailConfig) {
3
+ const transporterCache = new WeakMap();
4
+
5
+ function getTransporter(emailConfig) {
4
6
  if (!emailConfig) return null;
5
- return nodemailer.createTransport({
7
+ if (transporterCache.has(emailConfig)) return transporterCache.get(emailConfig);
8
+
9
+ const transporter = nodemailer.createTransport({
6
10
  service: emailConfig.service,
7
11
  auth: emailConfig.auth,
8
12
  });
13
+
14
+ transporterCache.set(emailConfig, transporter);
15
+ return transporter;
9
16
  }
10
17
 
11
- function sendDownAlert(server, emailConfig) {
12
- const transporter = createTransport(emailConfig);
13
- if (!transporter) return;
14
- transporter.sendMail({
15
- from: emailConfig.auth.user,
16
- to: server.owner,
17
- subject: "⚠️ Backend Down",
18
- text: `Your backend at ${server.url} is down.`,
19
- });
18
+ async function sendDownAlert(server, emailConfig) {
19
+ const transporter = getTransporter(emailConfig);
20
+ if (!transporter || !server.owner) return;
21
+ try {
22
+ await transporter.sendMail({
23
+ from: emailConfig.auth.user,
24
+ to: server.owner,
25
+ subject: "⚠️ Backend Down",
26
+ text: `Your backend at ${server.url} is down.\nTime: ${new Date().toISOString()}`,
27
+ });
28
+ } catch (err) {
29
+ console.error(`[smart-stick-loadbalancer] Failed to send down alert for ${server.url}:`, err.message);
30
+ }
20
31
  }
21
32
 
22
- function sendUpAlert(server, emailConfig) {
23
- const transporter = createTransport(emailConfig);
24
- if (!transporter) return;
25
- transporter.sendMail({
26
- from: emailConfig.auth.user,
27
- to: server.owner,
28
- subject: "✅ Backend Recovered",
29
- text: `Your backend at ${server.url} is back online.`,
30
- });
33
+ async function sendUpAlert(server, emailConfig) {
34
+ const transporter = getTransporter(emailConfig);
35
+ if (!transporter || !server.owner) return;
36
+ try {
37
+ await transporter.sendMail({
38
+ from: emailConfig.auth.user,
39
+ to: server.owner,
40
+ subject: "✅ Backend Recovered",
41
+ text: `Your backend at ${server.url} is back online.\nTime: ${new Date().toISOString()}`,
42
+ });
43
+ } catch (err) {
44
+ console.error(`[smart-stick-loadbalancer] Failed to send up alert for ${server.url}:`, err.message);
45
+ }
31
46
  }
32
47
 
33
- module.exports = { sendDownAlert, sendUpAlert };
48
+ module.exports = { sendDownAlert, sendUpAlert };
package/src/router.js ADDED
@@ -0,0 +1,41 @@
1
+ function buildWeightedPool(healthyBackends) {
2
+ const pool = [];
3
+ for (const backend of healthyBackends) {
4
+ const slots = Math.max(1, Math.round(backend.weight ?? 1));
5
+ for (let i = 0; i < slots; i++) {
6
+ pool.push(backend);
7
+ }
8
+ }
9
+ return pool;
10
+ }
11
+
12
+ function selectBackend(strategy, healthyBackends, weightedPool, currentIndex) {
13
+ switch (strategy) {
14
+ case "round-robin": {
15
+ if (!weightedPool.length) return { backend: healthyBackends[0], nextIndex: 0 };
16
+ const backend = weightedPool[currentIndex % weightedPool.length];
17
+ return { backend, nextIndex: (currentIndex + 1) % weightedPool.length };
18
+ }
19
+
20
+ case "least-connections": {
21
+ // Pick the healthy backend with the lowest activeConnections count.
22
+ // Ties broken by whichever appears first (effectively round-robin on ties).
23
+ const backend = healthyBackends.reduce((best, candidate) =>
24
+ candidate.activeConnections < best.activeConnections ? candidate : best
25
+ );
26
+ return { backend, nextIndex: currentIndex };
27
+ }
28
+
29
+ case "random": {
30
+ // Weighted random — pick a random slot from the weighted pool.
31
+ if (!weightedPool.length) return { backend: healthyBackends[0], nextIndex: 0 };
32
+ const idx = Math.floor(Math.random() * weightedPool.length);
33
+ return { backend: weightedPool[idx], nextIndex: currentIndex };
34
+ }
35
+
36
+ default:
37
+ return { backend: healthyBackends[0], nextIndex: 0 };
38
+ }
39
+ }
40
+
41
+ module.exports = { buildWeightedPool, selectBackend };
package/src/config.json DELETED
@@ -1,28 +0,0 @@
1
- {
2
- "port": 3001,
3
- "backends": [
4
- {
5
- "id": 0,
6
- "url":"<link to reviewer instance>",
7
- "owner": "<reviewer email>",
8
- "weight": 1
9
- },
10
- {
11
- "id": 1,
12
- "url": "<link to reviewer instance>",
13
- "owner": "<reviewer email>",
14
- "weight": 1
15
- }
16
- ],
17
- "health": {
18
- "interval": 10000,
19
- "timeout": 2000
20
- },
21
- "email": {
22
- "service": "gmail",
23
- "auth": {
24
- "user": "<sendermail>@gmail.com",
25
- "pass": "<app password>"
26
- }
27
- }
28
- }