@websitescreenshotworld/sdk 0.0.0-stage → 0.1.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.
Files changed (81) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/LICENSE +21 -0
  3. package/README.md +399 -2
  4. package/dist/cjs/auto-schedule.d.ts +20 -0
  5. package/dist/cjs/auto-schedule.js +34 -0
  6. package/dist/cjs/client.d.ts +41 -0
  7. package/dist/cjs/client.js +58 -0
  8. package/dist/cjs/error-codes.d.ts +54 -0
  9. package/dist/cjs/error-codes.js +94 -0
  10. package/dist/cjs/errors.d.ts +56 -0
  11. package/dist/cjs/errors.js +112 -0
  12. package/dist/cjs/files.d.ts +6 -0
  13. package/dist/cjs/files.js +45 -0
  14. package/dist/cjs/index.d.ts +16 -0
  15. package/dist/cjs/index.js +37 -0
  16. package/dist/cjs/instant-snap.d.ts +99 -0
  17. package/dist/cjs/instant-snap.js +205 -0
  18. package/dist/cjs/models/capture-file.d.ts +35 -0
  19. package/dist/cjs/models/capture-file.js +34 -0
  20. package/dist/cjs/models/capture-options.d.ts +144 -0
  21. package/dist/cjs/models/capture-options.js +2 -0
  22. package/dist/cjs/models/capture.d.ts +108 -0
  23. package/dist/cjs/models/capture.js +63 -0
  24. package/dist/cjs/models/defaults.d.ts +82 -0
  25. package/dist/cjs/models/defaults.js +46 -0
  26. package/dist/cjs/models/tokens.d.ts +92 -0
  27. package/dist/cjs/models/tokens.js +85 -0
  28. package/dist/cjs/models/trigger-result.d.ts +20 -0
  29. package/dist/cjs/models/trigger-result.js +13 -0
  30. package/dist/cjs/package.json +3 -0
  31. package/dist/cjs/transport.d.ts +55 -0
  32. package/dist/cjs/transport.js +206 -0
  33. package/dist/cjs/version.d.ts +2 -0
  34. package/dist/cjs/version.js +5 -0
  35. package/dist/cjs/webhooks/events.d.ts +128 -0
  36. package/dist/cjs/webhooks/events.js +13 -0
  37. package/dist/cjs/webhooks/parse-event.d.ts +10 -0
  38. package/dist/cjs/webhooks/parse-event.js +149 -0
  39. package/dist/cjs/webhooks/webhook.d.ts +26 -0
  40. package/dist/cjs/webhooks/webhook.js +78 -0
  41. package/dist/cjs/wire.d.ts +15 -0
  42. package/dist/cjs/wire.js +71 -0
  43. package/dist/esm/auto-schedule.d.ts +20 -0
  44. package/dist/esm/auto-schedule.js +30 -0
  45. package/dist/esm/client.d.ts +41 -0
  46. package/dist/esm/client.js +54 -0
  47. package/dist/esm/error-codes.d.ts +54 -0
  48. package/dist/esm/error-codes.js +91 -0
  49. package/dist/esm/errors.d.ts +56 -0
  50. package/dist/esm/errors.js +98 -0
  51. package/dist/esm/files.d.ts +6 -0
  52. package/dist/esm/files.js +41 -0
  53. package/dist/esm/index.d.ts +16 -0
  54. package/dist/esm/index.js +7 -0
  55. package/dist/esm/instant-snap.d.ts +99 -0
  56. package/dist/esm/instant-snap.js +201 -0
  57. package/dist/esm/models/capture-file.d.ts +35 -0
  58. package/dist/esm/models/capture-file.js +31 -0
  59. package/dist/esm/models/capture-options.d.ts +144 -0
  60. package/dist/esm/models/capture-options.js +1 -0
  61. package/dist/esm/models/capture.d.ts +108 -0
  62. package/dist/esm/models/capture.js +57 -0
  63. package/dist/esm/models/defaults.d.ts +82 -0
  64. package/dist/esm/models/defaults.js +42 -0
  65. package/dist/esm/models/tokens.d.ts +92 -0
  66. package/dist/esm/models/tokens.js +82 -0
  67. package/dist/esm/models/trigger-result.d.ts +20 -0
  68. package/dist/esm/models/trigger-result.js +10 -0
  69. package/dist/esm/transport.d.ts +55 -0
  70. package/dist/esm/transport.js +201 -0
  71. package/dist/esm/version.d.ts +2 -0
  72. package/dist/esm/version.js +2 -0
  73. package/dist/esm/webhooks/events.d.ts +128 -0
  74. package/dist/esm/webhooks/events.js +10 -0
  75. package/dist/esm/webhooks/parse-event.d.ts +10 -0
  76. package/dist/esm/webhooks/parse-event.js +146 -0
  77. package/dist/esm/webhooks/webhook.d.ts +26 -0
  78. package/dist/esm/webhooks/webhook.js +75 -0
  79. package/dist/esm/wire.d.ts +15 -0
  80. package/dist/esm/wire.js +66 -0
  81. package/package.json +68 -4
