@crawlbrulee/sdk 0.1.2 → 0.2.0

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/dist/index.d.cts CHANGED
@@ -524,6 +524,54 @@ interface WhoamiResponse {
524
524
  token_preview: string;
525
525
  }
526
526
 
527
+ /**
528
+ * Async scrape completion webhooks.
529
+ *
530
+ * When you submit a job with {@link Crawlbrulee.scrapeAsync} the API can deliver
531
+ * a `scrape.complete` webhook to your configured endpoint once the job reaches a
532
+ * terminal state. The envelope on the wire is exactly:
533
+ *
534
+ * ```json
535
+ * {
536
+ * "event_id": "evt_…",
537
+ * "timestamp": "2026-06-13T12:00:00.000Z",
538
+ * "event": "scrape.complete",
539
+ * "data": { "job_id": "job_…", "status": "success", "url": "https://…", "completed_at": "…" }
540
+ * }
541
+ * ```
542
+ */
543
+ /** Terminal status carried by a {@link ScrapeCompleteWebhook}. */
544
+ type ScrapeWebhookStatus = 'success' | 'failed' | 'cancelled';
545
+ /** `data` block of a {@link ScrapeCompleteWebhook}. */
546
+ interface ScrapeCompleteWebhookData {
547
+ /** The async job identifier — pass it to `getScrapeResult`. */
548
+ job_id: string;
549
+ /** Terminal state of the job. */
550
+ status: ScrapeWebhookStatus;
551
+ /** The URL that was scraped. */
552
+ url: string;
553
+ /** ISO-8601 UTC timestamp when the job reached its terminal state. */
554
+ completed_at: string;
555
+ /** Failure message — present only when `status === 'failed'`. */
556
+ error?: string;
557
+ /** Correlation data echoed back from the original scrape request, if any. */
558
+ metadata?: Record<string, unknown>;
559
+ }
560
+ /**
561
+ * Webhook envelope delivered when an async scrape job completes. The `event`
562
+ * discriminator is always the literal `'scrape.complete'`.
563
+ */
564
+ interface ScrapeCompleteWebhook {
565
+ /** Unique event identifier — also delivered in the `X-Cwbl-Event-Id` header. */
566
+ event_id: string;
567
+ /** ISO-8601 UTC timestamp when the event was emitted. */
568
+ timestamp: string;
569
+ /** Event type discriminator. */
570
+ event: 'scrape.complete';
571
+ /** Event payload. */
572
+ data: ScrapeCompleteWebhookData;
573
+ }
574
+
527
575
  /** Options accepted by the {@link Crawlbrulee} constructor. */
