worker-lb 0.0.1

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 +17 -0
  2. package/package.json +27 -0
  3. package/src/index.ts +226 -0
package/README.md ADDED
@@ -0,0 +1,17 @@
1
+ # worker-lb
2
+
3
+ > [!NOTE]
4
+ > **WARNING:** I don't know if you should use this in production just yet, this is more of an experiment and by no means is battle-tested.
5
+
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
+
8
+ ## Features
9
+
10
+ - Failover between multiple HTTP endpoints
11
+ - Health checks with customizable intervals and timeouts
12
+ - "Dooms-day mode" to automatically dump traffic to a log file when all backends are down (not finished yet)
13
+
14
+ ## Use Cases
15
+
16
+ - Simple load balancing for dirty small projects
17
+ - Latency is not a concern but reliability is
package/package.json ADDED
@@ -0,0 +1,27 @@
1
+ {
2
+ "name": "worker-lb",
3
+ "version": "0.0.1",
4
+ "type": "module",
5
+ "main": "src/index.ts",
6
+ "module": "src/index.ts",
7
+ "types": "src/index.ts",
8
+ "files": [
9
+ "src/index.ts"
10
+ ],
11
+ "scripts": {
12
+ "publish": "bun publish"
13
+ },
14
+ "exports": {
15
+ ".": {
16
+ "import": "./src/index.ts",
17
+ "types": "./src/index.ts"
18
+ }
19
+ },
20
+ "devDependencies": {
21
+ "@cloudflare/workers-types": "^4.20241218.0",
22
+ "@types/bun": "^1.3.6"
23
+ },
24
+ "peerDependencies": {
25
+ "typescript": "^5"
26
+ }
27
+ }
package/src/index.ts ADDED
@@ -0,0 +1,226 @@
1
+ /**
2
+ * The process in which fall back is handled.
3
+ *
4
+ * `async-block` – One by one check and select the first available endpoint
5
+ *
6
+ * `promise.any` – Use Promise.any to select the first available endpoint that responds successfully
7
+ *
8
+ * `fail-forward` – Always use the first endpoint, fail over to the next only on specified failure conditions
9
+ *
10
+ */
11
+ export type AvailabilityMethodType =
12
+ | "async-block"
13
+ | "promise.any"
14
+ | "fail-forward";
15
+
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
+ };
34
+ "fail-forward": {
35
+ /**
36
+ * Statuses that should result in a failover
37
+ *
38
+ * @default [502, 503, 504]
39
+ */
40
+ failoverOnStatuses?: number[];
41
+ };
42
+ }
43
+
44
+ export type AvailabilityMethod = {
45
+ [K in AvailabilityMethodType]: {
46
+ type: K;
47
+ options?: AvailabilityMethodOptions[K];
48
+ };
49
+ }[AvailabilityMethodType];
50
+
51
+ export const DEFAULT_FAILOVER_STATUSES = [502, 503, 504];
52
+
53
+ type RecoveryFn = (request: Request) => Promise<Response | undefined>;
54
+
55
+ export interface LoadBalancerOptions {
56
+ /**
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"]
60
+ */
61
+ endpoints: string[];
62
+ /**
63
+ * The availability method when failure happens.
64
+ *
65
+ * @default { type: "fail-forward" }
66
+ */
67
+ availability?: AvailabilityMethod;
68
+ /**
69
+ * A recovery function that you can customize to dump data or notify on failures. This only runs when all endpoints are unavailable.
70
+ *
71
+ * Some ideas for ya bud:
72
+ * - Send a notification to a monitoring service
73
+ * - Dump the request data to an S3/R2 bucket for replaying later.
74
+ */
75
+ recoveryFn?: RecoveryFn;
76
+ }
77
+
78
+ export class LoadBalancer {
79
+ private endpoints: string[];
80
+ private availability: AvailabilityMethod;
81
+ private recoveryFn?: RecoveryFn;
82
+
83
+ constructor(options: LoadBalancerOptions) {
84
+ if (!options.endpoints || options.endpoints.length === 0) {
85
+ throw new Error("At least one endpoint is required");
86
+ }
87
+
88
+ this.endpoints = options.endpoints.map((url) => url.replace(/\/$/, "")); // remove trailing slash
89
+ this.availability = options.availability ?? {
90
+ type: "fail-forward",
91
+ };
92
+
93
+ if (
94
+ this.availability.type === "fail-forward" &&
95
+ !this.availability.options?.failoverOnStatuses
96
+ ) {
97
+ this.availability.options = {
98
+ failoverOnStatuses: DEFAULT_FAILOVER_STATUSES,
99
+ };
100
+ }
101
+
102
+ this.recoveryFn = options.recoveryFn;
103
+ }
104
+
105
+ async getAvailableEndpoint(): Promise<string> {
106
+ switch (this.availability.type) {
107
+ case "async-block":
108
+ return this.getEndpointAsyncBlock();
109
+ case "promise.any":
110
+ return this.getEndpointPromiseAny();
111
+ case "fail-forward":
112
+ return this.endpoints[0]!;
113
+ }
114
+ }
115
+
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
+
121
+ for (const endpoint of this.endpoints) {
122
+ try {
123
+ const response = await fetch(
124
+ endpoint + this.availability.options?.healthCheckPathname,
125
+ {
126
+ method: "GET",
127
+ },
128
+ );
129
+ if (response.ok) {
130
+ return endpoint;
131
+ }
132
+ } catch {
133
+ continue;
134
+ }
135
+ }
136
+ throw new Error("No available endpoints");
137
+ }
138
+
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
+
146
+ 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}`);
152
+ }
153
+ return endpoint;
154
+ });
155
+
156
+ try {
157
+ return await Promise.any(healthChecks);
158
+ } catch {
159
+ throw new Error("No available endpoints");
160
+ }
161
+ }
162
+
163
+ private async getEndpointsToTry(): Promise<string[]> {
164
+ if (this.availability.type === "fail-forward") {
165
+ return this.endpoints;
166
+ }
167
+
168
+ return [await this.getAvailableEndpoint()];
169
+ }
170
+
171
+ async handleRequest(request: Request): Promise<Response> {
172
+ const startTime = Date.now();
173
+ const endpointsToTry = await this.getEndpointsToTry();
174
+ const gatherEndpointLatency = Date.now();
175
+ const url = new URL(request.url);
176
+
177
+ for (const endpoint of endpointsToTry) {
178
+ 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
+ });
186
+
187
+ const shouldFailover =
188
+ this.availability.type === "fail-forward" &&
189
+ this.availability.options?.failoverOnStatuses?.includes(
190
+ response.status,
191
+ );
192
+
193
+ if (!shouldFailover) {
194
+ const endTime = Date.now();
195
+ const headers = new Headers(response.headers);
196
+ headers.set("X-Load-Balancer-Endpoint", endpoint);
197
+ headers.set(
198
+ "X-Load-Balancer-Latency",
199
+ (endTime - startTime).toString(),
200
+ );
201
+ headers.set(
202
+ "X-Load-Balancer-Endpoint-Gather-Latency",
203
+ (gatherEndpointLatency - startTime).toString(),
204
+ );
205
+
206
+ return new Response(response.body, {
207
+ status: response.status,
208
+ statusText: response.statusText,
209
+ headers,
210
+ });
211
+ }
212
+ } catch {
213
+ // Network error, try next endpoint
214
+ continue;
215
+ }
216
+ }
217
+
218
+ if (this.recoveryFn) {
219
+ await this.recoveryFn(request);
220
+ }
221
+
222
+ throw new Error("No available endpoints");
223
+ }
224
+ }
225
+
226
+ export default LoadBalancer;