@crawlora-org/sdk 1.4.0-sdk.1 → 1.5.0-sdk.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,14 @@
1
1
  # Changelog
2
2
 
3
+ ## v1.5.0-sdk.1
4
+
5
+ - Added `beforeRequest`/`afterResponse` middleware hooks (mutate the outgoing
6
+ request context, transform the parsed response).
7
+ - Added opt-in `idempotencyKeys` (stable `Idempotency-Key` on POST/PATCH, reused
8
+ across retries) and per-request `retries`/`isRetryable` overrides.
9
+ - Added client-side `rateLimit` (requests/sec) and `maxConcurrency` throttling.
10
+ - Errors now carry `requestId`.
11
+
3
12
  ## v1.4.0-sdk.1
4
13
 
5
14
  - Configurable retries: `maxRetryDelay`, `retryStatuses`, and an `isRetryable`
package/docs/recipes.md CHANGED
@@ -119,6 +119,32 @@ for await (const chunk of res.body) { /* ... */ }
119
119
  `CRAWLORA_API_KEY` and `CRAWLORA_BASE_URL` are used when not set explicitly
120
120
  (precedence: option > env > default).
121
121
 
122
+ ## Middleware
123
+
124
+ ```js
125
+ const crawlora = new CrawloraClient({
126
+ beforeRequest: (ctx) => { ctx.headers["x-signature"] = sign(ctx); },
127
+ afterResponse: (operationId, status, headers, body) => body // return a value to transform
128
+ });
129
+ ```
130
+
131
+ ## Idempotency And Per-Request Retries
132
+
133
+ ```js
134
+ const crawlora = new CrawloraClient({ idempotencyKeys: true }); // stable key on POST/PATCH retries
135
+
136
+ await crawlora.request("bing-search", { q: "coffee" }, {
137
+ retries: 5,
138
+ isRetryable: (status) => status >= 500
139
+ });
140
+ ```
141
+
142
+ ## Rate Limiting
143
+
144
+ ```js
145
+ const crawlora = new CrawloraClient({ rateLimit: 10, maxConcurrency: 4 });
146
+ ```
147
+
122
148
  ## Optional Live Smoke Tests
123
149
 
