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.
Files changed (3) hide show
  1. package/README.md +53 -1
  2. package/package.json +1 -1
  3. 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "worker-lb",
3
- "version": "0.0.2",
3
+ "version": "0.1.3",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "module": "src/index.ts",
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 fall back is handled.
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
- export interface AvailabilityMethodOptions {
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 RecoveryFn = (request: Request) => Promise<Response | undefined>;
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
- export interface LoadBalancerOptions {
58
+ type GeoSteering = {
59
+ type: "geo";
56
60
  /**
57
- * The endpoints that you want to load balance against
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
- endpoints: string[];
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. This only runs when all endpoints are unavailable.
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 for ya bud:
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: string[];
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.map((url) => url.replace(/\/$/, "")); // remove trailing slash
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
- async getAvailableEndpoint(): Promise<string> {
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<string> {
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 response = await fetch(
124
- endpoint + this.availability.options?.healthCheckPathname,
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<string> {
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 response = await fetch(endpoint + healthPathname, {
148
- method: "GET",
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
- private async getEndpointsToTry(): Promise<string[]> {
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
- return [await this.getAvailableEndpoint()];
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(request: Request): Promise<Response> {
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 gatherEndpointLatency = Date.now();
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 targetUrl = endpoint + url.pathname + url.search;
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
- (gatherEndpointLatency - startTime).toString(),
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");