@hunt-labs/bytekit-sdk 0.9.3 → 0.11.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,176 @@ today, see [README.md](./README.md), which remains the authoritative usage guide
10
10
 
11
11
  ## [Unreleased]
12
12
 
13
+ ## [0.11.0] - 2026-09-14
14
+
15
+ ### Added
16
+
17
+ - **`solve_challenge` on `scrape.create` (#4447).** `ScrapeOpts` now carries
18
+ `solve_challenge?: boolean`, and the generated `ScrapeRequest` schema type carries the field
19
+ with its server default `false`. When `true`,
20
+ the scrape attempts to clear a Cloudflare challenge; success is not guaranteed. It is billed
21
+ as a normal browser render. A scrape that omits the option sends no `solve_challenge` key,
22
+ so existing calls are unchanged on the wire.
23
+ - **`credits_scrape` on the usage response types (#4452).** The generated `GET /v1/usage`
24
+ response type and each `GET /v1/usage/daily` data item now carry `credits_scrape: number`:
25
+ credits drawn under the scrape usage source, such as `/v1/schema` extractions. The gateway
26
+ always charged this bucket but never returned it, so the published per-source credit fields
27
+ could not add up to `credits_used`.
28
+
29
+ ### Changed
30
+
31
+ - **Internal `GET /v1/bulk` is cursor-paginated and its 100-job cap is gone (#4508).** The
32
+ generated `listBulk` types record a changed response shape: the operation used to return a
33
+ bare array of at most the 100 newest jobs, and now returns the same page envelope
34
+ `GET /v1/monitors` does, `{ data, next_cursor, has_more }`, plus `limit` and `cursor` query
35
+ parameters (`limit` is 1 to 100, default 25). A `status` filter and a `cursor` combine in
36
+ one request: `status` narrows the set and the cursor addresses a position inside it. The
37
+ operation's summary also no longer says "in-flight", which was wrong — it has always
38
+ returned jobs of every status, `cancelled` included. This is a breaking change to the wire
39
+ shape of an `x-sdk-scope: internal` operation; no SDK method calls it, so no published
40
+ method signature moves.
41
+ - **`GET /v1/logs` `billing_multiplier` is the bandwidth factor, not a credit count (#4469).**
42
+ The generated logs-row type records the changed meaning of a public field: the value is now
43
+ `billable_bytes` / `raw_bytes` rounded to 2 decimal places — the factor the request settled
44
+ at, carrying the endpoint, cache-hit and clean-markdown factors, and the flat 1.5 Phase-1
45
+ factor on screenshots and recordings. It used to be the row's persisted `credits_charged`,
46
+ an account credit count charged per capture option, so two screenshots billed at the same
47
+ 1.5 factor reported `2` and `1`. The 2 decimal places are load-bearing rather than
48
+ cosmetic: `billable_bytes` is itself rounded, so an odd byte basis would otherwise surface
49
+ as `1.500001943158721` instead of `1.5` on roughly half of all such rows. Every factor the
50
+ field can report is a multiple of 0.25, so 2 places is exact for any basis of 100 bytes or
51
+ more, and `raw_bytes` × `billing_multiplier` rounded to the nearest byte still reproduces
52
+ `billable_bytes`. The `billable_bytes` doc comment therefore no longer says it is NOT
53
+ `raw_bytes` × `billing_multiplier`. The field is also null on more rows than before: a
54
+ search or webhook row has no byte basis, a capture whose Phase 1 was skipped has a zero one,
55
+ and a capture old enough to predate the Phase-1 basis being recorded has none at all. One
56
+ residual is documented rather than fixed: a markdown `/v1/fetch` row completed before the
57
+ billing decomposition was recorded reports 1.0 where settlement charged 1.5, because nothing
58
+ on such a row distinguishes it. The type shapes are unchanged (`number | null`), and no SDK
59
+ method calls this endpoint.
60
+ - **An all-null `/v1/schema` extraction your schema allows is validated again (#4490).** The
61
+ generated `/v1/schema` 200 response type records the exception to #4455's rule: an output
62
+ whose every leaf is `null` still arrives `validated: true` with the object intact, and is
63
+ charged, when the schema you sent declares `"null"` in the `type` union of every leaf the
64
+ output returned as `null`. Only that form counts — `nullable: true`, an `enum` containing
65
+ `null` and `const: null` do not, and a response that is itself `null` rather than an object is
66
+ never accepted however the root is declared. Everything else stays the #4455 miss. The `data`
67
+ doc comment now also states the other half: a `validated: true` response always carries an
68
+ object, never `null`. The type shapes are unchanged; only their doc comments moved.
69
+ - **An all-null `/v1/schema` extraction is reported, not validated (#4455).** The generated
70
+ `/v1/schema` 200 response type documents a new additive warning code, `empty_extraction`: a
71
+ schema-valid output whose every leaf is `null` now arrives with `data: null` and
72
+ `validated: false`, and is not charged the per-request credits. Previously it arrived as
73
+ `validated: true` with the all-null object and no warning. The `data` and `validated` doc
74
+ comments now also say that every `validated: false` response carries `data: null`; the old
75
+ comment claimed exhausted retries returned the last raw output, which the API never did. The
76
+ type shapes are unchanged; only their doc comments moved.
77
+ - **An inline `POST /v1/scrape` failure is documented with a persisted `id` (#4454).** The
78
+ gateway now records a sync scrape that ends `upstream_error` or `blocked` as a failed scrape
79
+ job, so its `failed` envelope carries that job's `sc_` id instead of `null`, `scrape.get(id)`
80
+ returns the same envelope, and the request appears in the dashboard Logs. The generated
81
+ `createScrape` 200 and `ScrapeErrorEnvelope` doc comments and the `scrape.create` doc comment
82
+ no longer say `id: null`. The type shapes are unchanged (`id` stays `string | null`), and
83
+ `scrape.create` still throws `ByteKitError` on that envelope exactly as before.
84
+ - **Internal `GET /v1/logs` types list search and webhook rows (#4460).** Both `endpoint` enums
85
+ add `search` and `webhook`; a row's `source` is now `string | null`. No SDK method calls it.
86
+
87
+ ## [0.10.0] - 2026-09-11
88
+
89
+ ### Changed
90
+
91
+ - **A terminal `POST /v1/scrape` failure now throws instead of resolving (#1870).** The
92
+ gateway used to report an upstream fetch failure — unreachable host, refused connection,
93
+ or bot protection that defeated every proxy tier — as HTTP 503; it now answers **HTTP 200**
94
+ with the canonical `failed` envelope (`id: null`), because a definitive upstream outcome is
95
+ a result, not a transport error. `!response.ok` was this SDK's only failure seam, so without
96
+ this change that body would resolve like a success and silence every caller's `catch`.
97
+ `scrape.create` (and any `request` / `requestWithResponse` call to `POST /v1/scrape`) now
98
+ raises `ByteKitError` carrying the envelope's `code` and `message`, the **actual** status
99
+ `200`, the response headers, and `error.details` when one is supplied. A legacy 503 from a
100
+ not-yet-upgraded gateway still throws through the unchanged non-2xx decoder, so the SDK
101
+ works against both. **Upgrade note:** if you inspected `result.status === 'failed'` after
102
+ `scrape.create`, move that handling into a `catch`.
103
+ - Everything else is untouched: `scrape.get(id)`, bulk item results, and every other endpoint
104
+ still deliver `failed` as data; `requestRaw` (`/v1/fetch`) never parses JSON and is
105
+ unaffected; 204 and empty-body handling are unchanged. `request()` now delegates to
106
+ `requestWithResponse()` so the guard has a single application site — identical in every
107
+ observable respect but the return value, as both were already documented.
108
+
109
+ ## [0.9.4] - 2026-09-06 [NEVER PUBLISHED]
110
+
111
+ **This version was cut but NEVER PUBLISHED — npm `latest` goes from 0.9.3 straight to 0.10.0.**
112
+ Publication is triggered by CI on `main` from the version the manifest declares, and 0.10.0
113
+ (#1870) superseded 0.9.4 in the tree before any release reached `main`. Everything below
114
+ reaches consumers in the **0.10.0** artifact, which contains all of it; nothing is installable
115
+ at 0.9.4: `npm i @hunt-labs/bytekit-sdk@0.9.4` fails.
116
+
117
+ ### Added
118
+
119
+ - Bulk fetch, scrape, and mixed-result reads now accept optional cursor, limit, and status
120
+ page parameters while preserving the existing `(id, requestOptions?)` call shape (#3717).
121
+ Generated response types publish stable item identities, page cursors and completeness,
122
+ whole-job counters, exact mixed artifact keys, artifact warnings, bounded webhook recovery
123
+ metadata, and the `artifact_unavailable` error contract.
124
+
125
+ ### Fixed
126
+
127
+ - Corrected the artifact-URL fallback documentation on `GET /v1/bulk/{id}/screenshots`
128
+ (`image_url`, `content_url`, `markdown_url`) and `GET /v1/logs` (`artifact_url`) (#3717).
129
+ Those fields were documented as null-plus-`artifact_unavailable` when no signature can be
130
+ minted; the servers have always returned the row's stored value unchanged instead — `null`
131
+ while nothing is stored, and otherwise the private unsigned storage URL, which answers `401`
132
+ — and raise no warning. `GET /v1/fetch/bulk/{id}` does null the field and report
133
+ `artifact_unavailable`, and its documentation is unchanged. No server behaviour changed and
134
+ no type moved; the descriptions now match what the endpoints return.
135
+
136
+ ### Changed
137
+
138
+ - Regenerated `src/generated/api.d.ts` against the corrected `topup_balance_bytes` description
139
+ on `GET /v1/usage` (#4251). The description read "Remaining purchased top-up bytes", which
140
+ contradicted its own "Equal to `topup_bytes`" clause: the value covers purchased top-up lots
141
+ and the one-time starter grant, which is what the endpoint has always returned. The clause
142
+ recording that the figure is already net of consumed overage, and the "Present only in
143
+ dual/bytes billing mode" clause, are both unchanged and still true. Types are unchanged and
144
+ no server value changed; only the documentation on them moved.
145
+ - Regenerated `src/generated/api.d.ts` against the clarified `cache_age_s` description on
146
+ `ScrapeSuccessEnvelope` (#4237). The description now records that `GET /v1/scrape/{id}`
147
+ reports `cache: hit` without `cache_age_s`: the age is a property of the replay that happened
148
+ at completion time, is not persisted on the row, and cannot be derived honestly afterwards.
149
+ The one-directional "Present only on `cache: hit`" clause is unchanged and still true. Types
150
+ are unchanged; only the documentation on them moved.
151
+ - Regenerated `src/generated/api.d.ts` against the completed `cache` presence list on
152
+ `ScrapeSuccessEnvelope` (#4203). The field's description now names `GET /v1/scrape/{id}` as a
153
+ third delivery path, alongside the synchronous response and webhook delivery: a polled scrape
154
+ now reports the cache disposition its row recorded, and omits the field — rather than
155
+ defaulting it to `miss` — for a row that recorded none. Types are unchanged; only the
156
+ documentation on them moved.
157
+ - Regenerated `src/generated/api.d.ts` against the corrected `PUT /v1/billing/auto-topup`
158
+ request body (#3984). `monthly_cap_cents` is now typed `number | null`: an explicit `null` is
159
+ the wire representation of "no monthly cap", and it is what the endpoint has always accepted —
160
+ the request schema simply never documented it, while the 200 response schema already did.
161
+ Omitting the key while `enabled` is `true` is still rejected with `422`, so send `null`
162
+ rather than dropping the field. No server behaviour changes; the type widens to match what
163
+ the API already accepted.
164
+ - Regenerated `src/generated/api.d.ts` against the corrected monitor `scrape_options`
165
+ documentation (#4024). The description no longer lists `headers` among the accepted
166
+ fields — `POST /v1/monitors` never honoured a caller-supplied `headers` option and now
167
+ rejects it with the same 400 every other capture endpoint returns for the field. Types are
168
+ unchanged; only the documentation on them moved.
169
+ - Regenerated `src/generated/api.d.ts` against the completed `X-Scrape-Proxy-Tier-Label`
170
+ omission list (#4030). The header's description now names a third case in which no tier is
171
+ reported: a response replayed from stored bytes after this request's own cache lookup missed,
172
+ which arrives as `X-Scrape-Cache: miss` and likewise ran no proxy tier. The two cases already
173
+ documented — a cache hit, and responses the fast path did not serve — are unchanged. Types are
174
+ unchanged; only the documentation on them moved.
175
+ - Regenerated `src/generated/api.d.ts` against the qualified `X-Scrape-Proxy-Tier-Label`
176
+ omission list (#4132). The third case that list records — a response replayed from stored bytes
177
+ after this request's own cache lookup missed — is not reachable through this API: every caller
178
+ of the internal fetch path closes the handler branch that produces it, and the branch survives
179
+ only as a deploy-skew safeguard. The description now says so rather than presenting the case as
180
+ ordinary behaviour. The two reachable cases — a cache hit, and responses the fast path did not
181
+ serve — are unchanged. Types are unchanged; only the documentation on them moved.
182
+
13
183
  ## [0.9.3] - 2026-09-04
14
184
 
15
185
  ### 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` names the failed job the gateway recorded it as —
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
  }