@hydrafetch/node-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/dist/index.js ADDED
@@ -0,0 +1,269 @@
1
+ // src/errors.ts
2
+ var HydrafetchError = class extends Error {
3
+ code;
4
+ status;
5
+ requestId;
6
+ details;
7
+ constructor(args) {
8
+ super(args.message);
9
+ this.name = "HydrafetchError";
10
+ this.code = args.code;
11
+ this.status = args.status;
12
+ this.requestId = args.requestId;
13
+ this.details = args.details;
14
+ }
15
+ /** 401 and 403. Retrying without changing the key is pointless. */
16
+ get isAuth() {
17
+ return this.status === 401 || this.status === 403;
18
+ }
19
+ /** 402. The workspace is out of credits. */
20
+ get isOutOfCredits() {
21
+ return this.status === 402;
22
+ }
23
+ /** 400 and 422. The request itself is wrong; a repeat costs another call and fails again. */
24
+ get isInvalidRequest() {
25
+ return this.status === 400 || this.status === 422;
26
+ }
27
+ /** 429 and 5xx. Worth another attempt after a wait. */
28
+ get isRetryable() {
29
+ return this.status === 429 || this.status >= 500;
30
+ }
31
+ };
32
+ var HydrafetchTimeoutError = class extends HydrafetchError {
33
+ constructor(message) {
34
+ super({ code: "TIMEOUT", message, status: 408 });
35
+ this.name = "HydrafetchTimeoutError";
36
+ }
37
+ };
38
+
39
+ // src/client.ts
40
+ var DEFAULT_BASE_URL = "https://api.hydrafetch.com";
41
+ var DEFAULT_TIMEOUT_MS = 12e4;
42
+ var DEFAULT_MAX_RETRIES = 2;
43
+ function sleep(ms) {
44
+ return new Promise((resolve) => setTimeout(resolve, ms));
45
+ }
46
+ var Hydrafetch = class {
47
+ apiKey;
48
+ baseUrl;
49
+ timeoutMs;
50
+ maxRetries;
51
+ fetchImpl;
52
+ constructor(options = {}) {
53
+ const opts = typeof options === "string" ? { apiKey: options } : options;
54
+ const env = globalThis.process?.env;
55
+ const apiKey = opts.apiKey ?? env?.HYDRAFETCH_API_KEY;
56
+ if (!apiKey) {
57
+ throw new HydrafetchError({
58
+ code: "MISSING_API_KEY",
59
+ message: "No API key. Pass one to the constructor or set HYDRAFETCH_API_KEY. Create a key at https://app.hydrafetch.com.",
60
+ status: 401
61
+ });
62
+ }
63
+ this.apiKey = apiKey;
64
+ this.baseUrl = (opts.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
65
+ this.timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
66
+ this.maxRetries = opts.maxRetries ?? DEFAULT_MAX_RETRIES;
67
+ this.fetchImpl = opts.fetch ?? globalThis.fetch;
68
+ if (typeof this.fetchImpl !== "function") {
69
+ throw new HydrafetchError({
70
+ code: "NO_FETCH",
71
+ message: "No global fetch. Use Node 18 or newer, or pass a fetch implementation.",
72
+ status: 500
73
+ });
74
+ }
75
+ }
76
+ async request(method, path, body, query) {
77
+ const url = new URL(this.baseUrl + path);
78
+ for (const [k, v] of Object.entries(query ?? {})) {
79
+ if (v === void 0 || v === null) continue;
80
+ url.searchParams.set(k, String(v));
81
+ }
82
+ let lastError;
83
+ for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
84
+ const controller = new AbortController();
85
+ const timer = setTimeout(() => controller.abort(), this.timeoutMs);
86
+ try {
87
+ const res = await this.fetchImpl(url.toString(), {
88
+ method,
89
+ headers: {
90
+ "X-API-Key": this.apiKey,
91
+ "content-type": "application/json",
92
+ accept: "application/json"
93
+ },
94
+ body: body === void 0 ? void 0 : JSON.stringify(body),
95
+ signal: controller.signal
96
+ });
97
+ const text = await res.text();
98
+ let parsed;
99
+ try {
100
+ parsed = text ? JSON.parse(text) : void 0;
101
+ } catch {
102
+ parsed = void 0;
103
+ }
104
+ if (!res.ok || parsed?.success === false) {
105
+ const err = new HydrafetchError({
106
+ code: parsed?.error?.code ?? `HTTP_${res.status}`,
107
+ message: parsed?.error?.message ?? text.slice(0, 300) ?? res.statusText,
108
+ status: res.status,
109
+ requestId: parsed?.meta?.requestId,
110
+ details: parsed?.error?.details
111
+ });
112
+ if (!err.isRetryable || attempt === this.maxRetries) throw err;
113
+ lastError = err;
114
+ await sleep(2 ** attempt * 500 + Math.floor(Math.random() * 250));
115
+ continue;
116
+ }
117
+ return parsed?.data ?? parsed;
118
+ } catch (error) {
119
+ if (error instanceof HydrafetchError) {
120
+ if (!error.isRetryable || attempt === this.maxRetries) throw error;
121
+ lastError = error;
122
+ continue;
123
+ }
124
+ if (error?.name === "AbortError") {
125
+ throw new HydrafetchTimeoutError(
126
+ `Request to ${path} exceeded ${this.timeoutMs}ms. Raise timeoutMs, or use async job endpoints for long work.`
127
+ );
128
+ }
129
+ if (attempt === this.maxRetries) throw error;
130
+ await sleep(2 ** attempt * 500);
131
+ } finally {
132
+ clearTimeout(timer);
133
+ }
134
+ }
135
+ throw lastError ?? new HydrafetchError({ code: "UNKNOWN", message: "Request failed", status: 500 });
136
+ }
137
+ /**
138
+ * One page as clean Markdown and structured data. One credit.
139
+ *
140
+ * The return type follows the formats you ask for, so the ones you requested
141
+ * are not optional:
142
+ *
143
+ * ```ts
144
+ * const a = await hf.scrape(url); // a.markdown: string
145
+ * const b = await hf.scrape(url, { formats: ['html'] }); // b.html: string
146
+ * ```
147
+ */
148
+ scrape(url, options = {}) {
149
+ return this.request("POST", "/v1/web/scrape", { url, ...options });
150
+ }
151
+ /** A page straight to a Markdown string, for when that is all you want. */
152
+ async markdown(url, options = {}) {
153
+ const page = await this.scrape(url, { ...options, formats: ["markdown"] });
154
+ return page.markdown;
155
+ }
156
+ /** A site's URLs from its sitemap and links, without fetching the pages. One credit. */
157
+ map(url, options = {}) {
158
+ return this.request("POST", "/v1/web/map", { url, ...options });
159
+ }
160
+ /** Ranked web results. One credit, plus one per result when scrapeResults is set. */
161
+ search(query, options = {}) {
162
+ return this.request("POST", "/v1/web/search", { query, ...options });
163
+ }
164
+ /** Typed JSON from one or more URLs. Five credits per URL. */
165
+ extract(urls, options = {}) {
166
+ return this.request("POST", "/v1/web/extract", {
167
+ urls: Array.isArray(urls) ? urls : [urls],
168
+ ...options
169
+ });
170
+ }
171
+ /** A company's brand from its domain. Five credits. */
172
+ brand(domain) {
173
+ return this.request("GET", "/v1/web/brand", void 0, { domain });
174
+ }
175
+ /** One embeddable logo. One credit, a fifth of a full brand resolve. */
176
+ logo(domain, options = {}) {
177
+ return this.request("GET", "/v1/web/brand/logo", void 0, { domain, ...options });
178
+ }
179
+ /** A site's design system, read from computed styles in a real browser. Ten credits. */
180
+ styleguide(domain) {
181
+ return this.request("GET", "/v1/web/styleguide", void 0, { domain });
182
+ }
183
+ /** A rendered PNG of the page, returned as a public URL. Five credits. */
184
+ screenshot(url, options = {}) {
185
+ return this.request("POST", "/v1/web/screenshot", { url, ...options });
186
+ }
187
+ /** A page's images with metadata, without rendering it. One credit. */
188
+ images(url, options = {}) {
189
+ return this.request("POST", "/v1/web/images", { url, ...options });
190
+ }
191
+ /** A page's links. One credit. */
192
+ links(url, options = {}) {
193
+ return this.request("POST", "/v1/web/links", { url, ...options });
194
+ }
195
+ /**
196
+ * Start a crawl from a seed URL. Returns a job id immediately; poll with
197
+ * crawlStatus, or use crawlAndWait to do both.
198
+ */
199
+ async startCrawl(url, options = {}) {
200
+ const res = await this.request("POST", "/v1/web/crawl", {
201
+ url,
202
+ ...options
203
+ });
204
+ const id = res.crawlId ?? res.id;
205
+ if (!id) {
206
+ throw new HydrafetchError({
207
+ code: "NO_JOB_ID",
208
+ message: "Crawl accepted but returned no id.",
209
+ status: 502
210
+ });
211
+ }
212
+ return id;
213
+ }
214
+ crawlStatus(id) {
215
+ return this.request("GET", `/v1/web/crawl/${encodeURIComponent(id)}`);
216
+ }
217
+ /**
218
+ * Start a batch over a known list of URLs. Per-page options belong in scrapeOptions,
219
+ * not at the top level.
220
+ */
221
+ async startBatch(urls, options = {}) {
222
+ const res = await this.request("POST", "/v1/web/batch", {
223
+ urls,
224
+ ...options
225
+ });
226
+ const id = res.batchId ?? res.id;
227
+ if (!id) {
228
+ throw new HydrafetchError({
229
+ code: "NO_JOB_ID",
230
+ message: "Batch accepted but returned no id.",
231
+ status: 502
232
+ });
233
+ }
234
+ return id;
235
+ }
236
+ batchStatus(id) {
237
+ return this.request("GET", `/v1/web/batch/${encodeURIComponent(id)}`);
238
+ }
239
+ /** Start a crawl and poll until it reaches a terminal state. */
240
+ crawlAndWait(url, options = {}, wait = {}) {
241
+ return this.startCrawl(url, options).then((id) => this.waitForJob(() => this.crawlStatus(id), wait));
242
+ }
243
+ /** Start a batch and poll until it reaches a terminal state. */
244
+ batchAndWait(urls, options = {}, wait = {}) {
245
+ return this.startBatch(urls, options).then((id) => this.waitForJob(() => this.batchStatus(id), wait));
246
+ }
247
+ async waitForJob(poll, wait) {
248
+ const timeoutMs = wait.timeoutMs ?? 3e5;
249
+ const interval = wait.pollIntervalMs ?? 2e3;
250
+ const deadline = Date.now() + timeoutMs;
251
+ const terminal = /* @__PURE__ */ new Set(["completed", "failed", "cancelled"]);
252
+ for (; ; ) {
253
+ const job = await poll();
254
+ wait.onProgress?.(job);
255
+ if (terminal.has(String(job.status))) return job;
256
+ if (Date.now() + interval > deadline) {
257
+ throw new HydrafetchTimeoutError(
258
+ `Job did not finish within ${timeoutMs}ms. It is still running server-side; poll its status directly, or pass a webhook.`
259
+ );
260
+ }
261
+ await sleep(interval);
262
+ }
263
+ }
264
+ };
265
+ export {
266
+ Hydrafetch,
267
+ HydrafetchError,
268
+ HydrafetchTimeoutError
269
+ };
package/package.json ADDED
@@ -0,0 +1,55 @@
1
+ {
2
+ "name": "@hydrafetch/node-sdk",
3
+ "version": "0.1.0",
4
+ "description": "Official Node/TypeScript client for the Hydrafetch web data API. Turn any URL into clean Markdown and structured data.",
5
+ "keywords": [
6
+ "hydrafetch",
7
+ "web-scraping",
8
+ "markdown",
9
+ "llm",
10
+ "rag",
11
+ "crawler",
12
+ "structured-data",
13
+ "agents"
14
+ ],
15
+ "homepage": "https://hydrafetch.com",
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "git+https://github.com/Hydrafetch/node-sdk.git"
19
+ },
20
+ "bugs": "https://github.com/Hydrafetch/node-sdk/issues",
21
+ "license": "MIT",
22
+ "type": "module",
23
+ "main": "./dist/index.cjs",
24
+ "module": "./dist/index.js",
25
+ "types": "./dist/index.d.ts",
26
+ "exports": {
27
+ ".": {
28
+ "types": "./dist/index.d.ts",
29
+ "import": "./dist/index.js",
30
+ "require": "./dist/index.cjs"
31
+ }
32
+ },
33
+ "files": [
34
+ "dist",
35
+ "README.md",
36
+ "LICENSE"
37
+ ],
38
+ "engines": {
39
+ "node": ">=18"
40
+ },
41
+ "scripts": {
42
+ "build": "tsup src/index.ts --format esm,cjs --dts --clean",
43
+ "test": "vitest run",
44
+ "typecheck": "tsc --noEmit",
45
+ "prepublishOnly": "npm run typecheck && npm run test && npm run build"
46
+ },
47
+ "devDependencies": {
48
+ "tsup": "^8.5.0",
49
+ "typescript": "^5.9.2",
50
+ "vitest": "^3.2.4"
51
+ },
52
+ "publishConfig": {
53
+ "access": "public"
54
+ }
55
+ }