124
150
  ```sh
@@ -0,0 +1,22 @@
1
+ import { CrawloraClient } from "../src/index.js";
2
+
3
+ const client = newClient();
4
+ if (client) {
5
+ const seller = process.env.CRAWLORA_EBAY_SELLER || "garlandcomputer";
6
+ let count = 0;
7
+ for await (const item of client.paginateItems("ebay-seller-feedback", { seller }, { maxPages: 3 })) {
8
+ count += 1;
9
+ }
10
+ console.log(`collected ${count} feedback items across up to 3 pages`);
11
+ }
12
+
13
+ function newClient() {
14
+ if (!process.env.CRAWLORA_API_KEY) {
15
+ console.error("set CRAWLORA_API_KEY to run this live example");
16
+ return null;
17
+ }
18
+ return new CrawloraClient({
19
+ apiKey: process.env.CRAWLORA_API_KEY,
20
+ baseUrl: process.env.CRAWLORA_BASE_URL
21
+ });
22
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crawlora-org/sdk",
3
- "version": "1.4.0-sdk.1",
3
+ "version": "1.5.0-sdk.3",
4
4
  "description": "JavaScript and TypeScript SDK for the public Crawlora API.",
5
5
  "keywords": [
6
6
  "crawlora",
package/src/client.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { operations, groups } from "./operations.js";
2
2
 
3
3
  const DEFAULT_BASE_URL = "https://api.crawlora.net/api/v1";
4
- export const VERSION = "1.4.0-sdk.1";
4
+ export const VERSION = "1.5.0-sdk.3";
5
5
  const DEFAULT_USER_AGENT = `crawlora-js-sdk/${VERSION}`;
6
6
 
7
7
  export class CrawloraError extends Error {
@@ -48,6 +48,16 @@ function apiErrorClass(status) {
48
48
  return status >= 500 ? CrawloraServerError : CrawloraClientError;
49
49
  }
50
50
 
51
+ /**
52
+ * Client for the Crawlora API.
53
+ *
54
+ * Call operations via grouped helpers (`client.bing.search({ q })`) or
55
+ * dynamically (`client.request("bing-search", { q })`). Supports configurable
56
+ * retries, an `onRetry` hook, opt-in `requestId` and `idempotencyKeys`,
57
+ * `beforeRequest`/`afterResponse` middleware, client-side `rateLimit` /
58
+ * `maxConcurrency`, pagination (`paginate`/`paginateItems`), and
59
+ * `responseType: "stream"`.
60
+ */
51
61
  export class CrawloraClient {
52
62
  constructor(options = {}) {
53
63
  // Precedence: explicit option > environment variable > default.
@@ -62,7 +72,13 @@ export class CrawloraClient {
62
72
  this.isRetryable = typeof options.isRetryable === "function" ? options.isRetryable : null;
63
73
  this.onRetry = typeof options.onRetry === "function" ? options.onRetry : null;
64
74
  this.requestId = options.requestId === true;
75
+ this.idempotencyKeys = options.idempotencyKeys === true;
65
76
  this.logger = typeof options.logger === "function" ? options.logger : null;
77
+ this.beforeRequest = asHookList(options.beforeRequest);
78
+ this.afterResponse = asHookList(options.afterResponse);
79
+ this.limiter = (options.rateLimit > 0 || options.maxConcurrency > 0)
80
+ ? new RateLimiter(options.rateLimit || 0, options.maxConcurrency || 0)
81
+ : null;
66
82
  this.headers = { ...(options.headers || {}) };
67
83
  this.userAgent = options.userAgent === false ? "" : (options.userAgent || DEFAULT_USER_AGENT);
68
84
  this.fetch = options.fetch || globalThis.fetch;
@@ -93,13 +109,19 @@ export class CrawloraClient {
93
109
  options = options ?? {};
94
110
  const responseType = validateResponseType(options.responseType || "auto");
95
111
  this.#log({ event: "request", operation: operationId });
112
+ const maxRetries = options.retries ?? this.retries;
113
+ const isRetryable = typeof options.isRetryable === "function" ? options.isRetryable : null;
114
+ const idempotencyKey = (this.idempotencyKeys && (operation.method === "POST" || operation.method === "PATCH"))
115
+ ? generateId() : undefined;
96
116
 
97
117
  let attempt = 0;
98
118
  for (;;) {
99
119
  try {
100
- return await this.#send(operation, params, options, responseType);
120
+ const run = () => this.#send(operation, params, options, responseType, idempotencyKey);
121
+ return await (this.limiter ? this.limiter.run(run, options.signal) : run());
101
122
  } catch (error) {
102
- if (!(error instanceof CrawloraError) || error.retryable === false || attempt >= this.retries || !this.#isRetryable(error.status, error)) {
123
+ const retryable = isRetryable ? !!isRetryable(error.status, error) : this.#isRetryable(error.status, error);
124
+ if (!(error instanceof CrawloraError) || error.retryable === false || attempt >= maxRetries || !retryable) {
103
125
  throw error;
104
126
  }
105
127
  attempt++;
@@ -126,7 +148,7 @@ export class CrawloraClient {
126
148
  if (this.logger) this.logger(event);
127
149
  }
128
150
 
129
- async #send(operation, params, options, responseType) {
151
+ async #send(operation, params, options, responseType, idempotencyKey) {
130
152
  const { url, body, bodyHeaders } = buildRequest(operation, this.baseUrl, params);
131
153
  const headers = mergeHeaders(
132
154
  this.headers,
@@ -136,6 +158,18 @@ export class CrawloraClient {
136
158
  options.headers
137
159
  );
138
160
  const requestId = this.requestId ? ensureRequestId(headers) : (headerValue(headers, "x-request-id") || undefined);
161
+ if (idempotencyKey && !headerValue(headers, "idempotency-key")) {
162
+ headers["Idempotency-Key"] = idempotencyKey;
163
+ }
164
+
165
+ let requestUrl = url;
166
+ let requestHeaders = headers;
167
+ if (this.beforeRequest.length) {
168
+ const ctx = { operationId: operation.id, method: operation.method, url: requestUrl, headers: requestHeaders };
169
+ for (const hook of this.beforeRequest) await hook(ctx);
170
+ requestUrl = ctx.url;
171
+ requestHeaders = ctx.headers;
172
+ }
139
173
 
140
174
  const controller = new AbortController();
141
175
  const timeoutMs = options.timeout ?? this.timeout;
@@ -147,9 +181,9 @@ export class CrawloraClient {
147
181
  const signal = composeSignal(controller, options.signal);
148
182
  let response;
149
183
  try {
150
- response = await this.fetch(url, {
184
+ response = await this.fetch(requestUrl, {
151
185
  method: operation.method,
152
- headers,
186
+ headers: requestHeaders,
153
187
  body,
154
188
  signal
155
189
  });
@@ -170,7 +204,7 @@ export class CrawloraClient {
170
204
  if (responseType === "stream" && response.ok) {
171
205
  return response;
172
206
  }
173
- const parsed = await parseResponse(response, responseType === "stream" ? "auto" : responseType, headersObject, requestId);
207
+ let parsed = await parseResponse(response, responseType === "stream" ? "auto" : responseType, headersObject, requestId);
174
208
  if (!response.ok) {
175
209
  const code = parsed && typeof parsed === "object" ? parsed.code : undefined;
176
210
  const message = parsed && typeof parsed === "object" && parsed.msg ? parsed.msg : response.statusText;
@@ -184,6 +218,12 @@ export class CrawloraClient {
184
218
  requestId
185
219
  });
186
220
  }
221
+ if (this.afterResponse.length) {
222
+ for (const hook of this.afterResponse) {
223
+ const result = await hook(operation.id, response.status, headersObject, parsed);
224
+ if (result !== undefined) parsed = result;
225
+ }
226
+ }
187
227
  return parsed;
188
228
  }
189
229
 
@@ -438,14 +478,67 @@ function parseRetryAfter(headers, cap = 30000) {
438
478
  return undefined;
439
479
  }
440
480
 
481
+ function asHookList(value) {
482
+ if (!value) return [];
483
+ return typeof value === "function" ? [value] : Array.from(value);
484
+ }
485
+
486
+ // Optional client-side throttle: caps concurrency and spaces requests to a
487
+ // maximum rate (requests per second).
488
+ class RateLimiter {
489
+ constructor(rps, concurrency) {
490
+ this.interval = rps > 0 ? 1000 / rps : 0;
491
+ this.limit = concurrency > 0 ? concurrency : Infinity;
492
+ this.active = 0;
493
+ this.waiters = [];
494
+ this.nextAllowed = 0;
495
+ }
496
+
497
+ async run(fn, signal) {
498
+ await this.#acquire();
499
+ try {
500
+ await this.#rateWait(signal);
501
+ return await fn();
502
+ } finally {
503
+ const next = this.waiters.shift();
504
+ if (next) {
505
+ next(); // transfer the slot to the next waiter (active unchanged)
506
+ } else {
507
+ this.active--;
508
+ }
509
+ }
510
+ }
511
+
512
+ #acquire() {
513
+ if (this.active < this.limit) {
514
+ this.active++;
515
+ return Promise.resolve();
516
+ }
517
+ // Wait for a slot to be transferred to us (active stays at the limit).
518
+ return new Promise((resolve) => this.waiters.push(resolve));
519
+ }
520
+
521
+ async #rateWait(signal) {
522
+ if (!this.interval) return;
523
+ const now = Date.now();
524
+ const wait = Math.max(0, this.nextAllowed - now);
525
+ this.nextAllowed = Math.max(now, this.nextAllowed) + this.interval;
526
+ if (wait > 0) await sleep(wait, signal);
527
+ }
528
+ }
529
+
441
530
  function envVar(name) {
442
531
  return (typeof process !== "undefined" && process.env && process.env[name]) || undefined;
443
532
  }
444
533
 
534
+ function generateId() {
535
+ return (typeof crypto !== "undefined" && crypto.randomUUID) ? crypto.randomUUID() : `id-${Date.now()}-${Math.random().toString(16).slice(2)}`;
536
+ }
537
+
445
538
  function ensureRequestId(headers) {
446
539
  const existing = headerValue(headers, "x-request-id");
447
540
  if (existing) return existing;
448
- const id = (typeof crypto !== "undefined" && crypto.randomUUID) ? crypto.randomUUID() : `req-${Date.now()}-${Math.random().toString(16).slice(2)}`;
541
+ const id = generateId();
449
542
  headers["x-request-id"] = id;
450
543
  return id;
451
544
  }
package/src/index.d.ts CHANGED
@@ -10,6 +10,20 @@ export type CrawloraParams = Record<string, unknown>;
10
10
 
11
11
  export type CrawloraLogEvent = { event: string; [key: string]: unknown };
12
12
 
13
+ export interface CrawloraRequestContext {
14
+ operationId: string;
15
+ method: string;
16
+ url: string;
17
+ headers: Record<string, string>;
18
+ }
19
+ export type CrawloraBeforeRequest = (ctx: CrawloraRequestContext) => void | Promise<void>;
20
+ export type CrawloraAfterResponse = (
21
+ operationId: string,
22
+ status: number,
23
+ headers: Record<string, string>,
24
+ body: unknown
25
+ ) => unknown;
26
+
13
27
  export interface CrawloraClientOptions {
14
28
  apiKey?: string;
15
29
  jwtToken?: string;
@@ -27,8 +41,18 @@ export interface CrawloraClientOptions {
27
41
  onRetry?: (attempt: number, error: CrawloraError, delay: number) => void;
28
42
  /** Generate an x-request-id header when absent. */
29
43
  requestId?: boolean;
44
+ /** Attach a stable Idempotency-Key on POST/PATCH, reused across retries. */
45
+ idempotencyKeys?: boolean;
46
+ /** Cap outgoing requests to at most this many per second. */
47
+ rateLimit?: number;
48
+ /** Cap the number of in-flight requests. */
49
+ maxConcurrency?: number;
30
50
  /** Structured event sink (request/retry); never logs on its own. */
31
51
  logger?: (event: CrawloraLogEvent) => void;
52
+ /** Hook(s) run before each request; mutate ctx.headers / ctx.url. */
53
+ beforeRequest?: CrawloraBeforeRequest | Iterable<CrawloraBeforeRequest>;
54
+ /** Hook(s) run on the parsed success body; return a value to replace it. */
55
+ afterResponse?: CrawloraAfterResponse | Iterable<CrawloraAfterResponse>;
32
56
  headers?: Record<string, string>;
33
57
  userAgent?: string | false;
34
58
  fetch?: typeof globalThis.fetch;
@@ -39,6 +63,10 @@ export interface CrawloraRequestOptions {
39
63
  responseType?: "auto" | "json" | "text" | "stream";
40
64
  timeout?: number;
41
65
  signal?: AbortSignal;
66
+ /** Per-request retry count, overriding the client default. */
67
+ retries?: number;
68
+ /** Per-request retry predicate, overriding the client default. */
69
+ isRetryable?: (status: number, error: CrawloraError) => boolean;
42
70
  }
43
71
 
44
72
  export type OperationMethod = <T = unknown>(