Skip to content

Commit 07c75da

Browse files
Merge pull request #15 from Crowdhandler/maintenance/workers-compat
Maintenance/workers compat
2 parents 892442a + bc20f4b commit 07c75da

37 files changed

Lines changed: 1824 additions & 405 deletions

README.md

Lines changed: 115 additions & 9 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?)
@@ -257,8 +296,10 @@ await gatekeeper.recordPerformance();
257296
258297
// With custom options
259298
await gatekeeper.recordPerformance({
260-
sample: 0.2, // Sample 20% of requests
261-
factor: 100 // Custom timing factor
299+
sample: 1, // Record 100% of requests (default 0.2)
300+
statusCode: 200, // HTTP status code (default 200)
301+
overrideElapsed: 1234, // Custom timing in ms
302+
timeout: 1500 // Per-call API timeout in ms (default 1500)
262303
});
263304
```
264305
@@ -284,10 +325,11 @@ const instance = crowdhandler.init({
284325
privateKey: 'YOUR_PRIVATE_KEY', // Required for private API methods
285326

286327
// 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)
328+
request: req, // Express/Node.js request
329+
response: res, // Express/Node.js response
330+
lambdaEdgeEvent: event, // Lambda@Edge event
331+
cloudflareWorkersRequest: request, // Cloudflare Workers Request
332+
// (none) // Browser environment (auto-detected)
291333

292334
// Options
293335
options: {
@@ -566,6 +608,69 @@ exports.handler = async (event) => {
566608
};
567609
```
568610
611+
### Cloudflare Workers
612+
613+
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.
614+
615+
```javascript
616+
import { init } from 'crowdhandler-sdk';
617+
618+
export default {
619+
async fetch(request, env, ctx) {
620+
const { gatekeeper } = init({
621+
publicKey: env.CROWDHANDLER_PUBLIC_KEY,
622+
cloudflareWorkersRequest: request
623+
});
624+
625+
const result = await gatekeeper.validateRequest();
626+
627+
if (result.error) {
628+
console.error(`API Error ${result.error.statusCode}: ${result.error.message}`);
629+
}
630+
631+
// Strip CrowdHandler params from a freshly promoted URL
632+
if (result.stripParams) {
633+
return new Response(null, {
634+
status: 302,
635+
headers: {
636+
Location: decodeURIComponent(result.targetURL),
637+
'Set-Cookie': `crowdhandler=${result.cookieValue}; path=/; Secure`
638+
}
639+
});
640+
}
641+
642+
// Send unpromoted users to the waiting room
643+
if (!result.promoted) {
644+
return new Response(null, {
645+
status: 302,
646+
headers: { Location: result.targetURL }
647+
});
648+
}
649+
650+
// Promoted: fetch the origin and attach the session cookie if needed
651+
const originResponse = await fetch(request);
652+
const response = new Response(originResponse.body, originResponse);
653+
654+
if (result.setCookie) {
655+
response.headers.append(
656+
'set-cookie',
657+
`crowdhandler=${result.cookieValue}; path=/; Secure`
658+
);
659+
}
660+
661+
// Performance recording continues after the response is returned
662+
ctx.waitUntil(gatekeeper.recordPerformance());
663+
return response;
664+
}
665+
};
666+
```
667+
668+
**Workers vs. Express/Lambda — what's different:**
669+
670+
- 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.
671+
- Use `ctx.waitUntil()` for `recordPerformance()` so the metric call doesn't delay the user's response. On Workers the SDK awaits the underlying API call internally (so it actually flushes inside `ctx.waitUntil`); the put is capped at 1500ms by default — pass `{ timeout: <ms> }` to tune.
672+
- 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.
673+
569674
### React / Next.js
570675
571676
```javascript
@@ -636,7 +741,8 @@ await gatekeeper.recordPerformance();
636741
await gatekeeper.recordPerformance({
637742
sample: 1.0, // Record 100% of requests (default 0.2)
638743
statusCode: 200, // HTTP status code
639-
overrideElapsed: 1234 // Custom timing in ms
744+
overrideElapsed: 1234, // Custom timing in ms
745+
timeout: 1500 // Per-call API timeout in ms (default 1500, overrides global SDK timeout)
640746
});
641747
```
642748

dist/client/base_client.js