528
576
  interface CrawlbruleeOptions {
529
577
  /**
@@ -627,6 +675,25 @@ declare class Crawlbrulee {
627
675
  * first, or use {@link Crawlbrulee.waitForScrape} to poll-then-fetch.
628
676
  */
629
677
  getScrapeResult(jobId: string, options?: RequestOptions): Promise<ScrapeResponse>;
678
+ /**
679
+ * Fetch the scrape result referenced by a `scrape.complete` webhook body.
680
+ *
681
+ * Always verify the webhook signature with `verifyWebhookSignature` before
682
+ * acting on it; this method trusts the parsed body it is handed.
683
+ *
684
+ * Behavior by `data.status`:
685
+ * - `success` — delegates to {@link Crawlbrulee.getScrapeResult} for the
686
+ * webhook's `job_id` and returns the parsed result.
687
+ * - `failed` — throws a {@link CrawlbruleeError} carrying `data.error`
688
+ * (`errorName: 'job_failed'`); there is no result to fetch.
689
+ * - `cancelled` — throws a {@link CrawlbruleeError}
690
+ * (`errorName: 'client_closed_request'`).
691
+ *
692
+ * A non-`scrape.complete` envelope throws a {@link CrawlbruleeError}
693
+ * defensively. Any HTTP error from the underlying fetch propagates as the
694
+ * usual typed `CrawlbruleeError` subclass.
695
+ */
696
+ fetchScrapeResultFromWebhook(webhook: ScrapeCompleteWebhook, options?: RequestOptions): Promise<ScrapeResponse>;
630
697
  /**
631
698
  * Poll an async scrape job until it reaches a terminal state, then return
632
699
  * the scrape result.
@@ -782,4 +849,96 @@ declare const DEFAULT_REQUEST_TIMEOUT_MS = 60000;
782
849
  /** Environment variable read by `Crawlbrulee.fromEnv()` to source the API key. */
783
850
  declare const ENV_API_KEY = "CRAWLBRULEE_API_KEY";
784
851
 
785
- export { type ApiErrorDetails, type ApiErrorName, type ApiErrorResponse, type AsyncJobStatus, type AsyncJobStatusResponse, type AsyncScrapeResponse, AuthenticationError, Crawlbrulee, CrawlbruleeError, type CrawlbruleeOptions, DEFAULT_BASE_URL, DEFAULT_REQUEST_TIMEOUT_MS, ENV_API_KEY, type HttpMethod, type MapCache, type MapLinkItem, type MapLocation, type MapPagination, type MapRequest, type MapResponse, type MapTruncation, type MapTypes, NotFoundError, type PageInlineImage, type PageLink, type ProxyTier, RateLimitError, type RateLimitErrorDetails, type RequestOptions, type ScrapeCache, type ScrapeExtract, type ScrapeLocation, type ScrapeMetadata, type ScrapeRequest, type ScrapeResponse, type ScreenshotAfterAction, type ScreenshotBeforeAction, type ScreenshotCleanup, type ScreenshotDeviceMode, type ScreenshotProperties, type ScreenshotRequest, type ScreenshotResult, type ScreenshotScrollAction, type ScreenshotSlice, type ScreenshotSliceAction, type ScreenshotType, type ScreenshotViewport, type ScreenshotViewportInfo, type ScreenshotWaitAction, TransportError, UsageAllocationError, type UsageAllocationErrorDetails, type UsageAllocationReason, type UsageLimitDetails, type UsageResponse, ValidationError, type WaitForScrapeOptions, type WhoamiResponse, isCrawlbruleeError };
852
+ /**
853
+ * Verification for async scrape completion webhooks.
854
+ *
855
+ * {@link verifyWebhookSignature} validates the signature crawlbrulee attaches to
856
+ * every webhook delivery. It is a standalone, network-free helper built on Web
857
+ * Crypto (`globalThis.crypto.subtle`) so it runs unchanged on Node.js 22+,
858
+ * browsers, Bun, Deno, and edge runtimes — it never touches `node:crypto`.
859
+ */
860
+ /** HTTP header carrying the primary webhook signature (always present). */
861
+ declare const WEBHOOK_SIGNATURE_HEADER = "X-Cwbl-Signature";
862
+ /**
863
+ * HTTP header carrying a signature produced with the previous signing secret.
864
+ * Present only during a signing-secret rotation grace window.
865
+ */
866
+ declare const WEBHOOK_SIGNATURE_ROTATED_HEADER = "X-Cwbl-Signature-Rotated";
867
+ /** HTTP header carrying the unique event id, useful for delivery de-duplication. */
868
+ declare const WEBHOOK_EVENT_ID_HEADER = "X-Cwbl-Event-Id";
869
+ /** Default replay-protection window (seconds) applied to the signed timestamp. */
870
+ declare const DEFAULT_WEBHOOK_TOLERANCE_SECONDS = 300;
871
+ /** Which signature header satisfied verification. */
872
+ type WebhookSignatureSource = 'primary' | 'rotated';
873
+ /**
874
+ * Why a webhook signature failed to verify.
875
+ *
876
+ * - `missing_signature` — neither the primary nor the rotated header was present.
877
+ * - `malformed_signature` — a header was present but not in the expected
878
+ * `t=<unix_seconds>,v1=<64_hex>` format.
879
+ * - `timestamp_out_of_tolerance` — the signed timestamp drifted further from now
880
+ * than `toleranceSeconds` allows (replay protection).
881
+ * - `signature_mismatch` — a well-formed, in-tolerance signature did not match
882
+ * the one computed from the payload and secret.
883
+ */
884
+ type WebhookVerificationFailureReason = 'missing_signature' | 'malformed_signature' | 'timestamp_out_of_tolerance' | 'signature_mismatch';
885
+ /** Result of {@link verifyWebhookSignature}. Verification failure is returned, not thrown. */
886
+ type WebhookVerificationResult = {
887
+ verified: true;
888
+ signedWith: WebhookSignatureSource;
889
+ } | {
890
+ verified: false;
891
+ reason: WebhookVerificationFailureReason;
892
+ };
893
+ /** Options for {@link verifyWebhookSignature}. */
894
+ interface VerifyWebhookSignatureOptions {
895
+ /**
896
+ * The raw request body, exactly as received. Pass the bytes/string the server
897
+ * signed — do NOT re-serialize parsed JSON, or the signature will not match.
898
+ */
899
+ payload: string | Uint8Array;
900
+ /**
901
+ * The request headers. Accepts a fetch `Headers` instance or a plain object
902
+ * (Express/Node give lowercased keys, values possibly arrays). Lookup is
903
+ * case-insensitive.
904
+ */
905
+ headers: Headers | Record<string, string | string[] | undefined>;
906
+ /** The current signing secret (`whsec_…`). */
907
+ secret: string;
908
+ /**
909
+ * Replay-protection window in seconds. Defaults to
910
+ * {@link DEFAULT_WEBHOOK_TOLERANCE_SECONDS} (300). Pass `0` (or any falsy
911
+ * value) to disable the timestamp check entirely.
912
+ */
913
+ toleranceSeconds?: number;
914
+ }
915
+ /**
916
+ * Verify a crawlbrulee webhook signature against the primary and rotated
917
+ * headers.
918
+ *
919
+ * The signing scheme matches the backend:
920
+ * - the signed payload is `` `${t}.${rawBody}` `` where `t` is the unix-seconds
921
+ * integer from the header and `rawBody` is the raw request body,
922
+ * - the signature is `HMAC-SHA256(secret, signedPayload)` as lowercase hex,
923
+ * - the header value is `t=<unix_seconds>,v1=<64_hex>`.
924
+ *
925
+ * The supplied `secret` is tried against the primary header first, then the
926
+ * rotated header (which the API emits during a signing-secret rotation grace
927
+ * window). Whichever matches wins, and the result reports which header it was.
928
+ *
929
+ * This NEVER throws on a verification failure — failures are normal control
930
+ * flow and are returned as `{ verified: false, reason }`.
931
+ *
932
+ * @example
933
+ * ```ts
934
+ * const result = await verifyWebhookSignature({
935
+ * payload: rawBody,
936
+ * headers: req.headers,
937
+ * secret: process.env.CRAWLBRULEE_WEBHOOK_SECRET!,
938
+ * })
939
+ * if (!result.verified) return res.status(400).end()
940
+ * ```
941
+ */
942
+ declare function verifyWebhookSignature(options: VerifyWebhookSignatureOptions): Promise<WebhookVerificationResult>;
943
+
944
+ export { type ApiErrorDetails, type ApiErrorName, type ApiErrorResponse, type AsyncJobStatus, type AsyncJobStatusResponse, type AsyncScrapeResponse, AuthenticationError, Crawlbrulee, CrawlbruleeError, type CrawlbruleeOptions, DEFAULT_BASE_URL, DEFAULT_REQUEST_TIMEOUT_MS, DEFAULT_WEBHOOK_TOLERANCE_SECONDS, ENV_API_KEY, type HttpMethod, type MapCache, type MapLinkItem, type MapLocation, type MapPagination, type MapRequest, type MapResponse, type MapTruncation, type MapTypes, NotFoundError, type PageInlineImage, type PageLink, type ProxyTier, RateLimitError, type RateLimitErrorDetails, type RequestOptions, type ScrapeCache, type ScrapeCompleteWebhook, type ScrapeCompleteWebhookData, type ScrapeExtract, type ScrapeLocation, type ScrapeMetadata, type ScrapeRequest, type ScrapeResponse, type ScrapeWebhookStatus, type ScreenshotAfterAction, type ScreenshotBeforeAction, type ScreenshotCleanup, type ScreenshotDeviceMode, type ScreenshotProperties, type ScreenshotRequest, type ScreenshotResult, type ScreenshotScrollAction, type ScreenshotSlice, type ScreenshotSliceAction, type ScreenshotType, type ScreenshotViewport, type ScreenshotViewportInfo, type ScreenshotWaitAction, TransportError, UsageAllocationError, type UsageAllocationErrorDetails, type UsageAllocationReason, type UsageLimitDetails, type UsageResponse, ValidationError, type VerifyWebhookSignatureOptions, WEBHOOK_EVENT_ID_HEADER, WEBHOOK_SIGNATURE_HEADER, WEBHOOK_SIGNATURE_ROTATED_HEADER, type WaitForScrapeOptions, type WebhookSignatureSource, type WebhookVerificationFailureReason, type WebhookVerificationResult, type WhoamiResponse, isCrawlbruleeError, verifyWebhookSignature };
package/dist/index.d.ts CHANGED
@@ -524,6 +524,54 @@ interface WhoamiResponse {
524
524
  token_preview: string;
525
525
  }
526
526
 
527
+ /**
528
+ * Async scrape completion webhooks.
529
+ *
530
+ * When you submit a job with {@link Crawlbrulee.scrapeAsync} the API can deliver
531
+ * a `scrape.complete` webhook to your configured endpoint once the job reaches a
532
+ * terminal state. The envelope on the wire is exactly:
533
+ *
534
+ * ```json
535
+ * {
536
+ * "event_id": "evt_…",
537
+ * "timestamp": "2026-06-13T12:00:00.000Z",
538
+ * "event": "scrape.complete",
539
+ * "data": { "job_id": "job_…", "status": "success", "url": "https://…", "completed_at": "…" }
540
+ * }
541
+ * ```
542
+ */
543
+ /** Terminal status carried by a {@link ScrapeCompleteWebhook}. */
544
+ type ScrapeWebhookStatus = 'success' | 'failed' | 'cancelled';
545
+ /** `data` block of a {@link ScrapeCompleteWebhook}. */
546
+ interface ScrapeCompleteWebhookData {
547
+ /** The async job identifier — pass it to `getScrapeResult`. */
548
+ job_id: string;
549
+ /** Terminal state of the job. */
550
+ status: ScrapeWebhookStatus;
551
+ /** The URL that was scraped. */
552
+ url: string;
553
+ /** ISO-8601 UTC timestamp when the job reached its terminal state. */
554
+ completed_at: string;
555
+ /** Failure message — present only when `status === 'failed'`. */
556
+ error?: string;
557
+ /** Correlation data echoed back from the original scrape request, if any. */
558
+ metadata?: Record<string, unknown>;
559
+ }
560
+ /**
561
+ * Webhook envelope delivered when an async scrape job completes. The `event`
562
+ * discriminator is always the literal `'scrape.complete'`.
563
+ */
564
+ interface ScrapeCompleteWebhook {
565
+ /** Unique event identifier — also delivered in the `X-Cwbl-Event-Id` header. */
566
+ event_id: string;
567
+ /** ISO-8601 UTC timestamp when the event was emitted. */
568
+ timestamp: string;
569
+ /** Event type discriminator. */
570
+ event: 'scrape.complete';
571
+ /** Event payload. */
572
+ data: ScrapeCompleteWebhookData;
573
+ }
574
+
527
575
  /** Options accepted by the {@link Crawlbrulee} constructor. */
528
576
  interface CrawlbruleeOptions {
529
577
  /**
@@ -627,6 +675,25 @@ declare class Crawlbrulee {
627
675
  * first, or use {@link Crawlbrulee.waitForScrape} to poll-then-fetch.
628
676
  */
629
677
  getScrapeResult(jobId: string, options?: RequestOptions): Promise<ScrapeResponse>;
678
+ /**
679
+ * Fetch the scrape result referenced by a `scrape.complete` webhook body.
680
+ *
681
+ * Always verify the webhook signature with `verifyWebhookSignature` before
682
+ * acting on it; this method trusts the parsed body it is handed.
683
+ *
684
+ * Behavior by `data.status`:
685
+ * - `success` — delegates to {@link Crawlbrulee.getScrapeResult} for the
686
+ * webhook's `job_id` and returns the parsed result.
687
+ * - `failed` — throws a {@link CrawlbruleeError} carrying `data.error`
688
+ * (`errorName: 'job_failed'`); there is no result to fetch.
689
+ * - `cancelled` — throws a {@link CrawlbruleeError}
690
+ * (`errorName: 'client_closed_request'`).
691
+ *
692
+ * A non-`scrape.complete` envelope throws a {@link CrawlbruleeError}
693
+ * defensively. Any HTTP error from the underlying fetch propagates as the
694
+ * usual typed `CrawlbruleeError` subclass.
695
+ */
696
+ fetchScrapeResultFromWebhook(webhook: ScrapeCompleteWebhook, options?: RequestOptions): Promise<ScrapeResponse>;
630
697
  /**
631
698
  * Poll an async scrape job until it reaches a terminal state, then return
632
699
  * the scrape result.
@@ -782,4 +849,96 @@ declare const DEFAULT_REQUEST_TIMEOUT_MS = 60000;
782
849
  /** Environment variable read by `Crawlbrulee.fromEnv()` to source the API key. */
783
850
  declare const ENV_API_KEY = "CRAWLBRULEE_API_KEY";
784
851
 
785
- export { type ApiErrorDetails, type ApiErrorName, type ApiErrorResponse, type AsyncJobStatus, type AsyncJobStatusResponse, type AsyncScrapeResponse, AuthenticationError, Crawlbrulee, CrawlbruleeError, type CrawlbruleeOptions, DEFAULT_BASE_URL, DEFAULT_REQUEST_TIMEOUT_MS, ENV_API_KEY, type HttpMethod, type MapCache, type MapLinkItem, type MapLocation, type MapPagination, type MapRequest, type MapResponse, type MapTruncation, type MapTypes, NotFoundError, type PageInlineImage, type PageLink, type ProxyTier, RateLimitError, type RateLimitErrorDetails, type RequestOptions, type ScrapeCache, type ScrapeExtract, type ScrapeLocation, type ScrapeMetadata, type ScrapeRequest, type ScrapeResponse, type ScreenshotAfterAction, type ScreenshotBeforeAction, type ScreenshotCleanup, type ScreenshotDeviceMode, type ScreenshotProperties, type ScreenshotRequest, type ScreenshotResult, type ScreenshotScrollAction, type ScreenshotSlice, type ScreenshotSliceAction, type ScreenshotType, type ScreenshotViewport, type ScreenshotViewportInfo, type ScreenshotWaitAction, TransportError, UsageAllocationError, type UsageAllocationErrorDetails, type UsageAllocationReason, type UsageLimitDetails, type UsageResponse, ValidationError, type WaitForScrapeOptions, type WhoamiResponse, isCrawlbruleeError };
852
+ /**
853
+ * Verification for async scrape completion webhooks.
854
+ *
855
+ * {@link verifyWebhookSignature} validates the signature crawlbrulee attaches to
856
+ * every webhook delivery. It is a standalone, network-free helper built on Web
857
+ * Crypto (`globalThis.crypto.subtle`) so it runs unchanged on Node.js 22+,
858
+ * browsers, Bun, Deno, and edge runtimes — it never touches `node:crypto`.
859
+ */
860
+ /** HTTP header carrying the primary webhook signature (always present). */
861
+ declare const WEBHOOK_SIGNATURE_HEADER = "X-Cwbl-Signature";
862
+ /**
863
+ * HTTP header carrying a signature produced with the previous signing secret.
864
+ * Present only during a signing-secret rotation grace window.
865
+ */
866
+ declare const WEBHOOK_SIGNATURE_ROTATED_HEADER = "X-Cwbl-Signature-Rotated";
867
+ /** HTTP header carrying the unique event id, useful for delivery de-duplication. */
868
+ declare const WEBHOOK_EVENT_ID_HEADER = "X-Cwbl-Event-Id";
869
+ /** Default replay-protection window (seconds) applied to the signed timestamp. */
870
+ declare const DEFAULT_WEBHOOK_TOLERANCE_SECONDS = 300;
871
+ /** Which signature header satisfied verification. */
872
+ type WebhookSignatureSource = 'primary' | 'rotated';
873
+ /**
874
+ * Why a webhook signature failed to verify.
875
+ *
876
+ * - `missing_signature` — neither the primary nor the rotated header was present.
877
+ * - `malformed_signature` — a header was present but not in the expected
878
+ * `t=<unix_seconds>,v1=<64_hex>` format.
879
+ * - `timestamp_out_of_tolerance` — the signed timestamp drifted further from now
880
+ * than `toleranceSeconds` allows (replay protection).
881
+ * - `signature_mismatch` — a well-formed, in-tolerance signature did not match
882
+ * the one computed from the payload and secret.
883
+ */
884
+ type WebhookVerificationFailureReason = 'missing_signature' | 'malformed_signature' | 'timestamp_out_of_tolerance' | 'signature_mismatch';
885
+ /** Result of {@link verifyWebhookSignature}. Verification failure is returned, not thrown. */
886
+ type WebhookVerificationResult = {
887
+ verified: true;
888
+ signedWith: WebhookSignatureSource;
889
+ } | {
890
+ verified: false;
891
+ reason: WebhookVerificationFailureReason;
892
+ };
893
+ /** Options for {@link verifyWebhookSignature}. */
894
+ interface VerifyWebhookSignatureOptions {
895
+ /**
896
+ * The raw request body, exactly as received. Pass the bytes/string the server
897
+ * signed — do NOT re-serialize parsed JSON, or the signature will not match.
898
+ */
899
+ payload: string | Uint8Array;
900
+ /**
901
+ * The request headers. Accepts a fetch `Headers` instance or a plain object
902
+ * (Express/Node give lowercased keys, values possibly arrays). Lookup is
903
+ * case-insensitive.
904
+ */
905
+ headers: Headers | Record<string, string | string[] | undefined>;
906
+ /** The current signing secret (`whsec_…`). */
907
+ secret: string;
908
+ /**
909
+ * Replay-protection window in seconds. Defaults to
910
+ * {@link DEFAULT_WEBHOOK_TOLERANCE_SECONDS} (300). Pass `0` (or any falsy
911
+ * value) to disable the timestamp check entirely.
912
+ */
913
+ toleranceSeconds?: number;
914
+ }
915
+ /**
916
+ * Verify a crawlbrulee webhook signature against the primary and rotated
917
+ * headers.
918
+ *
919
+ * The signing scheme matches the backend:
920
+ * - the signed payload is `` `${t}.${rawBody}` `` where `t` is the unix-seconds
921
+ * integer from the header and `rawBody` is the raw request body,
922
+ * - the signature is `HMAC-SHA256(secret, signedPayload)` as lowercase hex,
923
+ * - the header value is `t=<unix_seconds>,v1=<64_hex>`.
924
+ *
925
+ * The supplied `secret` is tried against the primary header first, then the
926
+ * rotated header (which the API emits during a signing-secret rotation grace
927
+ * window). Whichever matches wins, and the result reports which header it was.
928
+ *
929
+ * This NEVER throws on a verification failure — failures are normal control
930
+ * flow and are returned as `{ verified: false, reason }`.
931
+ *
932
+ * @example
933
+ * ```ts
934
+ * const result = await verifyWebhookSignature({
935
+ * payload: rawBody,
936
+ * headers: req.headers,
937
+ * secret: process.env.CRAWLBRULEE_WEBHOOK_SECRET!,
938
+ * })
939
+ * if (!result.verified) return res.status(400).end()
940
+ * ```
941
+ */
942
+ declare function verifyWebhookSignature(options: VerifyWebhookSignatureOptions): Promise<WebhookVerificationResult>;
943
+
944
+ export { type ApiErrorDetails, type ApiErrorName, type ApiErrorResponse, type AsyncJobStatus, type AsyncJobStatusResponse, type AsyncScrapeResponse, AuthenticationError, Crawlbrulee, CrawlbruleeError, type CrawlbruleeOptions, DEFAULT_BASE_URL, DEFAULT_REQUEST_TIMEOUT_MS, DEFAULT_WEBHOOK_TOLERANCE_SECONDS, ENV_API_KEY, type HttpMethod, type MapCache, type MapLinkItem, type MapLocation, type MapPagination, type MapRequest, type MapResponse, type MapTruncation, type MapTypes, NotFoundError, type PageInlineImage, type PageLink, type ProxyTier, RateLimitError, type RateLimitErrorDetails, type RequestOptions, type ScrapeCache, type ScrapeCompleteWebhook, type ScrapeCompleteWebhookData, type ScrapeExtract, type ScrapeLocation, type ScrapeMetadata, type ScrapeRequest, type ScrapeResponse, type ScrapeWebhookStatus, type ScreenshotAfterAction, type ScreenshotBeforeAction, type ScreenshotCleanup, type ScreenshotDeviceMode, type ScreenshotProperties, type ScreenshotRequest, type ScreenshotResult, type ScreenshotScrollAction, type ScreenshotSlice, type ScreenshotSliceAction, type ScreenshotType, type ScreenshotViewport, type ScreenshotViewportInfo, type ScreenshotWaitAction, TransportError, UsageAllocationError, type UsageAllocationErrorDetails, type UsageAllocationReason, type UsageLimitDetails, type UsageResponse, ValidationError, type VerifyWebhookSignatureOptions, WEBHOOK_EVENT_ID_HEADER, WEBHOOK_SIGNATURE_HEADER, WEBHOOK_SIGNATURE_ROTATED_HEADER, type WaitForScrapeOptions, type WebhookSignatureSource, type WebhookVerificationFailureReason, type WebhookVerificationResult, type WhoamiResponse, isCrawlbruleeError, verifyWebhookSignature };
package/dist/index.js CHANGED
@@ -396,6 +396,52 @@ var Crawlbrulee = class _Crawlbrulee {
396
396
  assertNonEmptyJobId(jobId);
397
397
  return this.http.get(`/api/scrape/result/${encodeURIComponent(jobId)}`, options);
398
398
  }
399
+ /**
400
+ * Fetch the scrape result referenced by a `scrape.complete` webhook body.
401
+ *
402
+ * Always verify the webhook signature with `verifyWebhookSignature` before
403
+ * acting on it; this method trusts the parsed body it is handed.
404
+ *
405
+ * Behavior by `data.status`:
406
+ * - `success` — delegates to {@link Crawlbrulee.getScrapeResult} for the
407
+ * webhook's `job_id` and returns the parsed result.
408
+ * - `failed` — throws a {@link CrawlbruleeError} carrying `data.error`
409
+ * (`errorName: 'job_failed'`); there is no result to fetch.
410
+ * - `cancelled` — throws a {@link CrawlbruleeError}
411
+ * (`errorName: 'client_closed_request'`).
412
+ *
413
+ * A non-`scrape.complete` envelope throws a {@link CrawlbruleeError}
414
+ * defensively. Any HTTP error from the underlying fetch propagates as the
415
+ * usual typed `CrawlbruleeError` subclass.
416
+ */
417
+ async fetchScrapeResultFromWebhook(webhook, options) {
418
+ if (webhook?.event !== "scrape.complete") {
419
+ throw new CrawlbruleeError(
420
+ `Expected a 'scrape.complete' webhook but received '${String(webhook?.event)}'.`,
421
+ { status: 0, errorName: "validation_error" }
422
+ );
423
+ }
424
+ const { job_id: jobId, status, error } = webhook.data;
425
+ switch (status) {
426
+ case "success":
427
+ return this.getScrapeResult(jobId, options);
428
+ case "failed":
429
+ throw new CrawlbruleeError(error ?? `Async scrape job ${jobId} failed.`, {
430
+ status: 0,
431
+ errorName: "job_failed"
432
+ });
433
+ case "cancelled":
434
+ throw new CrawlbruleeError(`Async scrape job ${jobId} was cancelled.`, {
435
+ status: 0,
436
+ errorName: "client_closed_request"
437
+ });
438
+ default:
439
+ throw new CrawlbruleeError(
440
+ `Async scrape webhook for job ${jobId} carried an unexpected status '${String(status)}'.`,
441
+ { status: 0, errorName: "validation_error" }
442
+ );
443
+ }
444
+ }
399
445
  /**
400
446
  * Poll an async scrape job until it reaches a terminal state, then return
401
447
  * the scrape result.
@@ -522,6 +568,117 @@ function sleep(ms, signal) {
522
568
  });
523
569
  }
524
570
 
525
- export { AuthenticationError, Crawlbrulee, CrawlbruleeError, DEFAULT_BASE_URL, DEFAULT_REQUEST_TIMEOUT_MS, ENV_API_KEY, NotFoundError, RateLimitError, TransportError, UsageAllocationError, ValidationError, isCrawlbruleeError };
571
+ // src/webhooks.ts
572
+ var WEBHOOK_SIGNATURE_HEADER = "X-Cwbl-Signature";
573
+ var WEBHOOK_SIGNATURE_ROTATED_HEADER = "X-Cwbl-Signature-Rotated";
574
+ var WEBHOOK_EVENT_ID_HEADER = "X-Cwbl-Event-Id";
575
+ var DEFAULT_WEBHOOK_TOLERANCE_SECONDS = 300;
576
+ var SIGNATURE_FORMAT = /^t=(\d+),v1=([0-9a-f]{64})$/;
577
+ async function verifyWebhookSignature(options) {
578
+ const { payload, headers, secret } = options;
579
+ const toleranceSeconds = options.toleranceSeconds ?? DEFAULT_WEBHOOK_TOLERANCE_SECONDS;
580
+ const primaryHeader = getHeader(headers, WEBHOOK_SIGNATURE_HEADER);
581
+ const rotatedHeader = getHeader(headers, WEBHOOK_SIGNATURE_ROTATED_HEADER);
582
+ if (primaryHeader === void 0 && rotatedHeader === void 0) {
583
+ return { verified: false, reason: "missing_signature" };
584
+ }
585
+ const nowSeconds = Math.floor(Date.now() / 1e3);
586
+ const body = toBytes(payload);
587
+ const key = await importHmacKey(secret);
588
+ let failure = "malformed_signature";
589
+ for (const source of ["primary", "rotated"]) {
590
+ const raw = source === "primary" ? primaryHeader : rotatedHeader;
591
+ if (raw === void 0) continue;
592
+ const parsed = parseSignatureHeader(raw);
593
+ if (!parsed) {
594
+ continue;
595
+ }
596
+ if (toleranceSeconds && Math.abs(nowSeconds - parsed.timestamp) > toleranceSeconds) {
597
+ failure = mostSpecificFailure(failure, "timestamp_out_of_tolerance");
598
+ continue;
599
+ }
600
+ const expected = await computeSignatureHex(key, parsed.timestamp, body);
601
+ if (constantTimeEqualHex(expected, parsed.signature)) {
602
+ return { verified: true, signedWith: source };
603
+ }
604
+ failure = mostSpecificFailure(failure, "signature_mismatch");
605
+ }
606
+ return { verified: false, reason: failure };
607
+ }
608
+ function mostSpecificFailure(current, candidate) {
609
+ const rank = {
610
+ missing_signature: 0,
611
+ malformed_signature: 1,
612
+ timestamp_out_of_tolerance: 2,
613
+ signature_mismatch: 3
614
+ };
615
+ return rank[candidate] > rank[current] ? candidate : current;
616
+ }
617
+ function getHeader(headers, name) {
618
+ if (typeof Headers !== "undefined" && headers instanceof Headers) {
619
+ return headers.get(name) ?? void 0;
620
+ }
621
+ const target = name.toLowerCase();
622
+ for (const key of Object.keys(headers)) {
623
+ if (key.toLowerCase() !== target) continue;
624
+ const value = headers[key];
625
+ if (Array.isArray(value)) return value[0];
626
+ return value ?? void 0;
627
+ }
628
+ return void 0;
629
+ }
630
+ function parseSignatureHeader(value) {
631
+ const match = SIGNATURE_FORMAT.exec(value.trim());
632
+ if (!match) return null;
633
+ const timestamp = Number(match[1]);
634
+ if (!Number.isSafeInteger(timestamp)) return null;
635
+ return { timestamp, signature: match[2] };
636
+ }
637
+ function toBytes(payload) {
638
+ return typeof payload === "string" ? new TextEncoder().encode(payload) : payload;
639
+ }
640
+ function importHmacKey(secret) {
641
+ return getSubtle().importKey(
642
+ "raw",
643
+ new TextEncoder().encode(secret),
644
+ { name: "HMAC", hash: "SHA-256" },
645
+ false,
646
+ ["sign"]
647
+ );
648
+ }
649
+ async function computeSignatureHex(key, timestamp, body) {
650
+ const prefix = new TextEncoder().encode(`${timestamp}.`);
651
+ const message = new Uint8Array(prefix.length + body.length);
652
+ message.set(prefix, 0);
653
+ message.set(body, prefix.length);
654
+ const digest = await getSubtle().sign("HMAC", key, message);
655
+ return toHex(new Uint8Array(digest));
656
+ }
657
+ function toHex(bytes) {
658
+ let hex = "";
659
+ for (const byte of bytes) {
660
+ hex += byte.toString(16).padStart(2, "0");
661
+ }
662
+ return hex;
663
+ }
664
+ function constantTimeEqualHex(a, b) {
665
+ if (a.length !== b.length) return false;
666
+ let diff = 0;
667
+ for (let i = 0; i < a.length; i++) {
668
+ diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
669
+ }
670
+ return diff === 0;
671
+ }
672
+ function getSubtle() {
673
+ const subtle = globalThis.crypto?.subtle;
674
+ if (!subtle) {
675
+ throw new Error(
676
+ "Web Crypto (globalThis.crypto.subtle) is not available in this runtime. crawlbrulee webhook verification requires Node.js 20+, Bun, Deno, or a modern browser/edge runtime."
677
+ );
678
+ }
679
+ return subtle;
680
+ }
681
+
682
+ export { AuthenticationError, Crawlbrulee, CrawlbruleeError, DEFAULT_BASE_URL, DEFAULT_REQUEST_TIMEOUT_MS, DEFAULT_WEBHOOK_TOLERANCE_SECONDS, ENV_API_KEY, NotFoundError, RateLimitError, TransportError, UsageAllocationError, ValidationError, WEBHOOK_EVENT_ID_HEADER, WEBHOOK_SIGNATURE_HEADER, WEBHOOK_SIGNATURE_ROTATED_HEADER, isCrawlbruleeError, verifyWebhookSignature };
526
683
  //# sourceMappingURL=index.js.map
527
684
  //# sourceMappingURL=index.js.map