worker-lb 0.0.2 → 0.1.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.
- package/README.md +53 -1
- package/package.json +1 -1
- package/src/index.ts +173 -75
package/README.md
CHANGED
|
@@ -5,13 +5,65 @@
|
|
|
5
5
|
|
|
6
6
|
worker-lb is a small and extensible load balancer built on Cloudflare Workers. It's basically a poor mans Cloudflare Load Balancer, but running on Cloudflare Workers, because why not.
|
|
7
7
|
|
|
8
|
+
Note that in the current state it actually works more like a failover/high availability solution rather than a fully fledged load balancer. You can see the load balancing status bits as coming soon below.
|
|
9
|
+
|
|
8
10
|
## Features
|
|
9
11
|
|
|
10
12
|
- Failover between multiple HTTP endpoints
|
|
11
13
|
- Health checks with customizable intervals and timeouts
|
|
12
|
-
- Recovery function to dump requests that did not reach any healthy endpoints
|
|
14
|
+
- Recovery function to dump requests that did not reach any healthy endpoints
|
|
15
|
+
- Geo steering (route by continent, country, region, or Cloudflare colo)
|
|
16
|
+
- Response time based steering (coming soon)
|
|
17
|
+
- Number of connections based steering (coming soon)
|
|
18
|
+
|
|
19
|
+
## Installation
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
bun add worker-lb
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Quick Start
|
|
26
|
+
|
|
27
|
+
```typescript
|
|
28
|
+
import { Endpoint, LoadBalancer } from "worker-lb";
|
|
29
|
+
|
|
30
|
+
const lb = new LoadBalancer({
|
|
31
|
+
endpoints: [
|
|
32
|
+
new Endpoint("https://api1.example.com"),
|
|
33
|
+
new Endpoint("https://api2.example.com"),
|
|
34
|
+
new Endpoint("https://api3.example.com"),
|
|
35
|
+
],
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
export default {
|
|
39
|
+
async fetch(request: Request<unknown, IncomingRequestCfProperties>): Promise<Response> {
|
|
40
|
+
return lb.handleRequest(request);
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
```
|
|
13
44
|
|
|
14
45
|
## Use Cases
|
|
15
46
|
|
|
16
47
|
- Simple load balancing for dirty small projects
|
|
17
48
|
- Latency is not a concern but reliability is
|
|
49
|
+
- Geographic routing to regional backends
|
|
50
|
+
|
|
51
|
+
## Documentation
|
|
52
|
+
|
|
53
|
+
- [Endpoints](./docs/endpoints.md) - Configure backend endpoints
|
|
54
|
+
- [Availability Methods](./docs/availability.md) - Failover strategies
|
|
55
|
+
- [Geo Steering](./docs/geo-steering.md) - Route by geographic location
|
|
56
|
+
|
|
57
|
+
## Headers Added to Responses
|
|
58
|
+
|
|
59
|
+
| Header | Description |
|
|
60
|
+
|--------|-------------|
|
|
61
|
+
| `X-Load-Balancer-Endpoint` | The endpoint URL that served the request |
|
|
62
|
+
| `X-Load-Balancer-Latency` | Total request latency in milliseconds |
|
|
63
|
+
| `X-Load-Balancer-Endpoint-Gather-Latency` | Time to select the endpoint in milliseconds |
|
|
64
|
+
| `X-Load-Balancer-Tried-Count` | Number of endpoints tried (only on failover) |
|
|
65
|
+
| `X-Load-Balancer-Tried-Endpoints` | Comma-separated endpoint URLs tried (only on failover) |
|
|
66
|
+
|
|
67
|
+
## License
|
|
68
|
+
|
|
69
|
+
MIT
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -1,36 +1,29 @@
|
|
|
1
|
+
import { Endpoint, GeoEndpoint } from "./endpoints/index.ts";
|
|
2
|
+
|
|
3
|
+
export { Endpoint, GeoEndpoint };
|
|
4
|
+
export type {
|
|
5
|
+
EndpointOptions,
|
|
6
|
+
GeoConfig,
|
|
7
|
+
GeoEndpointOptions,
|
|
8
|
+
} from "./endpoints/index.ts";
|
|
9
|
+
|
|
1
10
|
/**
|
|
2
|
-
* The process in which
|
|
11
|
+
* The process in which failover is handled.
|
|
3
12
|
*
|
|
4
13
|
* `async-block` – One by one check and select the first available endpoint
|
|
5
14
|
*
|
|
6
15
|
* `promise.any` – Use Promise.any to select the first available endpoint that responds successfully
|
|
7
16
|
*
|
|
8
17
|
* `fail-forward` – Always use the first endpoint, fail over to the next only on specified failure conditions
|
|
9
|
-
*
|
|
10
18
|
*/
|
|
11
19
|
export type AvailabilityMethodType =
|
|
12
20
|
| "async-block"
|
|
13
21
|
| "promise.any"
|
|
14
22
|
| "fail-forward";
|
|
15
23
|
|
|
16
|
-
|
|
17
|
-
"async-block":
|
|
18
|
-
|
|
19
|
-
* async-block requires a health endpoint to check availability of the service
|
|
20
|
-
*
|
|
21
|
-
* @example "/health"
|
|
22
|
-
*/
|
|
23
|
-
healthCheckPathname: `/${string}`;
|
|
24
|
-
};
|
|
25
|
-
"promise.any": {
|
|
26
|
-
/**
|
|
27
|
-
* promise.any requires a health endpoint to check availability of the service
|
|
28
|
-
*
|
|
29
|
-
* @example "/health"
|
|
30
|
-
*
|
|
31
|
-
*/
|
|
32
|
-
healthEndpoint: `/${string}`;
|
|
33
|
-
};
|
|
24
|
+
type AvailabilityMethodOptions = {
|
|
25
|
+
"async-block": Record<string, never>;
|
|
26
|
+
"promise.any": Record<string, never>;
|
|
34
27
|
"fail-forward": {
|
|
35
28
|
/**
|
|
36
29
|
* Statuses that should result in a failover
|
|
@@ -39,7 +32,7 @@ export interface AvailabilityMethodOptions {
|
|
|
39
32
|
*/
|
|
40
33
|
failoverOnStatuses?: number[];
|
|
41
34
|
};
|
|
42
|
-
}
|
|
35
|
+
};
|
|
43
36
|
|
|
44
37
|
export type AvailabilityMethod = {
|
|
45
38
|
[K in AvailabilityMethodType]: {
|
|
@@ -50,34 +43,75 @@ export type AvailabilityMethod = {
|
|
|
50
43
|
|
|
51
44
|
export const DEFAULT_FAILOVER_STATUSES = [502, 503, 504];
|
|
52
45
|
|
|
53
|
-
type
|
|
46
|
+
export type RecoveryContext = {
|
|
47
|
+
/**
|
|
48
|
+
* The endpoints that were tried before all failed
|
|
49
|
+
*/
|
|
50
|
+
triedEndpoints: Endpoint[];
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
type RecoveryFn = (
|
|
54
|
+
request: Request,
|
|
55
|
+
context: RecoveryContext,
|
|
56
|
+
) => Promise<Response | undefined | void>;
|
|
54
57
|
|
|
55
|
-
|
|
58
|
+
type GeoSteering = {
|
|
59
|
+
type: "geo";
|
|
56
60
|
/**
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
* @example ["https://us-east-1.api.capy.lol/v1", "https://us-west-1.api.capy.lol/v1"]
|
|
61
|
+
* Default endpoints to use when no geo match is found.
|
|
62
|
+
* If not provided and no match is found, the first endpoint in the list is used.
|
|
60
63
|
*/
|
|
61
|
-
|
|
64
|
+
defaultEndpoints?: Endpoint[];
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
type Steering = GeoSteering;
|
|
68
|
+
|
|
69
|
+
type LoadBalancerOptionsBase = {
|
|
62
70
|
/**
|
|
63
71
|
* The availability method when failure happens.
|
|
64
72
|
*
|
|
65
|
-
* @default { type: "fail-forward"
|
|
73
|
+
* @default { type: "fail-forward" }
|
|
66
74
|
*/
|
|
67
75
|
availability?: AvailabilityMethod;
|
|
68
76
|
/**
|
|
69
|
-
* A recovery function that you can customize to dump data or notify on failures.
|
|
77
|
+
* A recovery function that you can customize to dump data or notify on failures.
|
|
78
|
+
* This only runs when all endpoints are unavailable.
|
|
70
79
|
*
|
|
71
|
-
* Some ideas
|
|
80
|
+
* Some ideas:
|
|
72
81
|
* - Send a notification to a monitoring service
|
|
73
82
|
* - Dump the request data to an S3/R2 bucket for replaying later.
|
|
74
83
|
*/
|
|
75
84
|
recoveryFn?: RecoveryFn;
|
|
76
|
-
}
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
type LoadBalancerOptionsWithoutSteering = LoadBalancerOptionsBase & {
|
|
88
|
+
/**
|
|
89
|
+
* The endpoints to load balance against
|
|
90
|
+
*/
|
|
91
|
+
endpoints: Endpoint[];
|
|
92
|
+
steering?: never;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
type LoadBalancerOptionsWithGeoSteering = LoadBalancerOptionsBase & {
|
|
96
|
+
/**
|
|
97
|
+
* The geo endpoints to load balance against.
|
|
98
|
+
* Each endpoint specifies which geographic regions it serves.
|
|
99
|
+
*/
|
|
100
|
+
endpoints: GeoEndpoint[];
|
|
101
|
+
/**
|
|
102
|
+
* Geo steering configuration
|
|
103
|
+
*/
|
|
104
|
+
steering: GeoSteering;
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
export type LoadBalancerOptions =
|
|
108
|
+
| LoadBalancerOptionsWithoutSteering
|
|
109
|
+
| LoadBalancerOptionsWithGeoSteering;
|
|
77
110
|
|
|
78
111
|
export class LoadBalancer {
|
|
79
|
-
private endpoints:
|
|
112
|
+
private endpoints: Endpoint[] | GeoEndpoint[];
|
|
80
113
|
private availability: AvailabilityMethod;
|
|
114
|
+
private steering?: Steering;
|
|
81
115
|
private recoveryFn?: RecoveryFn;
|
|
82
116
|
|
|
83
117
|
constructor(options: LoadBalancerOptions) {
|
|
@@ -85,7 +119,8 @@ export class LoadBalancer {
|
|
|
85
119
|
throw new Error("At least one endpoint is required");
|
|
86
120
|
}
|
|
87
121
|
|
|
88
|
-
this.endpoints = options.endpoints
|
|
122
|
+
this.endpoints = options.endpoints;
|
|
123
|
+
this.steering = options.steering;
|
|
89
124
|
this.availability = options.availability ?? {
|
|
90
125
|
type: "fail-forward",
|
|
91
126
|
};
|
|
@@ -102,7 +137,10 @@ export class LoadBalancer {
|
|
|
102
137
|
this.recoveryFn = options.recoveryFn;
|
|
103
138
|
}
|
|
104
139
|
|
|
105
|
-
|
|
140
|
+
/**
|
|
141
|
+
* Get the first available endpoint based on the availability method.
|
|
142
|
+
*/
|
|
143
|
+
async getAvailableEndpoint(): Promise<Endpoint> {
|
|
106
144
|
switch (this.availability.type) {
|
|
107
145
|
case "async-block":
|
|
108
146
|
return this.getEndpointAsyncBlock();
|
|
@@ -113,20 +151,11 @@ export class LoadBalancer {
|
|
|
113
151
|
}
|
|
114
152
|
}
|
|
115
153
|
|
|
116
|
-
private async getEndpointAsyncBlock(): Promise<
|
|
117
|
-
if (this.availability.type !== "async-block") {
|
|
118
|
-
throw new Error("Availability method is not async-block");
|
|
119
|
-
}
|
|
120
|
-
|
|
154
|
+
private async getEndpointAsyncBlock(): Promise<Endpoint> {
|
|
121
155
|
for (const endpoint of this.endpoints) {
|
|
122
156
|
try {
|
|
123
|
-
const
|
|
124
|
-
|
|
125
|
-
{
|
|
126
|
-
method: "GET",
|
|
127
|
-
},
|
|
128
|
-
);
|
|
129
|
-
if (response.ok) {
|
|
157
|
+
const isHealthy = await endpoint.healthCheck();
|
|
158
|
+
if (isHealthy) {
|
|
130
159
|
return endpoint;
|
|
131
160
|
}
|
|
132
161
|
} catch {
|
|
@@ -136,19 +165,11 @@ export class LoadBalancer {
|
|
|
136
165
|
throw new Error("No available endpoints");
|
|
137
166
|
}
|
|
138
167
|
|
|
139
|
-
private async getEndpointPromiseAny(): Promise<
|
|
140
|
-
if (this.availability.type !== "promise.any") {
|
|
141
|
-
throw new Error("Availability method is not promise.any");
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
const healthPathname = this.availability.options?.healthEndpoint;
|
|
145
|
-
|
|
168
|
+
private async getEndpointPromiseAny(): Promise<Endpoint> {
|
|
146
169
|
const healthChecks = this.endpoints.map(async (endpoint) => {
|
|
147
|
-
const
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
if (!response.ok) {
|
|
151
|
-
throw new Error(`Endpoint ${endpoint} returned ${response.status}`);
|
|
170
|
+
const isHealthy = await endpoint.healthCheck();
|
|
171
|
+
if (!isHealthy) {
|
|
172
|
+
throw new Error(`Endpoint ${endpoint.url} is not healthy`);
|
|
152
173
|
}
|
|
153
174
|
return endpoint;
|
|
154
175
|
});
|
|
@@ -160,29 +181,87 @@ export class LoadBalancer {
|
|
|
160
181
|
}
|
|
161
182
|
}
|
|
162
183
|
|
|
163
|
-
|
|
184
|
+
/**
|
|
185
|
+
* Get endpoints to try based on steering and availability configuration.
|
|
186
|
+
* Returns all endpoints ordered by priority for failover:
|
|
187
|
+
* 1. Geo-matched endpoints (if geo steering enabled)
|
|
188
|
+
* 2. Default endpoints (if configured and different from matched)
|
|
189
|
+
* 3. All remaining endpoints
|
|
190
|
+
*/
|
|
191
|
+
private async getEndpointsToTry(
|
|
192
|
+
request: Request<unknown, IncomingRequestCfProperties>,
|
|
193
|
+
): Promise<Endpoint[]> {
|
|
194
|
+
if (this.steering?.type === "geo") {
|
|
195
|
+
const geoEndpoints = this.endpoints as GeoEndpoint[];
|
|
196
|
+
const matchingEndpoints = geoEndpoints.filter((endpoint) =>
|
|
197
|
+
endpoint.matchesRequest(request),
|
|
198
|
+
);
|
|
199
|
+
|
|
200
|
+
// Build ordered list: matched -> defaults -> remaining
|
|
201
|
+
const orderedEndpoints: Endpoint[] = [];
|
|
202
|
+
const seen = new Set<Endpoint>();
|
|
203
|
+
|
|
204
|
+
// Add geo-matched endpoints first
|
|
205
|
+
for (const endpoint of matchingEndpoints) {
|
|
206
|
+
orderedEndpoints.push(endpoint);
|
|
207
|
+
seen.add(endpoint);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Add default endpoints if they're not already included
|
|
211
|
+
if (this.steering.defaultEndpoints) {
|
|
212
|
+
for (const endpoint of this.steering.defaultEndpoints) {
|
|
213
|
+
if (!seen.has(endpoint)) {
|
|
214
|
+
orderedEndpoints.push(endpoint);
|
|
215
|
+
seen.add(endpoint);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Add all remaining endpoints for failover
|
|
221
|
+
for (const endpoint of this.endpoints) {
|
|
222
|
+
if (!seen.has(endpoint)) {
|
|
223
|
+
orderedEndpoints.push(endpoint);
|
|
224
|
+
seen.add(endpoint);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
if (this.availability.type === "fail-forward") {
|
|
229
|
+
return orderedEndpoints;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// For async-block and promise.any, get the first healthy endpoint
|
|
233
|
+
const originalEndpoints = this.endpoints;
|
|
234
|
+
this.endpoints = orderedEndpoints;
|
|
235
|
+
try {
|
|
236
|
+
const healthyEndpoint = await this.getAvailableEndpoint();
|
|
237
|
+
return [healthyEndpoint];
|
|
238
|
+
} finally {
|
|
239
|
+
this.endpoints = originalEndpoints;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// No geo steering - use all endpoints
|
|
164
244
|
if (this.availability.type === "fail-forward") {
|
|
165
245
|
return this.endpoints;
|
|
166
246
|
}
|
|
167
247
|
|
|
168
|
-
|
|
248
|
+
// For async-block and promise.any, get the first healthy endpoint
|
|
249
|
+
const healthyEndpoint = await this.getAvailableEndpoint();
|
|
250
|
+
return [healthyEndpoint];
|
|
169
251
|
}
|
|
170
252
|
|
|
171
|
-
async handleRequest(
|
|
253
|
+
async handleRequest(
|
|
254
|
+
request: Request<unknown, IncomingRequestCfProperties>,
|
|
255
|
+
): Promise<Response> {
|
|
172
256
|
const startTime = Date.now();
|
|
173
|
-
const endpointsToTry = await this.getEndpointsToTry();
|
|
174
|
-
const
|
|
175
|
-
const url = new URL(request.url);
|
|
257
|
+
const endpointsToTry = await this.getEndpointsToTry(request);
|
|
258
|
+
const triedEndpoints: Endpoint[] = [];
|
|
176
259
|
|
|
177
260
|
for (const endpoint of endpointsToTry) {
|
|
261
|
+
triedEndpoints.push(endpoint);
|
|
262
|
+
const attemptStart = Date.now();
|
|
178
263
|
try {
|
|
179
|
-
const
|
|
180
|
-
const response = await fetch(targetUrl, {
|
|
181
|
-
method: request.method,
|
|
182
|
-
headers: request.headers,
|
|
183
|
-
body: request.body,
|
|
184
|
-
redirect: "follow",
|
|
185
|
-
});
|
|
264
|
+
const response = await endpoint.commitRequest(request);
|
|
186
265
|
|
|
187
266
|
const shouldFailover =
|
|
188
267
|
this.availability.type === "fail-forward" &&
|
|
@@ -193,30 +272,49 @@ export class LoadBalancer {
|
|
|
193
272
|
if (!shouldFailover) {
|
|
194
273
|
const endTime = Date.now();
|
|
195
274
|
const headers = new Headers(response.headers);
|
|
196
|
-
headers.set("X-Load-Balancer-Endpoint", endpoint);
|
|
275
|
+
headers.set("X-Load-Balancer-Endpoint", endpoint.url);
|
|
197
276
|
headers.set(
|
|
198
277
|
"X-Load-Balancer-Latency",
|
|
199
278
|
(endTime - startTime).toString(),
|
|
200
279
|
);
|
|
201
280
|
headers.set(
|
|
202
281
|
"X-Load-Balancer-Endpoint-Gather-Latency",
|
|
203
|
-
(
|
|
282
|
+
(attemptStart - startTime).toString(),
|
|
204
283
|
);
|
|
205
284
|
|
|
285
|
+
// Add failover trace headers if we tried more than one endpoint
|
|
286
|
+
if (triedEndpoints.length > 1) {
|
|
287
|
+
headers.set(
|
|
288
|
+
"X-Load-Balancer-Tried-Count",
|
|
289
|
+
triedEndpoints.length.toString(),
|
|
290
|
+
);
|
|
291
|
+
headers.set(
|
|
292
|
+
"X-Load-Balancer-Tried-Endpoints",
|
|
293
|
+
triedEndpoints.map((e) => e.url).join(", "),
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
|
|
206
297
|
return new Response(response.body, {
|
|
207
298
|
status: response.status,
|
|
208
299
|
statusText: response.statusText,
|
|
209
300
|
headers,
|
|
210
301
|
});
|
|
211
302
|
}
|
|
212
|
-
} catch {
|
|
303
|
+
} catch (e) {
|
|
304
|
+
console.log(e);
|
|
213
305
|
// Network error, try next endpoint
|
|
214
306
|
continue;
|
|
215
307
|
}
|
|
216
308
|
}
|
|
217
309
|
|
|
218
310
|
if (this.recoveryFn) {
|
|
219
|
-
await this.recoveryFn(request
|
|
311
|
+
const recoveryResponse = await this.recoveryFn(request, {
|
|
312
|
+
triedEndpoints,
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
if (recoveryResponse) {
|
|
316
|
+
return recoveryResponse;
|
|
317
|
+
}
|
|
220
318
|
}
|
|
221
319
|
|
|
222
320
|
throw new Error("No available endpoints");
|