smart-stick-loadbalancer 1.0.6 → 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.
- package/CONTRIBUTING.md +55 -0
- package/README.md +466 -40
- package/code_of_conduct.md +30 -0
- package/package.json +1 -1
- package/src/healthChecker.js +59 -14
- package/src/index.js +100 -28
- package/src/notifier.js +36 -21
- package/src/router.js +41 -0
- package/src/config.json +0 -28
package/CONTRIBUTING.md
ADDED
|
@@ -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
|
-
|
|
3
|
+

|
|
4
|
+

|
|
5
|
+

|
|
6
|
+

|
|
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
|
|
10
|
-
-
|
|
11
|
-
-
|
|
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
|
-
-
|
|
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
|
-
##
|
|
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
|
-
"
|
|
35
|
-
"
|
|
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
|
-
"
|
|
41
|
-
"
|
|
147
|
+
"weight": 1,
|
|
148
|
+
"healthPath": "/ready",
|
|
149
|
+
"healthMethod": "HEAD",
|
|
150
|
+
"healthTimeout": 5000,
|
|
151
|
+
"owner": "your-email@example.com"
|
|
42
152
|
}
|
|
43
153
|
],
|
|
44
|
-
"health": {
|
|
154
|
+
"health": {
|
|
155
|
+
"interval": 10000,
|
|
156
|
+
"timeout": 2000,
|
|
157
|
+
"algorithm": "http-status"
|
|
158
|
+
},
|
|
45
159
|
"email": {
|
|
46
160
|
"service": "gmail",
|
|
47
|
-
"auth": {
|
|
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();
|
|
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
|
-
|
|
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
|
-
##
|
|
246
|
+
## Health Checks
|
|
71
247
|
|
|
72
|
-
|
|
248
|
+
Every health check setting can be set globally under `health:` and overridden per backend. Per-backend always wins.
|
|
73
249
|
|
|
74
|
-
|
|
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
|
-
|
|
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(
|
|
79
|
-
app.
|
|
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(
|
|
87
|
-
app.
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
108
|
-
|
|
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
|
-
|
|
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
|
-
{
|
|
121
|
-
|
|
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
|
-
|
|
131
|
-
|
|
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
package/src/healthChecker.js
CHANGED
|
@@ -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.
|
|
6
|
-
|
|
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,
|
|
50
|
+
function startHealthChecker(servers, globalConfig, onDown, onUp) {
|
|
13
51
|
const lastStatus = new Map();
|
|
14
|
-
const interval =
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
25
|
-
|
|
62
|
+
if (!isUp && wasUp && onDown) onDown(server);
|
|
63
|
+
if (isUp && !wasUp && onUp) onUp(server);
|
|
26
64
|
|
|
27
|
-
|
|
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
|
|
11
|
-
throw new Error("
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
72
|
-
|
|
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)
|
|
136
|
+
if (!backend) {
|
|
137
|
+
return res.status(503).json({ error: "No healthy backends available." });
|
|
138
|
+
}
|
|
82
139
|
|
|
83
140
|
backend.requests++;
|
|
84
|
-
|
|
85
|
-
|
|
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
|
|
155
|
+
// Health checks — rebuild weighted pool on status change
|
|
92
156
|
startHealthChecker(
|
|
93
157
|
servers,
|
|
94
158
|
options.health || { interval: 10000, timeout: 2000 },
|
|
95
|
-
|
|
96
|
-
|
|
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(`
|
|
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
|
-
|
|
3
|
+
const transporterCache = new WeakMap();
|
|
4
|
+
|
|
5
|
+
function getTransporter(emailConfig) {
|
|
4
6
|
if (!emailConfig) return null;
|
|
5
|
-
return
|
|
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 =
|
|
13
|
-
if (!transporter) return;
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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 =
|
|
24
|
-
if (!transporter) return;
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
}
|