Skip to content

Commit 04405e6

Browse files
Add Cloudflare Workers compatibility (2.4.0)
- New CloudflareWorkersHandler in the request layer mirroring the Lambda/Node/Browser pattern, with cookie/header/redirect helpers aligned with crowdhandler-cloudflare-integration. - RequestContext + init() accept cloudflareWorkersRequest, with a matching function overload for type inference. - BaseClient detects workerd via navigator.userAgent and routes HTTP through native fetch + AbortController, preserving the axios error shape so existing handlers keep working. Node/Lambda continue to use axios unchanged. - package.json gains an exports map with workerd/worker conditions pointing Workers bundlers at the ESM build. - README documents Workers usage end-to-end.
1 parent 629293d commit 04405e6

6 files changed

Lines changed: 349 additions & 19 deletions

File tree

README.md

Lines changed: 109 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ The official JavaScript SDK for [CrowdHandler](https://www.crowdhandler.com) wai
88
## Features
99

1010
- 🚀 **Easy Integration** - Add queue management to any JavaScript application with a single function call
11-
- 🌐 **Flexible Deployment** - Works in Node.js servers, browsers, serverless functions, and CDN edge locations
11+
- 🌐 **Flexible Deployment** - Works in Node.js servers, browsers, Lambda@Edge, Cloudflare Workers, and other edge runtimes
1212
-**Performance Options** - Choose between real-time API validation or local signature validation based on your needs
1313
- 🔄 **Queue Continuity** - Maintains user position across page refreshes and sessions
1414
- 📘 **TypeScript Support** - Full type definitions for better development experience
@@ -29,7 +29,7 @@ npm install crowdhandler-sdk
2929
<script src="https://unpkg.com/crowdhandler-sdk/dist/crowdhandler.umd.min.js"></script>
3030

3131
<!-- Or specify a version -->
32-
<script src="https://unpkg.com/crowdhandler-sdk@2.0.0/dist/crowdhandler.umd.min.js"></script>
32+
<script src="https://unpkg.com/crowdhandler-sdk@2.4.0/dist/crowdhandler.umd.min.js"></script>
3333
```
3434

3535
### Module Formats
@@ -135,6 +135,45 @@ console.log('User granted access');
135135
await gatekeeper.recordPerformance();
136136
```
137137

138+
### Cloudflare Workers
139+
140+
```javascript
141+
import { init } from 'crowdhandler-sdk';
142+
143+
export default {
144+
async fetch(request, env, ctx) {
145+
const { gatekeeper } = init({
146+
publicKey: env.CROWDHANDLER_PUBLIC_KEY,
147+
cloudflareWorkersRequest: request
148+
});
149+
150+
const result = await gatekeeper.validateRequest();
151+
152+
// Workers have no mutable response object — build the outgoing
153+
// Response yourself using values from the result.
154+
if (!result.promoted) {
155+
return new Response(null, {
156+
status: 302,
157+
headers: { Location: result.targetURL }
158+
});
159+
}
160+
161+
const originResponse = await fetch(request);
162+
const response = new Response(originResponse.body, originResponse);
163+
164+
if (result.setCookie) {
165+
response.headers.append(
166+
'set-cookie',
167+
`crowdhandler=${result.cookieValue}; path=/; Secure`
168+
);
169+
}
170+
171+
ctx.waitUntil(gatekeeper.recordPerformance());
172+
return response;
173+
}
174+
};
175+
```
176+
138177
## Core Methods
139178

140179
### gatekeeper.validateRequest(params?)
@@ -284,10 +323,11 @@ const instance = crowdhandler.init({
284323
privateKey: 'YOUR_PRIVATE_KEY', // Required for private API methods
285324

286325
// Request context (choose one based on your environment)
287-
request: req, // Express/Node.js request
288-
response: res, // Express/Node.js response
289-
lambdaEdgeEvent: event, // Lambda@Edge event
290-
// (none) // Browser environment (auto-detected)
326+
request: req, // Express/Node.js request
327+
response: res, // Express/Node.js response
328+
lambdaEdgeEvent: event, // Lambda@Edge event
329+
cloudflareWorkersRequest: request, // Cloudflare Workers Request
330+
// (none) // Browser environment (auto-detected)
291331

292332
// Options
293333
options: {
@@ -566,6 +606,69 @@ exports.handler = async (event) => {
566606
};
567607
```
568608
609+
### Cloudflare Workers
610+
611+
The SDK ships with native support for the Cloudflare Workers (workerd) runtime — no Node polyfills required. Pass the Workers `Request` object via `cloudflareWorkersRequest` and the SDK uses native `fetch` internally for all API calls.
612+
613+
```javascript
614+
import { init } from 'crowdhandler-sdk';
615+
616+
export default {
617+
async fetch(request, env, ctx) {
618+
const { gatekeeper } = init({
619+
publicKey: env.CROWDHANDLER_PUBLIC_KEY,
620+
cloudflareWorkersRequest: request
621+
});
622+
623+
const result = await gatekeeper.validateRequest();
624+
625+
if (result.error) {
626+
console.error(`API Error ${result.error.statusCode}: ${result.error.message}`);
627+
}
628+
629+
// Strip CrowdHandler params from a freshly promoted URL
630+
if (result.stripParams) {
631+
return new Response(null, {
632+
status: 302,
633+
headers: {
634+
Location: decodeURIComponent(result.targetURL),
635+
'Set-Cookie': `crowdhandler=${result.cookieValue}; path=/; Secure`
636+
}
637+
});
638+
}
639+
640+
// Send unpromoted users to the waiting room
641+
if (!result.promoted) {
642+
return new Response(null, {
643+
status: 302,
644+
headers: { Location: result.targetURL }
645+
});
646+
}
647+
648+
// Promoted: fetch the origin and attach the session cookie if needed
649+
const originResponse = await fetch(request);
650+
const response = new Response(originResponse.body, originResponse);
651+
652+
if (result.setCookie) {
653+
response.headers.append(
654+
'set-cookie',
655+
`crowdhandler=${result.cookieValue}; path=/; Secure`
656+
);
657+
}
658+
659+
// Performance recording continues after the response is returned
660+
ctx.waitUntil(gatekeeper.recordPerformance());
661+
return response;
662+
}
663+
};
664+
```
665+
666+
**Workers vs. Express/Lambda — what's different:**
667+
668+
- Workers have no mutable response object. Build the outgoing `Response` yourself using values from `result` (`cookieValue`, `targetURL`, `setCookie`) rather than relying on helper methods that mutate a response in place.
669+
- Use `ctx.waitUntil()` for `recordPerformance()` so the metric call doesn't delay the user's response.
670+
- Default `mode: 'full'` (used above) only needs the public key. Hybrid mode is supported but requires shipping your private key as a Worker secret — only do this if you've assessed the trade-off.
671+
569672
### React / Next.js
570673
571674
```javascript

package.json

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "crowdhandler-sdk",
3-
"version": "2.3.2",
3+
"version": "2.4.0",
44
"description": "",
55
"homepage": "https://www.crowdhandler.com",
66
"repository": {
@@ -14,6 +14,19 @@
1414
"module": "dist/crowdhandler.esm.js",
1515
"browser": "dist/crowdhandler.umd.js",
1616
"types": "dist/types/index.d.ts",
17+
"exports": {
18+
".": {
19+
"types": "./dist/types/index.d.ts",
20+
"workerd": "./dist/crowdhandler.esm.js",
21+
"worker": "./dist/crowdhandler.esm.js",
22+
"browser": "./dist/crowdhandler.umd.js",
23+
"import": "./dist/crowdhandler.esm.js",
24+
"require": "./dist/crowdhandler.cjs.js",
25+
"default": "./dist/crowdhandler.esm.js"
26+
},
27+
"./package.json": "./package.json",
28+
"./dist/*": "./dist/*"
29+
},
1730
"scripts": {
1831
"build": "npm run build:clean && npm run build:rollup && npm run build:legacy && npm run check:circular",
1932
"build:clean": "rm -rf dist",

src/client/base_client.ts

Lines changed: 124 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,20 @@ import { z, ZodError } from "zod";
33
import { logger } from "../common/logger";
44
import { CrowdHandlerError, createError, ErrorCodes } from "../common/errors";
55

6+
/**
7+
* Detect if we're running in the Cloudflare Workers (workerd) runtime.
8+
* Workers sets navigator.userAgent to "Cloudflare-Workers" — this is the
9+
* documented and stable detection signal:
10+
* https://developers.cloudflare.com/workers/runtime-apis/web-standards/
11+
*
12+
* axios 0.27.2 has no fetch adapter and requires Node's http module, so it
13+
* crashes inside Workers. When we detect Workers, route HTTP through native
14+
* fetch instead — preserved error shape so errorHandler keeps working.
15+
*/
16+
const isCloudflareWorkers =
17+
typeof navigator !== "undefined" &&
18+
(navigator as any).userAgent === "Cloudflare-Workers";
19+
620
const APIResponse = z.object({}).catchall(z.any());
721

822
const APIErrorResponse = z
@@ -28,7 +42,107 @@ export class BaseClient {
2842
this.apiUrl = options.apiUrl || apiUrl;
2943
this.key = key;
3044
this.timeout = options.timeout || 5000;
31-
axios.defaults.timeout = this.timeout;
45+
if (!isCloudflareWorkers) {
46+
// axios.defaults is process-global state and is meaningless in Workers
47+
// (we don't use axios there). Skip in Workers to avoid touching axios's
48+
// internal config which can drag in Node-only deps during import.
49+
axios.defaults.timeout = this.timeout;
50+
}
51+
}
52+
53+
/**
54+
* Issue an HTTP request. Routes through axios in Node/Lambda environments
55+
* and native fetch in Cloudflare Workers. Both paths return / throw
56+
* axios-compatible shapes so errorHandler() and the response.data parsing
57+
* downstream work unchanged.
58+
*/
59+
private async httpRequest(
60+
method: "GET" | "POST" | "PUT" | "DELETE",
61+
url: string,
62+
options: {
63+
params?: Record<string, any>;
64+
body?: any;
65+
headers?: Record<string, string>;
66+
} = {}
67+
): Promise<{ data: any; status: number; headers: any }> {
68+
if (!isCloudflareWorkers) {
69+
// Node/Lambda path — preserve existing axios behaviour exactly.
70+
const response = await axios.request({
71+
method,
72+
url,
73+
params: options.params,
74+
data: options.body,
75+
headers: options.headers,
76+
});
77+
return { data: response.data, status: response.status, headers: response.headers };
78+
}
79+
80+
// Workers path — native fetch with a manual timeout via AbortController.
81+
let finalUrl = url;
82+
if (options.params && Object.keys(options.params).length > 0) {
83+
const search = new URLSearchParams();
84+
for (const [k, v] of Object.entries(options.params)) {
85+
if (v !== undefined && v !== null) search.append(k, String(v));
86+
}
87+
finalUrl += (finalUrl.includes("?") ? "&" : "?") + search.toString();
88+
}
89+
90+
const init: RequestInit = {
91+
method,
92+
headers: options.headers as any,
93+
};
94+
95+
if (options.body !== undefined && method !== "GET" && method !== "DELETE") {
96+
init.body = typeof options.body === "string" ? options.body : JSON.stringify(options.body);
97+
const hasContentType = options.headers && Object.keys(options.headers)
98+
.some((h) => h.toLowerCase() === "content-type");
99+
if (!hasContentType) {
100+
init.headers = { ...(options.headers || {}), "content-type": "application/json" };
101+
}
102+
}
103+
104+
const controller = new AbortController();
105+
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
106+
init.signal = controller.signal;
107+
108+
let response: Response;
109+
try {
110+
response = await fetch(finalUrl, init);
111+
} catch (err: any) {
112+
clearTimeout(timeoutId);
113+
// Mirror axios's "no response received" error shape so errorHandler's
114+
// `else if (error.request)` branch fires.
115+
const wrapped: any = new Error(err?.message || "Network request failed");
116+
wrapped.request = { url: finalUrl, method };
117+
wrapped.config = { url: finalUrl, method };
118+
throw wrapped;
119+
}
120+
clearTimeout(timeoutId);
121+
122+
// Read body — try JSON first, fall back to text.
123+
const contentType = response.headers.get("content-type") || "";
124+
let data: any;
125+
if (contentType.includes("application/json")) {
126+
try { data = await response.json(); } catch { data = null; }
127+
} else {
128+
const text = await response.text();
129+
try { data = JSON.parse(text); } catch { data = text; }
130+
}
131+
132+
if (response.status < 200 || response.status >= 300) {
133+
// Mirror axios's error.response shape so errorHandler's
134+
// `if (error.response)` branch fires unchanged.
135+
const headersObj: Record<string, string> = {};
136+
response.headers.forEach((v, k) => { headersObj[k] = v; });
137+
const wrapped: any = new Error(`Request failed with status ${response.status}`);
138+
wrapped.response = { status: response.status, data, headers: headersObj };
139+
wrapped.config = { url: finalUrl, method };
140+
throw wrapped;
141+
}
142+
143+
const headersObj: Record<string, string> = {};
144+
response.headers.forEach((v, k) => { headersObj[k] = v; });
145+
return { data, status: response.status, headers: headersObj };
32146
}
33147

34148
/**
@@ -152,7 +266,7 @@ export class BaseClient {
152266

153267
async httpDELETE(path: string, body: object) {
154268
try {
155-
const response = await axios.delete(this.apiUrl + path, {
269+
const response = await this.httpRequest("DELETE", this.apiUrl + path, {
156270
headers: {
157271
"x-api-key": this.key,
158272
},
@@ -170,13 +284,13 @@ export class BaseClient {
170284

171285
async httpGET(path?: string, params?: object) {
172286
try {
173-
const response = await axios.get(this.apiUrl + path, {
174-
params: params,
287+
const response = await this.httpRequest("GET", this.apiUrl + path, {
288+
params: params as Record<string, any>,
175289
headers: {
176290
"x-api-key": this.key,
177291
},
178292
});
179-
293+
180294
try {
181295
return APIResponse.parse(response.data);
182296
} catch (parseError: any) {
@@ -194,13 +308,14 @@ export class BaseClient {
194308
schema: z.Schema = APIResponse
195309
) {
196310
try {
197-
const response = await axios.post(this.apiUrl + path, body, {
311+
const response = await this.httpRequest("POST", this.apiUrl + path, {
312+
body,
198313
headers: {
199314
"x-api-key": this.key,
200315
...headers,
201316
},
202317
});
203-
318+
204319
try {
205320
return schema.parse(response.data);
206321
} catch (parseError: any) {
@@ -213,7 +328,8 @@ export class BaseClient {
213328

214329
async httpPUT(path: string, body: object) {
215330
try {
216-
const response = await axios.put(this.apiUrl + path, body, {
331+
const response = await this.httpRequest("PUT", this.apiUrl + path, {
332+
body,
217333
headers: {
218334
"x-api-key": this.key,
219335
},

0 commit comments

Comments
 (0)