Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
124 changes: 115 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ The official JavaScript SDK for [CrowdHandler](https://www.crowdhandler.com) wai
## Features

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

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

### Module Formats
Expand Down Expand Up @@ -135,6 +135,45 @@ console.log('User granted access');
await gatekeeper.recordPerformance();
```

### Cloudflare Workers

```javascript
import { init } from 'crowdhandler-sdk';

export default {
async fetch(request, env, ctx) {
const { gatekeeper } = init({
publicKey: env.CROWDHANDLER_PUBLIC_KEY,
cloudflareWorkersRequest: request
});

const result = await gatekeeper.validateRequest();

// Workers have no mutable response object — build the outgoing
// Response yourself using values from the result.
if (!result.promoted) {
return new Response(null, {
status: 302,
headers: { Location: result.targetURL }
});
}

const originResponse = await fetch(request);
const response = new Response(originResponse.body, originResponse);

if (result.setCookie) {
response.headers.append(
'set-cookie',
`crowdhandler=${result.cookieValue}; path=/; Secure`
);
}

ctx.waitUntil(gatekeeper.recordPerformance());
return response;
}
};
```

## Core Methods

### gatekeeper.validateRequest(params?)
Expand Down Expand Up @@ -257,8 +296,10 @@ await gatekeeper.recordPerformance();

// With custom options
await gatekeeper.recordPerformance({
sample: 0.2, // Sample 20% of requests
factor: 100 // Custom timing factor
sample: 1, // Record 100% of requests (default 0.2)
statusCode: 200, // HTTP status code (default 200)
overrideElapsed: 1234, // Custom timing in ms
timeout: 1500 // Per-call API timeout in ms (default 1500)
});
```

Expand All @@ -284,10 +325,11 @@ const instance = crowdhandler.init({
privateKey: 'YOUR_PRIVATE_KEY', // Required for private API methods

// Request context (choose one based on your environment)
request: req, // Express/Node.js request
response: res, // Express/Node.js response
lambdaEdgeEvent: event, // Lambda@Edge event
// (none) // Browser environment (auto-detected)
request: req, // Express/Node.js request
response: res, // Express/Node.js response
lambdaEdgeEvent: event, // Lambda@Edge event
cloudflareWorkersRequest: request, // Cloudflare Workers Request
// (none) // Browser environment (auto-detected)

// Options
options: {
Expand Down Expand Up @@ -566,6 +608,69 @@ exports.handler = async (event) => {
};
```

### Cloudflare Workers

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.

```javascript
import { init } from 'crowdhandler-sdk';

export default {
async fetch(request, env, ctx) {
const { gatekeeper } = init({
publicKey: env.CROWDHANDLER_PUBLIC_KEY,
cloudflareWorkersRequest: request
});

const result = await gatekeeper.validateRequest();

if (result.error) {
console.error(`API Error ${result.error.statusCode}: ${result.error.message}`);
}

// Strip CrowdHandler params from a freshly promoted URL
if (result.stripParams) {
return new Response(null, {
status: 302,
headers: {
Location: decodeURIComponent(result.targetURL),
'Set-Cookie': `crowdhandler=${result.cookieValue}; path=/; Secure`
}
});
}

// Send unpromoted users to the waiting room
if (!result.promoted) {
return new Response(null, {
status: 302,
headers: { Location: result.targetURL }
});
}

// Promoted: fetch the origin and attach the session cookie if needed
const originResponse = await fetch(request);
const response = new Response(originResponse.body, originResponse);

if (result.setCookie) {
response.headers.append(
'set-cookie',
`crowdhandler=${result.cookieValue}; path=/; Secure`
);
}

// Performance recording continues after the response is returned
ctx.waitUntil(gatekeeper.recordPerformance());
return response;
}
};
```

**Workers vs. Express/Lambda — what's different:**

- 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.
- 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.
- 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.

### React / Next.js

```javascript
Expand Down Expand Up @@ -636,7 +741,8 @@ await gatekeeper.recordPerformance();
await gatekeeper.recordPerformance({
sample: 1.0, // Record 100% of requests (default 0.2)
statusCode: 200, // HTTP status code
overrideElapsed: 1234 // Custom timing in ms
overrideElapsed: 1234, // Custom timing in ms
timeout: 1500 // Per-call API timeout in ms (default 1500, overrides global SDK timeout)
});
```

Expand Down
137 changes: 131 additions & 6 deletions dist/client/base_client.js
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,11 @@ var axios_1 = __importDefault(require("axios"));
var zod_1 = require("zod");
var logger_1 = require("../common/logger");
var errors_1 = require("../common/errors");
var runtime_1 = require("../common/runtime");
// axios 0.27.2 has no fetch adapter and requires Node's http module, so it
// crashes inside Workers. When isCloudflareWorkers is true we route HTTP
// through native fetch instead — preserved error shape so errorHandler keeps
// working.
var APIResponse = zod_1.z.object({}).catchall(zod_1.z.any());
var APIErrorResponse = zod_1.z
.object({
Expand All @@ -70,8 +75,125 @@ var BaseClient = /** @class */ (function () {
this.apiUrl = options.apiUrl || apiUrl;
this.key = key;
this.timeout = options.timeout || 5000;
axios_1.default.defaults.timeout = this.timeout;
if (!runtime_1.isCloudflareWorkers) {
// axios.defaults is process-global state and is meaningless in Workers
// (we don't use axios there). Skip in Workers to avoid touching axios's
// internal config which can drag in Node-only deps during import.
axios_1.default.defaults.timeout = this.timeout;
}
}
/**
* Issue an HTTP request. Routes through axios in Node/Lambda environments
* and native fetch in Cloudflare Workers. Both paths return / throw
* axios-compatible shapes so errorHandler() and the response.data parsing
* downstream work unchanged.
*/
BaseClient.prototype.httpRequest = function (method, url, options) {
var _a;
if (options === void 0) { options = {}; }
return __awaiter(this, void 0, void 0, function () {
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;
return __generator(this, function (_e) {
switch (_e.label) {
case 0:
requestTimeout = (_a = options.timeout) !== null && _a !== void 0 ? _a : this.timeout;
if (!!runtime_1.isCloudflareWorkers) return [3 /*break*/, 2];
return [4 /*yield*/, axios_1.default.request({
method: method,
url: url,
params: options.params,
data: options.body,
headers: options.headers,
timeout: requestTimeout,
})];
case 1:
response_1 = _e.sent();
return [2 /*return*/, { data: response_1.data, status: response_1.status, headers: response_1.headers }];
case 2:
finalUrl = url;
if (options.params && Object.keys(options.params).length > 0) {
search = new URLSearchParams();
for (_i = 0, _b = Object.entries(options.params); _i < _b.length; _i++) {
_c = _b[_i], k = _c[0], v = _c[1];
if (v !== undefined && v !== null)
search.append(k, String(v));
}
finalUrl += (finalUrl.includes("?") ? "&" : "?") + search.toString();
}
init = {
method: method,
headers: options.headers,
};
if (options.body !== undefined && method !== "GET" && method !== "DELETE") {
init.body = typeof options.body === "string" ? options.body : JSON.stringify(options.body);
hasContentType = options.headers && Object.keys(options.headers)
.some(function (h) { return h.toLowerCase() === "content-type"; });
if (!hasContentType) {
init.headers = __assign(__assign({}, (options.headers || {})), { "content-type": "application/json" });
}
}
controller = new AbortController();
timeoutId = setTimeout(function () { return controller.abort(); }, requestTimeout);
init.signal = controller.signal;
_e.label = 3;
case 3:
_e.trys.push([3, 5, , 6]);
return [4 /*yield*/, fetch(finalUrl, init)];
case 4:
response = _e.sent();
return [3 /*break*/, 6];
case 5:
err_1 = _e.sent();
clearTimeout(timeoutId);
wrapped = new Error((err_1 === null || err_1 === void 0 ? void 0 : err_1.message) || "Network request failed");
if (controller.signal.aborted || (err_1 === null || err_1 === void 0 ? void 0 : err_1.name) === "AbortError") {
wrapped.code = "ECONNABORTED";
}
wrapped.request = { url: finalUrl, method: method };
wrapped.config = { url: finalUrl, method: method };
throw wrapped;
case 6:
clearTimeout(timeoutId);
contentType = response.headers.get("content-type") || "";
if (!contentType.includes("application/json")) return [3 /*break*/, 11];
_e.label = 7;
case 7:
_e.trys.push([7, 9, , 10]);
return [4 /*yield*/, response.json()];
case 8:
data = _e.sent();
return [3 /*break*/, 10];
case 9:
_d = _e.sent();
data = null;
return [3 /*break*/, 10];
case 10: return [3 /*break*/, 13];
case 11: return [4 /*yield*/, response.text()];
case 12:
text = _e.sent();
try {
data = JSON.parse(text);
}
catch (_f) {
data = text;
}
_e.label = 13;
case 13:
if (response.status < 200 || response.status >= 300) {
headersObj_1 = {};
response.headers.forEach(function (v, k) { headersObj_1[k] = v; });
wrapped = new Error("Request failed with status ".concat(response.status));
wrapped.response = { status: response.status, data: data, headers: headersObj_1 };
wrapped.config = { url: finalUrl, method: method };
throw wrapped;
}
headersObj = {};
response.headers.forEach(function (v, k) { headersObj[k] = v; });
return [2 /*return*/, { data: data, status: response.status, headers: headersObj }];
}
});
});
};
/**
* Wraps any error into a CrowdHandlerError
*/
Expand Down Expand Up @@ -168,7 +290,7 @@ var BaseClient = /** @class */ (function () {
switch (_a.label) {
case 0:
_a.trys.push([0, 2, , 4]);
return [4 /*yield*/, axios_1.default.delete(this.apiUrl + path, {
return [4 /*yield*/, this.httpRequest("DELETE", this.apiUrl + path, {
headers: {
"x-api-key": this.key,
},
Expand Down Expand Up @@ -200,7 +322,7 @@ var BaseClient = /** @class */ (function () {
switch (_a.label) {
case 0:
_a.trys.push([0, 2, , 4]);
return [4 /*yield*/, axios_1.default.get(this.apiUrl + path, {
return [4 /*yield*/, this.httpRequest("GET", this.apiUrl + path, {
params: params,
headers: {
"x-api-key": this.key,
Expand Down Expand Up @@ -234,7 +356,8 @@ var BaseClient = /** @class */ (function () {
switch (_a.label) {
case 0:
_a.trys.push([0, 2, , 4]);
return [4 /*yield*/, axios_1.default.post(this.apiUrl + path, body, {
return [4 /*yield*/, this.httpRequest("POST", this.apiUrl + path, {
body: body,
headers: __assign({ "x-api-key": this.key }, headers),
})];
case 1:
Expand All @@ -257,17 +380,19 @@ var BaseClient = /** @class */ (function () {
});
});
};
BaseClient.prototype.httpPUT = function (path, body) {
BaseClient.prototype.httpPUT = function (path, body, options) {
return __awaiter(this, void 0, void 0, function () {
var response, error_4;
return __generator(this, function (_a) {
switch (_a.label) {
case 0:
_a.trys.push([0, 2, , 3]);
return [4 /*yield*/, axios_1.default.put(this.apiUrl + path, body, {
return [4 /*yield*/, this.httpRequest("PUT", this.apiUrl + path, {
body: body,
headers: {
"x-api-key": this.key,
},
timeout: options === null || options === void 0 ? void 0 : options.timeout,
})];
case 1:
response = _a.sent();
Expand Down
4 changes: 2 additions & 2 deletions dist/client/resource.js
Original file line number Diff line number Diff line change
Expand Up @@ -80,9 +80,9 @@ var Resource = /** @class */ (function (_super) {
);
return _super.prototype.httpPOST.call(this, this.path, requestBody);
};
Resource.prototype.put = function (id, body) {
Resource.prototype.put = function (id, body, options) {
this.path = this.formatPath(this.path, id);
return _super.prototype.httpPUT.call(this, this.path, body);
return _super.prototype.httpPUT.call(this, this.path, body, options);
};
return Resource;
}(base_client_1.BaseClient));
Expand Down
Loading