Lines changed: 131 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,11 @@ var axios_1 = __importDefault(require("axios"));
5555
var zod_1 = require("zod");
5656
var logger_1 = require("../common/logger");
5757
var errors_1 = require("../common/errors");
58+
var runtime_1 = require("../common/runtime");
59+
// axios 0.27.2 has no fetch adapter and requires Node's http module, so it
60+
// crashes inside Workers. When isCloudflareWorkers is true we route HTTP
61+
// through native fetch instead — preserved error shape so errorHandler keeps
62+
// working.
5863
var APIResponse = zod_1.z.object({}).catchall(zod_1.z.any());
5964
var APIErrorResponse = zod_1.z
6065
.object({
@@ -70,8 +75,125 @@ var BaseClient = /** @class */ (function () {
7075
this.apiUrl = options.apiUrl || apiUrl;
7176
this.key = key;
7277
this.timeout = options.timeout || 5000;
73-
axios_1.default.defaults.timeout = this.timeout;
78+
if (!runtime_1.isCloudflareWorkers) {
79+
// axios.defaults is process-global state and is meaningless in Workers
80+
// (we don't use axios there). Skip in Workers to avoid touching axios's
81+
// internal config which can drag in Node-only deps during import.
82+
axios_1.default.defaults.timeout = this.timeout;
83+
}
7484
}
85+
/**
86+
* Issue an HTTP request. Routes through axios in Node/Lambda environments
87+
* and native fetch in Cloudflare Workers. Both paths return / throw
88+
* axios-compatible shapes so errorHandler() and the response.data parsing
89+
* downstream work unchanged.
90+
*/
91+
BaseClient.prototype.httpRequest = function (method, url, options) {
92+
var _a;
93+
if (options === void 0) { options = {}; }
94+
return __awaiter(this, void 0, void 0, function () {
95+
var requestTimeout, response_1, finalUrl, search, _i, _b, _c, k, v, init, hasContentType, controller, timeoutId, response, err_1, wrapped, contentType, data, _d, text, headersObj_1, wrapped, headersObj;
96+
return __generator(this, function (_e) {
97+
switch (_e.label) {
98+
case 0:
99+
requestTimeout = (_a = options.timeout) !== null && _a !== void 0 ? _a : this.timeout;
100+
if (!!runtime_1.isCloudflareWorkers) return [3 /*break*/, 2];
101+
return [4 /*yield*/, axios_1.default.request({
102+
method: method,
103+
url: url,
104+
params: options.params,
105+
data: options.body,
106+
headers: options.headers,
107+
timeout: requestTimeout,
108+
})];
109+
case 1:
110+
response_1 = _e.sent();
111+
return [2 /*return*/, { data: response_1.data, status: response_1.status, headers: response_1.headers }];
112+
case 2:
113+
finalUrl = url;
114+
if (options.params && Object.keys(options.params).length > 0) {
115+
search = new URLSearchParams();
116+
for (_i = 0, _b = Object.entries(options.params); _i < _b.length; _i++) {
117+
_c = _b[_i], k = _c[0], v = _c[1];
118+
if (v !== undefined && v !== null)
119+
search.append(k, String(v));
120+
}
121+
finalUrl += (finalUrl.includes("?") ? "&" : "?") + search.toString();
122+
}
123+
init = {
124+
method: method,
125+
headers: options.headers,
126+
};
127+
if (options.body !== undefined && method !== "GET" && method !== "DELETE") {
128+
init.body = typeof options.body === "string" ? options.body : JSON.stringify(options.body);
129+
hasContentType = options.headers && Object.keys(options.headers)
130+
.some(function (h) { return h.toLowerCase() === "content-type"; });
131+
if (!hasContentType) {
132+
init.headers = __assign(__assign({}, (options.headers || {})), { "content-type": "application/json" });
133+
}
134+
}
135+
controller = new AbortController();
136+
timeoutId = setTimeout(function () { return controller.abort(); }, requestTimeout);
137+
init.signal = controller.signal;
138+
_e.label = 3;
139+
case 3:
140+
_e.trys.push([3, 5, , 6]);
141+
return [4 /*yield*/, fetch(finalUrl, init)];
142+
case 4:
143+
response = _e.sent();
144+
return [3 /*break*/, 6];
145+
case 5:
146+
err_1 = _e.sent();
147+
clearTimeout(timeoutId);
148+
wrapped = new Error((err_1 === null || err_1 === void 0 ? void 0 : err_1.message) || "Network request failed");
149+
if (controller.signal.aborted || (err_1 === null || err_1 === void 0 ? void 0 : err_1.name) === "AbortError") {
150+
wrapped.code = "ECONNABORTED";
151+
}
152+
wrapped.request = { url: finalUrl, method: method };
153+
wrapped.config = { url: finalUrl, method: method };
154+
throw wrapped;
155+
case 6:
156+
clearTimeout(timeoutId);
157+
contentType = response.headers.get("content-type") || "";
158+
if (!contentType.includes("application/json")) return [3 /*break*/, 11];
159+
_e.label = 7;
160+
case 7:
161+
_e.trys.push([7, 9, , 10]);
162+
return [4 /*yield*/, response.json()];
163+
case 8:
164+
data = _e.sent();
165+
return [3 /*break*/, 10];
166+
case 9:
167+
_d = _e.sent();
168+
data = null;
169+
return [3 /*break*/, 10];
170+
case 10: return [3 /*break*/, 13];
171+
case 11: return [4 /*yield*/, response.text()];
172+
case 12:
173+
text = _e.sent();
174+
try {
175+
data = JSON.parse(text);
176+
}
177+
catch (_f) {
178+
data = text;
179+
}
180+
_e.label = 13;
181+
case 13:
182+
if (response.status < 200 || response.status >= 300) {
183+
headersObj_1 = {};
184+
response.headers.forEach(function (v, k) { headersObj_1[k] = v; });
185+
wrapped = new Error("Request failed with status ".concat(response.status));
186+
wrapped.response = { status: response.status, data: data, headers: headersObj_1 };
187+
wrapped.config = { url: finalUrl, method: method };
188+
throw wrapped;
189+
}
190+
headersObj = {};
191+
response.headers.forEach(function (v, k) { headersObj[k] = v; });
192+
return [2 /*return*/, { data: data, status: response.status, headers: headersObj }];
193+
}
194+
});
195+
});
196+
};
75197
/**
76198
* Wraps any error into a CrowdHandlerError
77199
*/
@@ -168,7 +290,7 @@ var BaseClient = /** @class */ (function () {
168290
switch (_a.label) {
169291
case 0:
170292
_a.trys.push([0, 2, , 4]);
171-
return [4 /*yield*/, axios_1.default.delete(this.apiUrl + path, {
293+
return [4 /*yield*/, this.httpRequest("DELETE", this.apiUrl + path, {
172294
headers: {
173295
"x-api-key": this.key,
174296
},
@@ -200,7 +322,7 @@ var BaseClient = /** @class */ (function () {
200322
switch (_a.label) {
201323
case 0:
202324
_a.trys.push([0, 2, , 4]);
203-
return [4 /*yield*/, axios_1.default.get(this.apiUrl + path, {
325+
return [4 /*yield*/, this.httpRequest("GET", this.apiUrl + path, {
204326
params: params,
205327
headers: {
206328
"x-api-key": this.key,
@@ -234,7 +356,8 @@ var BaseClient = /** @class */ (function () {
234356
switch (_a.label) {
235357
case 0:
236358
_a.trys.push([0, 2, , 4]);
237-
return [4 /*yield*/, axios_1.default.post(this.apiUrl + path, body, {
359+
return [4 /*yield*/, this.httpRequest("POST", this.apiUrl + path, {
360+
body: body,
238361
headers: __assign({ "x-api-key": this.key }, headers),
239362
})];
240363
case 1:
@@ -257,17 +380,19 @@ var BaseClient = /** @class */ (function () {
257380
});
258381
});
259382
};
260-
BaseClient.prototype.httpPUT = function (path, body) {
383+
BaseClient.prototype.httpPUT = function (path, body, options) {
261384
return __awaiter(this, void 0, void 0, function () {
262385
var response, error_4;
263386
return __generator(this, function (_a) {
264387
switch (_a.label) {
265388
case 0:
266389
_a.trys.push([0, 2, , 3]);
267-
return [4 /*yield*/, axios_1.default.put(this.apiUrl + path, body, {
390+
return [4 /*yield*/, this.httpRequest("PUT", this.apiUrl + path, {
391+
body: body,
268392
headers: {
269393
"x-api-key": this.key,
270394
},
395+
timeout: options === null || options === void 0 ? void 0 : options.timeout,
271396
})];
272397
case 1:
273398
response = _a.sent();

dist/client/resource.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -80,9 +80,9 @@ var Resource = /** @class */ (function (_super) {
8080
);
8181
return _super.prototype.httpPOST.call(this, this.path, requestBody);
8282
};
83-
Resource.prototype.put = function (id, body) {
83+
Resource.prototype.put = function (id, body, options) {
8484
this.path = this.formatPath(this.path, id);
85-
return _super.prototype.httpPUT.call(this, this.path, body);
85+
return _super.prototype.httpPUT.call(this, this.path, body, options);
8686
};
8787
return Resource;
8888
}(base_client_1.BaseClient));

0 commit comments

Comments
 (0)