@@ -0,0 +1,55 @@
1
+ /** Options every call accepts as its last argument. */
2
+ export interface RequestOptions {
3
+ /**
4
+ * Milliseconds the call may take, retries and the waits between them included. Defaults to the
5
+ * client's `captureTimeout` for captures and `timeout` for everything else.
6
+ */
7
+ timeout?: number;
8
+ /**
9
+ * Stops the call, and any retry it would make, as soon as it aborts; the call then rejects with
10
+ * the signal's reason. Once a file has started to arrive, cancel its stream instead.
11
+ */
12
+ signal?: AbortSignal;
13
+ }
14
+ /** Options the capture calls accept as their last argument. */
15
+ export interface CaptureRequestOptions extends RequestOptions {
16
+ /**
17
+ * Makes the capture safe to repeat within 24 hours. A random UUID is used when none is given;
18
+ * either way, every retry of this call sends the same key.
19
+ */
20
+ idempotencyKey?: string;
21
+ }
22
+ export interface TransportOptions {
23
+ /** Looked up on each request when left out, so a global fetch replaced later is used. */
24
+ fetch: typeof fetch | undefined;
25
+ maxRetries: number;
26
+ userAgent: string;
27
+ }
28
+ /** One API call, which may take several attempts. */
29
+ export interface Call {
30
+ method: "GET" | "POST";
31
+ url: string;
32
+ /** Left out for the one endpoint that needs no key. */
33
+ apiKey?: string;
34
+ /** A JSON body; left out for calls without one. */
35
+ body?: unknown;
36
+ idempotencyKey?: string;
37
+ /** The answer is a file rather than JSON. */
38
+ file?: boolean;
39
+ timeout: number;
40
+ signal?: AbortSignal | undefined;
41
+ }
42
+ /** Sends calls with the shared headers, timeouts and retries. */
43
+ export declare class Transport {
44
+ #private;
45
+ constructor(options: TransportOptions);
46
+ /** Makes the call and parses its JSON answer. */
47
+ json(call: Call): Promise<unknown>;
48
+ /** Makes the call and returns the answer as soon as its headers arrive, with the body unread. */
49
+ file(call: Call): Promise<Response>;
50
+ }
51
+ /**
52
+ * How long to wait before the next attempt: the API's hint when it gave one, capped at a minute,
53
+ * otherwise a short backoff with jitter so that clients refused together do not return together.
54
+ */
55
+ export declare function retryDelay(retryAfterSeconds: number | null, attempt: number): number;
@@ -0,0 +1,201 @@
1
+ import { FALLBACK_CODE_BY_STATUS, FALLBACK_CODE_FOR_OTHER_STATUS, RETRYABLE_CODES, } from "./error-codes.js";
2
+ import { ConnectionError, WebsiteScreenshotWorldError, errorFor, } from "./errors.js";
3
+ const MAX_RETRY_AFTER_SECONDS = 60;
4
+ const BACKOFF_MS = [500, 1000];
5
+ const JITTER = 0.25;
6
+ // setTimeout fires at once for anything longer, so such a timeout means "no timeout".
7
+ const MAX_TIMER_MS = 2 ** 31 - 1;
8
+ /** Sends calls with the shared headers, timeouts and retries. */
9
+ export class Transport {
10
+ #options;
11
+ constructor(options) {
12
+ this.#options = options;
13
+ }
14
+ /** Makes the call and parses its JSON answer. */
15
+ json(call) {
16
+ return this.#send(call, async (response) => {
17
+ const text = await response.text();
18
+ try {
19
+ return JSON.parse(text);
20
+ }
21
+ catch (cause) {
22
+ throw new WebsiteScreenshotWorldError("The API answered with a body that is not JSON.", { httpStatus: response.status, rawBody: text, cause });
23
+ }
24
+ });
25
+ }
26
+ /** Makes the call and returns the answer as soon as its headers arrive, with the body unread. */
27
+ file(call) {
28
+ return this.#send(call, (response) => Promise.resolve(response));
29
+ }
30
+ async #send(call, read) {
31
+ const deadline = new Deadline(call.timeout, call.signal);
32
+ try {
33
+ const init = this.#requestInit(call, deadline.signal);
34
+ for (let attempt = 0;; attempt++) {
35
+ const outcome = await this.#attempt(call.url, init, read, deadline);
36
+ if (outcome.done)
37
+ return outcome.value;
38
+ const delay = retryDelay(outcome.error.retryAfterSeconds, attempt);
39
+ const retry = outcome.retryable &&
40
+ attempt < this.#options.maxRetries &&
41
+ deadline.allows(delay);
42
+ if (!retry)
43
+ throw outcome.error;
44
+ await deadline.sleep(delay);
45
+ }
46
+ }
47
+ finally {
48
+ deadline.end();
49
+ }
50
+ }
51
+ async #attempt(url, init, read, deadline) {
52
+ try {
53
+ const fetch = this.#options.fetch ?? globalThis.fetch;
54
+ const response = await fetch(url, init);
55
+ if (response.ok)
56
+ return { done: true, value: await read(response) };
57
+ const error = await refusalFrom(response);
58
+ return {
59
+ done: false,
60
+ error,
61
+ retryable: RETRYABLE_CODES.has(error.errorCode ?? ""),
62
+ };
63
+ }
64
+ catch (cause) {
65
+ deadline.throwIfStopped();
66
+ if (cause instanceof WebsiteScreenshotWorldError)
67
+ throw cause;
68
+ const error = new ConnectionError(`The request to ${new URL(url).origin} failed before an answer came back.`, { cause });
69
+ return { done: false, error, retryable: true };
70
+ }
71
+ }
72
+ #requestInit(call, signal) {
73
+ const headers = {
74
+ "User-Agent": this.#options.userAgent,
75
+ };
76
+ if (call.apiKey !== undefined)
77
+ headers["X-Api-Key"] = call.apiKey;
78
+ if (call.file !== true)
79
+ headers.Accept = "application/json";
80
+ if (call.body !== undefined)
81
+ headers["Content-Type"] = "application/json";
82
+ if (call.idempotencyKey !== undefined) {
83
+ headers["Idempotency-Key"] = call.idempotencyKey;
84
+ }
85
+ /* Redirects are never followed (code-review plan 7.4, 28 Oct 2026): fetch would send the
86
+ X-Api-Key to wherever a 3xx pointed. The API never redirects, and neither does the download
87
+ link, so a 3xx is answered as the refusal it is - "manual", not "error", which would surface
88
+ as a ConnectionError and be retried. */
89
+ const init = {
90
+ method: call.method,
91
+ headers,
92
+ signal,
93
+ redirect: "manual",
94
+ };
95
+ if (call.body !== undefined)
96
+ init.body = JSON.stringify(call.body);
97
+ return init;
98
+ }
99
+ }
100
+ /**
101
+ * How long to wait before the next attempt: the API's hint when it gave one, capped at a minute,
102
+ * otherwise a short backoff with jitter so that clients refused together do not return together.
103
+ */
104
+ export function retryDelay(retryAfterSeconds, attempt) {
105
+ if (retryAfterSeconds !== null) {
106
+ return Math.min(retryAfterSeconds, MAX_RETRY_AFTER_SECONDS) * 1000;
107
+ }
108
+ const base = BACKOFF_MS[Math.min(attempt, BACKOFF_MS.length - 1)] ?? 1000;
109
+ return base * (1 + Math.random() * JITTER);
110
+ }
111
+ async function refusalFrom(response) {
112
+ const rawBody = await response.text();
113
+ const body = errorBodyFrom(rawBody);
114
+ const errorCode = body?.error_code ??
115
+ FALLBACK_CODE_BY_STATUS[response.status] ??
116
+ FALLBACK_CODE_FOR_OTHER_STATUS;
117
+ const message = typeof body?.error_message === "string"
118
+ ? body.error_message
119
+ : `The API answered with HTTP ${String(response.status)} and no error details.`;
120
+ const retryAfterSeconds = typeof body?.retry_after_seconds === "number"
121
+ ? body.retry_after_seconds
122
+ : retryAfterHeader(response.headers);
123
+ return errorFor(errorCode, message, {
124
+ httpStatus: response.status,
125
+ retryAfterSeconds,
126
+ rawBody,
127
+ });
128
+ }
129
+ function errorBodyFrom(text) {
130
+ let value;
131
+ try {
132
+ value = JSON.parse(text);
133
+ }
134
+ catch {
135
+ return null;
136
+ }
137
+ const isErrorBody = typeof value === "object" &&
138
+ value !== null &&
139
+ typeof value.error_code === "string";
140
+ return isErrorBody ? value : null;
141
+ }
142
+ function retryAfterHeader(headers) {
143
+ const value = headers.get("retry-after")?.trim();
144
+ return value !== undefined && /^\d+$/.test(value) ? Number(value) : null;
145
+ }
146
+ /** Ends a call when its timeout passes or the caller's signal aborts, whichever comes first. */
147
+ class Deadline {
148
+ #controller = new AbortController();
149
+ #expiresAt;
150
+ #timer;
151
+ #callerSignal;
152
+ #onCallerAbort = () => {
153
+ this.#controller.abort(this.#callerSignal?.reason);
154
+ };
155
+ constructor(timeout, callerSignal) {
156
+ callerSignal?.throwIfAborted();
157
+ this.#callerSignal = callerSignal;
158
+ this.#expiresAt = performance.now() + timeout;
159
+ callerSignal?.addEventListener("abort", this.#onCallerAbort, {
160
+ once: true,
161
+ });
162
+ if (timeout <= MAX_TIMER_MS) {
163
+ this.#timer = setTimeout(() => {
164
+ this.#controller.abort(new ConnectionError(`The call did not finish within ${String(timeout / 1000)} seconds.`));
165
+ }, timeout);
166
+ }
167
+ }
168
+ get signal() {
169
+ return this.#controller.signal;
170
+ }
171
+ /** Whether waiting `milliseconds` still leaves time for another attempt. */
172
+ allows(milliseconds) {
173
+ return performance.now() + milliseconds < this.#expiresAt;
174
+ }
175
+ /** Throws the reason the call was stopped, if it was. */
176
+ throwIfStopped() {
177
+ this.#controller.signal.throwIfAborted();
178
+ }
179
+ sleep(milliseconds) {
180
+ const signal = this.#controller.signal;
181
+ return new Promise((resolve, reject) => {
182
+ if (signal.aborted) {
183
+ reject(signal.reason);
184
+ return;
185
+ }
186
+ const onAbort = () => {
187
+ clearTimeout(timer);
188
+ reject(signal.reason);
189
+ };
190
+ const timer = setTimeout(() => {
191
+ signal.removeEventListener("abort", onAbort);
192
+ resolve();
193
+ }, milliseconds);
194
+ signal.addEventListener("abort", onAbort, { once: true });
195
+ });
196
+ }
197
+ end() {
198
+ clearTimeout(this.#timer);
199
+ this.#callerSignal?.removeEventListener("abort", this.#onCallerAbort);
200
+ }
201
+ }
@@ -0,0 +1,2 @@
1
+ /** The version of this library. Kept equal to `version` in package.json. */
2
+ export declare const VERSION = "0.1.0";
@@ -0,0 +1,2 @@
1
+ /** The version of this library. Kept equal to `version` in package.json. */
2
+ export const VERSION = "0.1.0";
@@ -0,0 +1,128 @@
1
+ import type { CaptureFields, PageSource, S3Delivery } from "../models/capture.js";
2
+ import type { CaptureFormat, MediaCategory, PageSourceFormat, Product } from "../models/tokens.js";
3
+ /** The event types this version knows. */
4
+ export declare const WebhookEventType: {
5
+ readonly CaptureSucceeded: "capture.succeeded";
6
+ readonly CaptureFailed: "capture.failed";
7
+ readonly DeliveryCompleted: "delivery.completed";
8
+ readonly CaptureChanged: "capture.changed";
9
+ readonly SchedulePaused: "schedule.paused";
10
+ readonly SitemapRunCompleted: "sitemap_run.completed";
11
+ readonly Ping: "ping";
12
+ };
13
+ export type WebhookEventType = (typeof WebhookEventType)[keyof typeof WebhookEventType];
14
+ interface EventEnvelope<Type extends string, EventProduct, Data> {
15
+ /** The delivery id; the same on every retry of this delivery. */
16
+ id: string;
17
+ type: Type;
18
+ createdAt: Date;
19
+ product: EventProduct;
20
+ data: Data;
21
+ }
22
+ /** An Instant Snap capture as its webhook carries it: no download links or access key. */
23
+ export interface InstantSnapCaptureData extends CaptureFields {
24
+ pageSource: Omit<PageSource, "downloadUrl"> | null;
25
+ s3Delivery: Omit<S3Delivery, "accessKeyId"> | null;
26
+ }
27
+ /** A scheduled web page's capture. */
28
+ export interface ScheduledCapture {
29
+ id: string;
30
+ /** The web page's id; `autoSchedule.triggerNow` calls it `webPageId`. */
31
+ scheduleId: string;
32
+ scheduleName: string;
33
+ url: string;
34
+ status: string;
35
+ mediaCategory: MediaCategory;
36
+ format: CaptureFormat | null;
37
+ pageHttpStatus: number | null;
38
+ capturedAt: Date | null;
39
+ finalUrl: string | null;
40
+ mediaSha256: string | null;
41
+ pageSource: {
42
+ format: PageSourceFormat;
43
+ sha256: string | null;
44
+ } | null;
45
+ /** Set when the capture was part of a sitemap run. */
46
+ sitemapRunId: string | null;
47
+ }
48
+ /** One place a scheduled capture was delivered to. */
49
+ export interface Destination {
50
+ slot: number;
51
+ name: string;
52
+ provider: string;
53
+ status: string;
54
+ completedAt: Date | null;
55
+ }
56
+ export interface DeliveryCompletedData extends ScheduledCapture {
57
+ destinations: Destination[];
58
+ }
59
+ export interface CaptureChangedData extends ScheduledCapture {
60
+ /** How much of the image changed since the capture it was compared with. */
61
+ changePercent: number | null;
62
+ comparedToCaptureId: string | null;
63
+ changeImageAvailable: boolean;
64
+ }
65
+ export interface SchedulePausedData {
66
+ scheduleId: string;
67
+ scheduleName: string;
68
+ url: string;
69
+ /** Why the schedule stopped, such as `quota_exhausted`. */
70
+ reason: string;
71
+ pausedAt: Date | null;
72
+ }
73
+ export interface SitemapRunCompletedData {
74
+ sitemapRunId: string;
75
+ scheduleId: string;
76
+ scheduleName: string;
77
+ sitemapUrl: string;
78
+ status: string;
79
+ runAt: Date | null;
80
+ completedAt: Date | null;
81
+ pagesListed: number | null;
82
+ pagesMatched: number | null;
83
+ pagesQueued: number | null;
84
+ pagesSucceeded: number | null;
85
+ pagesFailed: number | null;
86
+ pagesOverLimit: number | null;
87
+ pagesOverQuota: number | null;
88
+ message: string | null;
89
+ }
90
+ export interface PingData {
91
+ endpointId: string;
92
+ endpointName: string;
93
+ message: string;
94
+ sentAt: Date | null;
95
+ }
96
+ type CaptureOutcome = "capture.succeeded" | "capture.failed";
97
+ /** An Instant Snap capture finished. */
98
+ export type InstantSnapCaptureEvent = EventEnvelope<CaptureOutcome, "instant_snap", InstantSnapCaptureData>;
99
+ /** A scheduled web page's capture finished. */
100
+ export type AutoScheduleCaptureEvent = EventEnvelope<CaptureOutcome, "auto_schedule", ScheduledCapture>;
101
+ /** A scheduled capture reached all of its destinations. */
102
+ export type DeliveryCompletedEvent = EventEnvelope<"delivery.completed", "auto_schedule", DeliveryCompletedData>;
103
+ /** A scheduled capture differs from the one before it. */
104
+ export type CaptureChangedEvent = EventEnvelope<"capture.changed", "auto_schedule", CaptureChangedData>;
105
+ /** A schedule stopped running. */
106
+ export type SchedulePausedEvent = EventEnvelope<"schedule.paused", "auto_schedule", SchedulePausedData>;
107
+ /** Every page of a sitemap run has been captured or skipped. */
108
+ export type SitemapRunCompletedEvent = EventEnvelope<"sitemap_run.completed", "auto_schedule", SitemapRunCompletedData>;
109
+ /** A test delivery sent from the dashboard, by either product. */
110
+ export type PingEvent = EventEnvelope<"ping", Product, PingData>;
111
+ /**
112
+ * An event type this version does not know yet, with its payload as sent in `rawData`.
113
+ *
114
+ * Its `data` is never set: that keeps `event.data` precisely typed after `switch (event.type)`
115
+ * narrows to a known type, since TypeScript cannot exclude one string from a `string` type.
116
+ */
117
+ export interface UnknownWebhookEvent {
118
+ id: string;
119
+ type: string;
120
+ createdAt: Date;
121
+ product: string;
122
+ data: never;
123
+ /** The event's `data` exactly as sent, with snake_case keys. */
124
+ rawData: Record<string, unknown>;
125
+ }
126
+ /** Every event `Webhook.verify` returns, told apart by `type` and, for captures, `product`. */
127
+ export type WebhookEvent = InstantSnapCaptureEvent | AutoScheduleCaptureEvent | DeliveryCompletedEvent | CaptureChangedEvent | SchedulePausedEvent | SitemapRunCompletedEvent | PingEvent | UnknownWebhookEvent;
128
+ export {};
@@ -0,0 +1,10 @@
1
+ /** The event types this version knows. */
2
+ export const WebhookEventType = {
3
+ CaptureSucceeded: "capture.succeeded",
4
+ CaptureFailed: "capture.failed",
5
+ DeliveryCompleted: "delivery.completed",
6
+ CaptureChanged: "capture.changed",
7
+ SchedulePaused: "schedule.paused",
8
+ SitemapRunCompleted: "sitemap_run.completed",
9
+ Ping: "ping",
10
+ };
@@ -0,0 +1,10 @@
1
+ import type { WebhookEvent } from "./events.js";
2
+ export interface WireEnvelope {
3
+ id: string;
4
+ type: string;
5
+ created_at: string;
6
+ product: string;
7
+ data: unknown;
8
+ }
9
+ /** Turns a verified webhook body into its typed event. */
10
+ export declare function parseEvent(wire: WireEnvelope): WebhookEvent;
@@ -0,0 +1,146 @@
1
+ import { parseCaptureFields, parsePageSourceFields, parseS3DeliveryFields, } from "../models/capture.js";
2
+ import { parseDate } from "../wire.js";
3
+ /** Turns a verified webhook body into its typed event. */
4
+ export function parseEvent(wire) {
5
+ const { type, product, data } = wire;
6
+ const envelope = { id: wire.id, createdAt: parseDate(wire.created_at) };
7
+ if (type === "capture.succeeded" || type === "capture.failed") {
8
+ if (product === "instant_snap") {
9
+ const capture = instantSnapCapture(data);
10
+ return { ...envelope, type, product, data: capture };
11
+ }
12
+ if (product === "auto_schedule") {
13
+ const capture = scheduledCapture(data);
14
+ return { ...envelope, type, product, data: capture };
15
+ }
16
+ }
17
+ switch (type) {
18
+ case "delivery.completed":
19
+ return {
20
+ ...envelope,
21
+ type,
22
+ product: "auto_schedule",
23
+ data: deliveryCompleted(data),
24
+ };
25
+ case "capture.changed":
26
+ return {
27
+ ...envelope,
28
+ type,
29
+ product: "auto_schedule",
30
+ data: captureChanged(data),
31
+ };
32
+ case "schedule.paused":
33
+ return {
34
+ ...envelope,
35
+ type,
36
+ product: "auto_schedule",
37
+ data: schedulePaused(data),
38
+ };
39
+ case "sitemap_run.completed":
40
+ return {
41
+ ...envelope,
42
+ type,
43
+ product: "auto_schedule",
44
+ data: sitemapRunCompleted(data),
45
+ };
46
+ case "ping":
47
+ return { ...envelope, type, product, data: ping(data) };
48
+ default:
49
+ // Includes a known capture type from a product this version does not know.
50
+ return {
51
+ ...envelope,
52
+ type,
53
+ product,
54
+ rawData: data,
55
+ };
56
+ }
57
+ }
58
+ function instantSnapCapture(wire) {
59
+ return {
60
+ ...parseCaptureFields(wire),
61
+ pageSource: wire.page_source
62
+ ? parsePageSourceFields(wire.page_source)
63
+ : null,
64
+ s3Delivery: wire.s3_delivery
65
+ ? parseS3DeliveryFields(wire.s3_delivery)
66
+ : null,
67
+ };
68
+ }
69
+ function scheduledCapture(wire) {
70
+ return {
71
+ id: wire.id,
72
+ scheduleId: wire.schedule_id,
73
+ scheduleName: wire.schedule_name,
74
+ url: wire.url,
75
+ status: wire.status,
76
+ mediaCategory: wire.media_category,
77
+ format: wire.format ?? null,
78
+ pageHttpStatus: wire.page_http_status ?? null,
79
+ capturedAt: parseDate(wire.captured_at),
80
+ finalUrl: wire.final_url ?? null,
81
+ mediaSha256: wire.media_sha256 ?? null,
82
+ pageSource: wire.page_source
83
+ ? {
84
+ format: wire.page_source.format,
85
+ sha256: wire.page_source.sha256 ?? null,
86
+ }
87
+ : null,
88
+ sitemapRunId: wire.sitemap_run_id ?? null,
89
+ };
90
+ }
91
+ function deliveryCompleted(wire) {
92
+ return {
93
+ ...scheduledCapture(wire),
94
+ destinations: (wire.destinations ?? []).map((destination) => ({
95
+ slot: destination.slot,
96
+ name: destination.name,
97
+ provider: destination.provider,
98
+ status: destination.status,
99
+ completedAt: parseDate(destination.completed_at),
100
+ })),
101
+ };
102
+ }
103
+ function captureChanged(wire) {
104
+ return {
105
+ ...scheduledCapture(wire),
106
+ changePercent: wire.change_percent ?? null,
107
+ comparedToCaptureId: wire.compared_to_capture_id ?? null,
108
+ changeImageAvailable: wire.change_image_available ?? false,
109
+ };
110
+ }
111
+ function schedulePaused(wire) {
112
+ return {
113
+ scheduleId: wire.schedule_id,
114
+ scheduleName: wire.schedule_name,
115
+ url: wire.url,
116
+ reason: wire.reason,
117
+ pausedAt: parseDate(wire.paused_at),
118
+ };
119
+ }
120
+ function sitemapRunCompleted(wire) {
121
+ return {
122
+ sitemapRunId: wire.sitemap_run_id,
123
+ scheduleId: wire.schedule_id,
124
+ scheduleName: wire.schedule_name,
125
+ sitemapUrl: wire.sitemap_url,
126
+ status: wire.status,
127
+ runAt: parseDate(wire.run_at),
128
+ completedAt: parseDate(wire.completed_at),
129
+ pagesListed: wire.pages_listed ?? null,
130
+ pagesMatched: wire.pages_matched ?? null,
131
+ pagesQueued: wire.pages_queued ?? null,
132
+ pagesSucceeded: wire.pages_succeeded ?? null,
133
+ pagesFailed: wire.pages_failed ?? null,
134
+ pagesOverLimit: wire.pages_over_limit ?? null,
135
+ pagesOverQuota: wire.pages_over_quota ?? null,
136
+ message: wire.message ?? null,
137
+ };
138
+ }
139
+ function ping(wire) {
140
+ return {
141
+ endpointId: wire.endpoint_id,
142
+ endpointName: wire.endpoint_name,
143
+ message: wire.message,
144
+ sentAt: parseDate(wire.sent_at),
145
+ };
146
+ }
@@ -0,0 +1,26 @@
1
+ import type { WebhookEvent } from "./events.js";
2
+ /** Options for {@link Webhook.verify}. */
3
+ export interface VerifyOptions {
4
+ /** How far the signature's timestamp may be from now, in seconds. Default 300. */
5
+ tolerance?: number;
6
+ /** The time to check the timestamp against. Defaults to the current time. */
7
+ now?: Date;
8
+ }
9
+ /**
10
+ * Checks a webhook delivery's `X-WSW-Signature` header against its raw body and returns the event.
11
+ *
12
+ * Pass the body exactly as received. A framework that parses the JSON and serialises it again
13
+ * changes the bytes, and the signature no longer matches.
14
+ *
15
+ * @param payload - The raw request body.
16
+ * @param signatureHeader - The `X-WSW-Signature` header.
17
+ * @param secret - The endpoint's signing secret, starting `whsec_`.
18
+ * @throws {@link SignatureVerificationError} when the header is missing or malformed, the timestamp
19
+ * is outside the tolerance, or no signature matches.
20
+ */
21
+ declare function verify(payload: string | Uint8Array, signatureHeader: string | readonly string[] | null | undefined, secret: string, options?: VerifyOptions): WebhookEvent;
22
+ /** Verifies webhook deliveries from both products. */
23
+ export declare const Webhook: {
24
+ readonly verify: typeof verify;
25
+ };
26
+ export {};
@@ -0,0 +1,75 @@
1
+ import { createHmac, timingSafeEqual } from "node:crypto";
2
+ import { SignatureVerificationError, WebsiteScreenshotWorldError, } from "../errors.js";
3
+ import { parseEvent } from "./parse-event.js";
4
+ const DEFAULT_TOLERANCE_SECONDS = 300;
5
+ const SIGNATURE_HEX = /^[0-9a-f]{64}$/;
6
+ /**
7
+ * Checks a webhook delivery's `X-WSW-Signature` header against its raw body and returns the event.
8
+ *
9
+ * Pass the body exactly as received. A framework that parses the JSON and serialises it again
10
+ * changes the bytes, and the signature no longer matches.
11
+ *
12
+ * @param payload - The raw request body.
13
+ * @param signatureHeader - The `X-WSW-Signature` header.
14
+ * @param secret - The endpoint's signing secret, starting `whsec_`.
15
+ * @throws {@link SignatureVerificationError} when the header is missing or malformed, the timestamp
16
+ * is outside the tolerance, or no signature matches.
17
+ */
18
+ function verify(payload, signatureHeader, secret, options = {}) {
19
+ const header = parseSignatureHeader(typeof signatureHeader === "string"
20
+ ? signatureHeader
21
+ : (signatureHeader ?? []).join(","));
22
+ if (header.timestamp === null) {
23
+ throw new SignatureVerificationError("The signature header has no valid timestamp.");
24
+ }
25
+ if (header.signatures.length === 0) {
26
+ throw new SignatureVerificationError("The signature header has no v1 signature.");
27
+ }
28
+ const tolerance = options.tolerance ?? DEFAULT_TOLERANCE_SECONDS;
29
+ const now = Math.floor((options.now ?? new Date()).getTime() / 1000);
30
+ if (Math.abs(now - header.timestamp) > tolerance) {
31
+ throw new SignatureVerificationError("The signature's timestamp is outside the tolerance.");
32
+ }
33
+ const body = typeof payload === "string" ? Buffer.from(payload, "utf8") : payload;
34
+ const expected = Buffer.from(createHmac("sha256", Buffer.from(secret, "utf8"))
35
+ .update(`${String(header.timestamp)}.`, "utf8")
36
+ .update(body)
37
+ .digest("hex"), "utf8");
38
+ const matches = header.signatures.some((signature) => timingSafeEqual(expected, Buffer.from(signature, "utf8")));
39
+ if (!matches) {
40
+ throw new SignatureVerificationError("No signature in the header matches the body.");
41
+ }
42
+ return parseEvent(parseBody(new TextDecoder().decode(body)));
43
+ }
44
+ function parseSignatureHeader(value) {
45
+ const header = { timestamp: null, signatures: [] };
46
+ let sawTimestamp = false;
47
+ for (const part of value.split(",")) {
48
+ const separator = part.indexOf("=");
49
+ if (separator === -1)
50
+ continue;
51
+ const name = part.slice(0, separator).trim();
52
+ const content = part.slice(separator + 1).trim();
53
+ if (name === "t" && !sawTimestamp) {
54
+ sawTimestamp = true;
55
+ header.timestamp = /^\d+$/.test(content) ? Number(content) : null;
56
+ }
57
+ else if (name === "v1") {
58
+ const signature = content.toLowerCase();
59
+ // A value that cannot be a digest is skipped so the comparison always sees equal lengths.
60
+ if (SIGNATURE_HEX.test(signature))
61
+ header.signatures.push(signature);
62
+ }
63
+ }
64
+ return header;
65
+ }
66
+ function parseBody(text) {
67
+ try {
68
+ return JSON.parse(text);
69
+ }
70
+ catch (cause) {
71
+ throw new WebsiteScreenshotWorldError("The webhook body is signed but is not JSON.", { cause });
72
+ }
73
+ }
74
+ /** Verifies webhook deliveries from both products. */
75
+ export const Webhook = { verify };
@@ -0,0 +1,15 @@
1
+ /** A JSON object as the API sends or receives it, with snake_case keys. */
2
+ export type WireObject = Record<string, unknown>;
3
+ /**
4
+ * Turns camelCase options into the API's snake_case body. Options left `undefined` or `null` are not
5
+ * sent; `false`, `0` and `""` are.
6
+ */
7
+ export declare function toWire(options: object): WireObject;
8
+ /** Re-cases the keys of an object the library does not model field by field. */
9
+ export declare function camelCaseKeys(value: unknown): unknown;
10
+ /**
11
+ * Parses an ISO 8601 date-time with any number of fraction digits and either `Z` or an offset.
12
+ * Returns `null` for `null` or a missing value.
13
+ */
14
+ export declare function parseDate(value: string): Date;
15
+ export declare function parseDate(value: string | null | undefined): Date | null;