@hunt-labs/bytekit-sdk 0.9.3 → 0.10.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/CHANGELOG.md CHANGED
@@ -10,6 +10,102 @@ today, see [README.md](./README.md), which remains the authoritative usage guide
10
10
 
11
11
  ## [Unreleased]
12
12
 
13
+ ## [0.10.0] - 2026-09-11
14
+
15
+ ### Changed
16
+
17
+ - **A terminal `POST /v1/scrape` failure now throws instead of resolving (#1870).** The
18
+ gateway used to report an upstream fetch failure — unreachable host, refused connection,
19
+ or bot protection that defeated every proxy tier — as HTTP 503; it now answers **HTTP 200**
20
+ with the canonical `failed` envelope (`id: null`), because a definitive upstream outcome is
21
+ a result, not a transport error. `!response.ok` was this SDK's only failure seam, so without
22
+ this change that body would resolve like a success and silence every caller's `catch`.
23
+ `scrape.create` (and any `request` / `requestWithResponse` call to `POST /v1/scrape`) now
24
+ raises `ByteKitError` carrying the envelope's `code` and `message`, the **actual** status
25
+ `200`, the response headers, and `error.details` when one is supplied. A legacy 503 from a
26
+ not-yet-upgraded gateway still throws through the unchanged non-2xx decoder, so the SDK
27
+ works against both. **Upgrade note:** if you inspected `result.status === 'failed'` after
28
+ `scrape.create`, move that handling into a `catch`.
29
+ - Everything else is untouched: `scrape.get(id)`, bulk item results, and every other endpoint
30
+ still deliver `failed` as data; `requestRaw` (`/v1/fetch`) never parses JSON and is
31
+ unaffected; 204 and empty-body handling are unchanged. `request()` now delegates to
32
+ `requestWithResponse()` so the guard has a single application site — identical in every
33
+ observable respect but the return value, as both were already documented.
34
+
35
+ ## [0.9.4] - 2026-09-06 [NEVER PUBLISHED]
36
+
37
+ **This version was cut but NEVER PUBLISHED — npm `latest` goes from 0.9.3 straight to 0.10.0.**
38
+ Publication is triggered by CI on `main` from the version the manifest declares, and 0.10.0
39
+ (#1870) superseded 0.9.4 in the tree before any release reached `main`. Everything below
40
+ reaches consumers in the **0.10.0** artifact, which contains all of it; nothing is installable
41
+ at 0.9.4: `npm i @hunt-labs/bytekit-sdk@0.9.4` fails.
42
+
43
+ ### Added
44
+
45
+ - Bulk fetch, scrape, and mixed-result reads now accept optional cursor, limit, and status
46
+ page parameters while preserving the existing `(id, requestOptions?)` call shape (#3717).
47
+ Generated response types publish stable item identities, page cursors and completeness,
48
+ whole-job counters, exact mixed artifact keys, artifact warnings, bounded webhook recovery
49
+ metadata, and the `artifact_unavailable` error contract.
50
+
51
+ ### Fixed
52
+
53
+ - Corrected the artifact-URL fallback documentation on `GET /v1/bulk/{id}/screenshots`
54
+ (`image_url`, `content_url`, `markdown_url`) and `GET /v1/logs` (`artifact_url`) (#3717).
55
+ Those fields were documented as null-plus-`artifact_unavailable` when no signature can be
56
+ minted; the servers have always returned the row's stored value unchanged instead — `null`
57
+ while nothing is stored, and otherwise the private unsigned storage URL, which answers `401`
58
+ — and raise no warning. `GET /v1/fetch/bulk/{id}` does null the field and report
59
+ `artifact_unavailable`, and its documentation is unchanged. No server behaviour changed and
60
+ no type moved; the descriptions now match what the endpoints return.
61
+
62
+ ### Changed
63
+
64
+ - Regenerated `src/generated/api.d.ts` against the corrected `topup_balance_bytes` description
65
+ on `GET /v1/usage` (#4251). The description read "Remaining purchased top-up bytes", which
66
+ contradicted its own "Equal to `topup_bytes`" clause: the value covers purchased top-up lots
67
+ and the one-time starter grant, which is what the endpoint has always returned. The clause
68
+ recording that the figure is already net of consumed overage, and the "Present only in
69
+ dual/bytes billing mode" clause, are both unchanged and still true. Types are unchanged and
70
+ no server value changed; only the documentation on them moved.
71
+ - Regenerated `src/generated/api.d.ts` against the clarified `cache_age_s` description on
72
+ `ScrapeSuccessEnvelope` (#4237). The description now records that `GET /v1/scrape/{id}`
73
+ reports `cache: hit` without `cache_age_s`: the age is a property of the replay that happened
74
+ at completion time, is not persisted on the row, and cannot be derived honestly afterwards.
75
+ The one-directional "Present only on `cache: hit`" clause is unchanged and still true. Types
76
+ are unchanged; only the documentation on them moved.
77
+ - Regenerated `src/generated/api.d.ts` against the completed `cache` presence list on
78
+ `ScrapeSuccessEnvelope` (#4203). The field's description now names `GET /v1/scrape/{id}` as a
79
+ third delivery path, alongside the synchronous response and webhook delivery: a polled scrape
80
+ now reports the cache disposition its row recorded, and omits the field — rather than
81
+ defaulting it to `miss` — for a row that recorded none. Types are unchanged; only the
82
+ documentation on them moved.
83
+ - Regenerated `src/generated/api.d.ts` against the corrected `PUT /v1/billing/auto-topup`
84
+ request body (#3984). `monthly_cap_cents` is now typed `number | null`: an explicit `null` is
85
+ the wire representation of "no monthly cap", and it is what the endpoint has always accepted —
86
+ the request schema simply never documented it, while the 200 response schema already did.
87
+ Omitting the key while `enabled` is `true` is still rejected with `422`, so send `null`
88
+ rather than dropping the field. No server behaviour changes; the type widens to match what
89
+ the API already accepted.
90
+ - Regenerated `src/generated/api.d.ts` against the corrected monitor `scrape_options`
91
+ documentation (#4024). The description no longer lists `headers` among the accepted
92
+ fields — `POST /v1/monitors` never honoured a caller-supplied `headers` option and now
93
+ rejects it with the same 400 every other capture endpoint returns for the field. Types are
94
+ unchanged; only the documentation on them moved.
95
+ - Regenerated `src/generated/api.d.ts` against the completed `X-Scrape-Proxy-Tier-Label`
96
+ omission list (#4030). The header's description now names a third case in which no tier is
97
+ reported: a response replayed from stored bytes after this request's own cache lookup missed,
98
+ which arrives as `X-Scrape-Cache: miss` and likewise ran no proxy tier. The two cases already
99
+ documented — a cache hit, and responses the fast path did not serve — are unchanged. Types are
100
+ unchanged; only the documentation on them moved.
101
+ - Regenerated `src/generated/api.d.ts` against the qualified `X-Scrape-Proxy-Tier-Label`
102
+ omission list (#4132). The third case that list records — a response replayed from stored bytes
103
+ after this request's own cache lookup missed — is not reachable through this API: every caller
104
+ of the internal fetch path closes the handler branch that produces it, and the branch survives
105
+ only as a deploy-skew safeguard. The description now says so rather than presenting the case as
106
+ ordinary behaviour. The two reachable cases — a cache hit, and responses the fast path did not
107
+ serve — are unchanged. Types are unchanged; only the documentation on them moved.
108
+
13
109
  ## [0.9.3] - 2026-09-04
14
110
 
15
111
  ### Changed
package/README.md CHANGED
@@ -300,6 +300,32 @@ try {
300
300
  }
301
301
  ```
302
302
 
303
+ ### A failed scrape throws, even though it arrives on a 200
304
+
305
+ `POST /v1/scrape` reports a terminal upstream fetch failure — an unreachable
306
+ host, a refused connection, or a target whose bot protection defeats every proxy
307
+ tier — as **HTTP 200** carrying the canonical `failed` envelope, because a
308
+ definitive upstream outcome is a result rather than a transport error. The SDK
309
+ turns that one body into the same `ByteKitError` you already catch, so a failed
310
+ scrape can never be mistaken for a successful one:
311
+
312
+ ```ts
313
+ import { ByteKit, ByteKitError } from '@hunt-labs/bytekit-sdk';
314
+
315
+ try {
316
+ await client.scrape.create({ url: 'https://does-not-resolve.invalid' });
317
+ } catch (err) {
318
+ if (err instanceof ByteKitError) {
319
+ // 200 'upstream_error' 'Failed to fetch URL.' (or 'blocked')
320
+ console.error(err.status, err.code, err.message);
321
+ }
322
+ }
323
+ ```
324
+
325
+ `err.status` is the real transport status, `200` — nothing is synthesized. This
326
+ applies to `scrape.create` only. `scrape.get(id)` and bulk item results deliver
327
+ `failed` as **data**, exactly as before: narrow on `result.status` there.
328
+
303
329
  ### Reading which field was rejected
304
330
 
305
331
  `err.details` carries the API envelope's structured rejection reason when the
package/dist/client.d.ts CHANGED
@@ -17,8 +17,8 @@ export interface ByteKitOptions {
17
17
  * read (so a large legitimate body counts against it too). `0` disables the SDK-side
18
18
  * timeout. Default 120000.
19
19
  *
20
- * NOT the same thing as `ScrapeOpts.timeout_ms`, which is the SERVER-side budget the
21
- * gateway applies to the scrape itself. Override per call with
20
+ * NOT the same thing as `ScrapeOpts.timeout_ms`, which is the SERVER-side budget
21
+ * ByteKit applies to the scrape itself. Override per call with
22
22
  * `RequestOptions.requestTimeoutMs`.
23
23
  */
24
24
  timeoutMs?: number;
package/dist/client.js CHANGED
@@ -214,6 +214,53 @@ function parseJsonBody(text) {
214
214
  }
215
215
  return JSON.parse(text);
216
216
  }
217
+ /**
218
+ * Throw a `ByteKitError` for the one 2xx body that is a FAILURE: the terminal
219
+ * `status: 'failed'` envelope `POST /v1/scrape` delivers on HTTP 200 (#1870).
220
+ *
221
+ * The gateway used to report a terminal upstream fetch failure as HTTP 503, and
222
+ * `!response.ok` in {@link ByteKit.send} was this SDK's only failure seam. Now that the
223
+ * same outcome arrives as an intentional 200 — a definitive upstream result is not a
224
+ * transport error, and `id` is `null` because the fetch failed before any job existed —
225
+ * that seam alone would let the failure resolve like a success and silence every caller's
226
+ * `catch`. This restores the exception integrators already write code for, with the
227
+ * envelope's own `code` and `message`.
228
+ *
229
+ * Scope is deliberately narrow — `POST` to exactly `/v1/scrape`:
230
+ *
231
+ * - `GET /v1/scrape/{id}`, bulk item results, and the `scrape.failed` webhook payload
232
+ * have always delivered `failed` as DATA, and a caller polling a job must keep
233
+ * receiving it as a value;
234
+ * - `requestRaw` (`/v1/fetch`) never parses JSON and is not routed through here at all.
235
+ *
236
+ * `status` is the ACTUAL transport status (200). The envelope's optional
237
+ * `error.http_status` is the TARGET's status when one was received, never ours, so it is
238
+ * not substituted here — and the old 503 is not synthesized either.
239
+ *
240
+ * Runs on the already-parsed body, outside `send`'s connection-error catch, so it can
241
+ * never be remapped into a `ByteKitConnectionError`.
242
+ */
243
+ function throwIfScrapeFailureEnvelope(method, path, data, status, headers) {
244
+ if (method.toUpperCase() !== 'POST' || path !== '/v1/scrape')
245
+ return;
246
+ if (typeof data !== 'object' || data === null)
247
+ return;
248
+ const envelope = data;
249
+ if (envelope.status !== 'failed')
250
+ return;
251
+ const error = typeof envelope.error === 'object' && envelope.error !== null
252
+ ? envelope.error
253
+ : {};
254
+ const code = typeof error.code === 'string' ? error.code : 'internal_error';
255
+ const message = typeof error.message === 'string' ? error.message : 'Scrape failed.';
256
+ // Same discipline as the non-2xx decoder above: arrays and scalars are rejected,
257
+ // because `ByteKitErrorDetails` promises something indexable.
258
+ const rawDetails = error.details;
259
+ const details = typeof rawDetails === 'object' && rawDetails !== null && !Array.isArray(rawDetails)
260
+ ? rawDetails
261
+ : undefined;
262
+ throw new ByteKitError(status, code, message, details, headers);
263
+ }
217
264
  /** The `name` of an error-shaped value, or `undefined` when it carries none. */
218
265
  const nameOf = (e) => typeof e === 'object' && e !== null && 'name' in e ? String(e.name) : undefined;
219
266
  /** The `cause` of an error-shaped value, or `undefined` when it carries none. */
@@ -477,8 +524,14 @@ export class ByteKit {
477
524
  // Only the read runs inside the transport's guarded region (#2546) — `JSON.parse`
478
525
  // stays out here (in `parseJsonBody`), so a malformed body still throws its own
479
526
  // SyntaxError rather than being remapped to a ByteKitConnectionError.
480
- const text = await this.send(method, path, body, options, readSuccessText);
481
- return parseJsonBody(text);
527
+ //
528
+ // #1870: delegated to `requestWithResponse` so the terminal-scrape-failure guard has
529
+ // exactly ONE application site across both public JSON transports, rather than a copy
530
+ // per entrypoint that could drift. The two were already documented as identical in
531
+ // every observable respect but the return value, and the header/status pair the
532
+ // delegate captures is what the thrown `ByteKitError` needs anyway.
533
+ const { data } = await this.requestWithResponse(method, path, body, options);
534
+ return data;
482
535
  }
483
536
  /**
484
537
  * Make an authenticated request whose response body IS the content, not a JSON
@@ -536,6 +589,11 @@ export class ByteKit {
536
589
  headers: response.headers,
537
590
  status: response.status,
538
591
  }));
539
- return { data: parseJsonBody(text), headers, status };
592
+ const data = parseJsonBody(text);
593
+ // #1870: the one 2xx body that is a failure. Placed after the parse and outside
594
+ // `send`'s guarded region, so the throw is a ByteKitError and never a remapped
595
+ // connection error.
596
+ throwIfScrapeFailureEnvelope(method, path, data, status, headers);
597
+ return { data, headers, status };
540
598
  }
541
599
  }