@scrapa/sdk 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.
package/src/client.ts ADDED
@@ -0,0 +1,227 @@
1
+ // The transport under every generated method: one request, one `{ data }` unwrap, one error
2
+ // taxonomy.
3
+ //
4
+ // The retry policy is the opinionated part, and it is narrow on purpose. A scrape that we
5
+ // broke was already refunded, so repeating it costs nothing; a rate limit says when it
6
+ // clears. But a call that timed out on YOUR side may well have answered on ours — repeating
7
+ // it reads the page twice and charges twice — so the client hands that one back with its
8
+ // code and lets you decide. The default timeout sits above the API's own ceiling, which is
9
+ // what makes a client-side timeout genuinely exceptional rather than routine.
10
+ import { ScrapaError, type ClientErrorCode } from "./errors.ts";
11
+ import { HEADERS, type ErrorCode, type UsageMeta } from "./generated/contract.ts";
12
+ import { DEFAULT_BASE_URL, GeneratedOperations, SDK_VERSION } from "./generated/operations.ts";
13
+
14
+ export interface ScrapaOptions {
15
+ /** An `sc_` key from the console. */
16
+ apiKey: string;
17
+ /** Defaults to the hosted API. */
18
+ baseUrl?: string;
19
+ /** Per-attempt timeout in ms. Default 65000 — above the API's own 60s ceiling for a
20
+ * scrape, so this fires only when something is genuinely wrong. */
21
+ timeout?: number;
22
+ /** Extra attempts for the failures a repeat can fix. Default 2, 0 disables. */
23
+ maxRetries?: number;
24
+ /** Swap the fetch implementation (tests, a proxy, a custom agent). */
25
+ fetch?: typeof globalThis.fetch;
26
+ }
27
+
28
+ /** What a generated method hands the transport. */
29
+ export interface RequestSpec {
30
+ method: "GET" | "POST" | "DELETE";
31
+ /** `/sessions/{id}` — a `{name}` is filled from the params and dropped from the rest. */
32
+ path: string;
33
+ /** Safe to repeat: a refunded failure can be retried without charging twice. */
34
+ idempotent: boolean;
35
+ }
36
+
37
+ /** Per-call overrides. */
38
+ export interface RequestOptions {
39
+ /** Abort this call — yours to control, and the API stops paying for egress when it fires. */
40
+ signal?: AbortSignal;
41
+ /** Override the client's timeout for one call. */
42
+ timeout?: number;
43
+ /** What the call cost and what is left, as the answer carries it. A method returns the
44
+ * page itself, so this is where the meter goes for the callers who want it — per call,
45
+ * rather than a mutable `lastUsage` that two concurrent scrapes would fight over. */
46
+ onUsage?: (usage: UsageMeta) => void;
47
+ }
48
+
49
+ /** A Retry-After we would honour beyond this is not a retry, it is a hang: the caller gets
50
+ * the error (with `retryAfter` on it) and schedules it themselves. */
51
+ const MAX_RETRY_DELAY_MS = 10_000;
52
+
53
+ /** What the API answers with when it never got to answer at all. */
54
+ function statusCode(status: number): ErrorCode {
55
+ if (status === 503) return "SERVICE_UNAVAILABLE";
56
+ if (status === 404) return "NOT_FOUND";
57
+ if (status === 401) return "UNAUTHORIZED";
58
+ return "INTERNAL_ERROR";
59
+ }
60
+
61
+ /** Seconds from `Retry-After`, or from the error's own details. */
62
+ function retryAfterOf(header: string | null, details: unknown): number | undefined {
63
+ const fromDetails =
64
+ typeof details === "object" && details !== null
65
+ ? (details as { retryAfterSecs?: unknown }).retryAfterSecs
66
+ : undefined;
67
+ if (typeof fromDetails === "number" && Number.isFinite(fromDetails)) return fromDetails;
68
+ const seconds = header === null ? NaN : Number(header);
69
+ return Number.isFinite(seconds) ? seconds : undefined;
70
+ }
71
+
72
+ function numberHeader(response: Response, name: string): number | undefined {
73
+ const raw = response.headers.get(name);
74
+ if (raw === null) return undefined;
75
+ const value = Number(raw);
76
+ return Number.isFinite(value) ? value : undefined;
77
+ }
78
+
79
+ const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
80
+
81
+ export class Scrapa extends GeneratedOperations {
82
+ private readonly apiKey: string;
83
+ private readonly baseUrl: string;
84
+ private readonly timeout: number;
85
+ private readonly maxRetries: number;
86
+ private readonly fetchImpl: typeof globalThis.fetch;
87
+
88
+ constructor(options: ScrapaOptions | string) {
89
+ super();
90
+ const opts = typeof options === "string" ? { apiKey: options } : options;
91
+ if (!opts.apiKey)
92
+ throw new TypeError(
93
+ "scrapa: an API key is required — create one at https://console.scrapa.app/keys",
94
+ );
95
+ this.apiKey = opts.apiKey;
96
+ // A trailing slash would double up against a path's leading one.
97
+ this.baseUrl = (opts.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
98
+ this.timeout = opts.timeout ?? 65_000;
99
+ this.maxRetries = opts.maxRetries ?? 2;
100
+ const impl = opts.fetch ?? globalThis.fetch;
101
+ if (typeof impl !== "function")
102
+ throw new TypeError(
103
+ "scrapa: no global fetch — use Node 20+, or pass `fetch` in the options.",
104
+ );
105
+ // Bound: an unbound global fetch throws "Illegal invocation" in browsers.
106
+ this.fetchImpl = opts.fetch ?? impl.bind(globalThis);
107
+ }
108
+
109
+ protected async request<T>(
110
+ spec: RequestSpec,
111
+ params: object = {},
112
+ opts: RequestOptions = {},
113
+ ): Promise<T> {
114
+ const retries = spec.idempotent ? this.maxRetries : 0;
115
+ for (let attempt = 0; ; attempt++) {
116
+ try {
117
+ return await this.attempt<T>(spec, params, opts);
118
+ } catch (error) {
119
+ if (!(error instanceof ScrapaError) || attempt >= retries) throw error;
120
+ const delay = this.retryDelay(error, attempt);
121
+ if (delay === undefined) throw error;
122
+ await sleep(delay);
123
+ }
124
+ }
125
+ }
126
+
127
+ /** How long to wait before repeating a call, or undefined to give up now. */
128
+ private retryDelay(error: ScrapaError, attempt: number): number | undefined {
129
+ // A timeout on OUR side may have answered on theirs; repeating it would read the
130
+ // page — and charge for it — twice. The caller gets the error and decides.
131
+ const worthRepeating = error.retryable && error.code !== "TIMEOUT";
132
+ if (!worthRepeating) return undefined;
133
+ if (error.retryAfter !== undefined) {
134
+ const ms = error.retryAfter * 1_000;
135
+ return ms > MAX_RETRY_DELAY_MS ? undefined : ms;
136
+ }
137
+ // Exponential with jitter, so a fleet of clients does not resynchronise onto the
138
+ // same second after an outage.
139
+ return Math.min(250 * 2 ** attempt, 4_000) * (0.5 + Math.random());
140
+ }
141
+
142
+ private async attempt<T>(spec: RequestSpec, params: object, opts: RequestOptions): Promise<T> {
143
+ const rest: Record<string, unknown> = { ...params };
144
+ // A templated segment is spent on the URL, not sent again as a query parameter.
145
+ const path = spec.path.replace(/\{(\w+)\}/g, (_, key: string) => {
146
+ const value = rest[key];
147
+ delete rest[key];
148
+ return encodeURIComponent(String(value ?? ""));
149
+ });
150
+ const url = new URL(this.baseUrl + path);
151
+ const hasBody = spec.method === "POST";
152
+ if (!hasBody)
153
+ for (const [key, value] of Object.entries(rest))
154
+ if (value !== undefined && value !== null) url.searchParams.set(key, String(value));
155
+
156
+ const headers: Record<string, string> = {
157
+ authorization: `Bearer ${this.apiKey}`,
158
+ accept: "application/json",
159
+ [HEADERS.client]: `scrapa-js/${SDK_VERSION}`,
160
+ };
161
+ if (hasBody) headers["content-type"] = "application/json";
162
+
163
+ const timeout = AbortSignal.timeout(opts.timeout ?? this.timeout);
164
+ let response: Response;
165
+ try {
166
+ response = await this.fetchImpl(url.toString(), {
167
+ method: spec.method,
168
+ headers,
169
+ ...(hasBody ? { body: JSON.stringify(rest) } : undefined),
170
+ signal: opts.signal ? AbortSignal.any([opts.signal, timeout]) : timeout,
171
+ });
172
+ } catch (cause) {
173
+ // The caller's own abort is theirs, not a failure of ours to describe.
174
+ if (opts.signal?.aborted) throw cause;
175
+ const timedOut = cause instanceof Error && cause.name === "TimeoutError";
176
+ throw new ScrapaError({
177
+ code: (timedOut ? "TIMEOUT" : "NETWORK_ERROR") satisfies ClientErrorCode,
178
+ status: 0,
179
+ message: timedOut
180
+ ? `scrapa: no answer within ${opts.timeout ?? this.timeout}ms — the page may still have been read and charged.`
181
+ : `scrapa: could not reach ${url.host}.`,
182
+ cause,
183
+ });
184
+ }
185
+
186
+ const requestId = response.headers.get(HEADERS.requestId) ?? undefined;
187
+ const text = await response.text();
188
+ let payload: unknown;
189
+ try {
190
+ payload = text ? (JSON.parse(text) as unknown) : undefined;
191
+ } catch {
192
+ payload = undefined;
193
+ }
194
+
195
+ if (response.ok) {
196
+ const answer = payload as { data: T; meta?: UsageMeta };
197
+ if (opts.onUsage && answer.meta) opts.onUsage(answer.meta);
198
+ return answer.data;
199
+ }
200
+
201
+ const envelope =
202
+ typeof payload === "object" && payload !== null
203
+ ? (payload as { error?: Record<string, unknown> }).error
204
+ : undefined;
205
+ const details = envelope?.details;
206
+ throw new ScrapaError({
207
+ code: (envelope?.code as ErrorCode | undefined) ?? statusCode(response.status),
208
+ status: response.status,
209
+ // A proxy or a cold start can answer HTML; say what actually came back rather
210
+ // than pretending the body was ours.
211
+ message:
212
+ typeof envelope?.message === "string"
213
+ ? envelope.message
214
+ : `scrapa: ${spec.method} ${spec.path} failed with ${response.status}.`,
215
+ messageKey: typeof envelope?.messageKey === "string" ? envelope.messageKey : undefined,
216
+ params: envelope?.params as Record<string, string | number> | undefined,
217
+ details: envelope ? details : text.slice(0, 500) || undefined,
218
+ retryAfter: retryAfterOf(response.headers.get(HEADERS.retryAfter), details),
219
+ requestId,
220
+ // Every metered answer says what it cost, refusals included: a blocked page
221
+ // reports 0, and that is how "blocked pages cost nothing" is verifiable rather
222
+ // than asserted.
223
+ cost: numberHeader(response, HEADERS.cost),
224
+ creditsRemaining: numberHeader(response, HEADERS.creditsRemaining),
225
+ });
226
+ }
227
+ }
package/src/errors.ts ADDED
@@ -0,0 +1,83 @@
1
+ // Every failure this client can raise, as one class.
2
+ //
3
+ // The API answers a refusal with a machine-readable code, and acting on the code is the
4
+ // whole point: it says WHO said no, which is what decides the caller's next move. The site
5
+ // blocked the page (try another engine, pool or country), our policy refused the URL
6
+ // (retrying cannot help), a limit needs waiting out, or we broke it (retry as-is, it was
7
+ // refunded). Collapse that into `Error("Request failed")` and the caller is left guessing.
8
+ import { OUTCOME_OF, type ErrorCode } from "./generated/contract.ts";
9
+
10
+ /** Failures that never reached the API, so they carry no server code. */
11
+ export type ClientErrorCode = "NETWORK_ERROR" | "TIMEOUT";
12
+
13
+ export interface ScrapaErrorInit {
14
+ code: ErrorCode | ClientErrorCode;
15
+ message: string;
16
+ /** HTTP status, or 0 when no answer arrived. */
17
+ status: number;
18
+ messageKey?: string;
19
+ params?: Record<string, string | number>;
20
+ details?: unknown;
21
+ /** Seconds until the call is worth making again, when the API said. */
22
+ retryAfter?: number;
23
+ /** `X-Request-ID` — quote it to support and we can find the call. */
24
+ requestId?: string;
25
+ /** What this call cost. Zero on every refusal the API charges nothing for, which is
26
+ * every refusal except a page that answered. */
27
+ cost?: number;
28
+ creditsRemaining?: number;
29
+ cause?: unknown;
30
+ }
31
+
32
+ export class ScrapaError extends Error {
33
+ readonly code: ErrorCode | ClientErrorCode;
34
+ readonly status: number;
35
+ readonly messageKey?: string;
36
+ readonly params?: Record<string, string | number>;
37
+ readonly details?: unknown;
38
+ readonly retryAfter?: number;
39
+ readonly requestId?: string;
40
+ readonly cost?: number;
41
+ readonly creditsRemaining?: number;
42
+
43
+ constructor(init: ScrapaErrorInit) {
44
+ super(init.message, init.cause !== undefined ? { cause: init.cause } : undefined);
45
+ this.name = "ScrapaError";
46
+ this.code = init.code;
47
+ this.status = init.status;
48
+ this.messageKey = init.messageKey;
49
+ this.params = init.params;
50
+ this.details = init.details;
51
+ this.retryAfter = init.retryAfter;
52
+ this.requestId = init.requestId;
53
+ this.cost = init.cost;
54
+ this.creditsRemaining = init.creditsRemaining;
55
+ }
56
+
57
+ /**
58
+ * Who said no — the one thing that decides what to do next.
59
+ *
60
+ * "blocked" the site refused the page. It cost you nothing: try another engine, pool
61
+ * or country. The same call again will be refused again.
62
+ * "denied" our policy refused this URL (a private address, the acceptable-use list).
63
+ * Retrying cannot help; change the URL.
64
+ * "broken" we broke it, and already refunded it. Worth one retry as-is.
65
+ *
66
+ * `undefined` for the refusals that were the caller's own doing — a bad field, a missing
67
+ * key, a spent quota — which are not outcomes of a page at all.
68
+ */
69
+ get outcome(): "blocked" | "denied" | "broken" | undefined {
70
+ return OUTCOME_OF[this.code as ErrorCode];
71
+ }
72
+
73
+ /** True when repeating the identical call could work. A limit that clears by waiting
74
+ * and anything we broke, never a verdict on the page itself. */
75
+ get retryable(): boolean {
76
+ return (
77
+ this.code === "NETWORK_ERROR" ||
78
+ this.code === "TIMEOUT" ||
79
+ this.code === "RATE_LIMIT_EXCEEDED" ||
80
+ this.outcome === "broken"
81
+ );
82
+ }
83
+ }