@hunt-labs/bytekit-sdk 0.3.0 → 0.5.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
@@ -8,6 +8,91 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
8
8
  This file records only the delta between versions. For how to use the package
9
9
  today, see [README.md](./README.md), which remains the authoritative usage guide.
10
10
 
11
+ ## [0.5.0] - 2026-07-29
12
+
13
+ ### Added
14
+
15
+ - `src/generated/api.d.ts` now models the `400` responses that the gateway's new
16
+ request-body-size guards can return. `operations['stripeWebhook']['responses']` gains a
17
+ `400` with this route's bespoke `{ error?: string }` body, and
18
+ `operations['retryWebhookDelivery']['responses']` gains a `400` carrying the unified
19
+ `components['schemas']['Error']` envelope. Both are purely additive — no existing member
20
+ changed shape — so every current call site still compiles. (#2640)
21
+
22
+ ### Changed
23
+
24
+ - Regenerated `src/generated/api.d.ts` against the merged spec. Beyond the two added `400`
25
+ members above, the delta is JSDoc only: every guarded endpoint's description now carries a
26
+ `**Limits:**` block stating its byte ceiling and the `request_too_large` error code
27
+ (#2640), and `POST /v1/bulk`'s description documents `defaults.type` inheritance and the
28
+ 422 on unrecognized keys (#2643). No runtime code changed. (#2640, #2643)
29
+
30
+ ## [0.4.0] - 2026-07-24
31
+
32
+ ### Added
33
+
34
+ - `RequestOptions` (per-call `requestTimeoutMs` + caller `signal`) is now accepted by
35
+ **every** resource method, not just `scrape.create` / `scrape.get`. It is always a
36
+ trailing optional parameter — on `screenshots.create` it is the third positional, after
37
+ `params` — so every existing call site compiles and serializes byte-identically. Per-call
38
+ options continue to beat the constructor's `timeoutMs` default; omitting them leaves the
39
+ constructor default in force exactly as before. (#2547)
40
+ - `ApiResponse<T>` (`{ data, headers, status }`), a generic `client.requestWithResponse<T>()`
41
+ transport, and three typed siblings — `sitemap.getWithResponse`,
42
+ `screenshots.createWithResponse`, `screenshots.getWithResponse` — make the documented
43
+ `X-Raw-Bytes`, `X-Billable-Bytes`, `X-Billing-Multiplier` and `X-Screenshot-*` response
44
+ headers reachable. This surface is purely **additive**: it issues the identical request
45
+ with the identical options as the base method, and no existing return type changes.
46
+ (#2547)
47
+ - `MonitorStatus` and `ScrapeCookie` are exported from the package entrypoint. (#2547)
48
+
49
+ ### Changed (**BREAKING** at compile time)
50
+
51
+ - `ScrapeOpts.cookies` is now `ScrapeCookie[]`, derived from the spec's `Cookie` schema
52
+ (`name` and `value` required; `domain`, `path`, `secure`, `httpOnly` optional), instead of
53
+ an open bag of untyped records. A cookie missing `name` used to compile and fail as a 422
54
+ on the wire; it is a compile error now. Serialization is unchanged. (#2547)
55
+ - `ListMonitorsParams.status` is now the spec enum
56
+ (`'active' | 'processing' | 'paused' | 'cancelled' | 'suspended'`), derived from the
57
+ generated `listMonitors` query type, instead of `string`. A typo like `'activee'` used to
58
+ compile and 422; it is a compile error now. The serialized query string is unchanged.
59
+ (#2547)
60
+
61
+ ### Fixed
62
+
63
+ - `timeoutMs` / `requestTimeoutMs` (and a caller-supplied `signal`) now cover the
64
+ **response body read**, not just the time to first byte of headers. The abort budget was
65
+ armed for `fetch` and cleared the moment headers arrived, after which `request()`'s
66
+ `response.text()`, `requestRaw()`'s `response.arrayBuffer()`, and the error-body
67
+ `response.json()` on a non-2xx all read the body unbounded — so a server that answered
68
+ with headers and then stalled its body hung the caller forever. A mid-body abort now
69
+ surfaces as `ByteKitConnectionError` with `code: 'timeout'` (budget) or `'aborted'`
70
+ (caller signal) instead of a bare `TypeError: terminated`.
71
+
72
+ The default is **unchanged at 120 s**, and the precedence table for
73
+ `signal` / `requestTimeoutMs` / `timeoutMs` is untouched. One consequence worth planning
74
+ for: a legitimately large body (a multi-MB `/v1/fetch` document) now counts against that
75
+ same budget, where previously only the wait for headers did. (#2546)
76
+
77
+ - The README's webhook-retry sample used an invalid delivery id prefix (`whd_`) that its own
78
+ branded type rejects. The sample is corrected, and a new test compiles every TypeScript
79
+ sample in the README against the SDK's sources so a non-compiling sample can no longer
80
+ ship. (#2547)
81
+
82
+ ### Fixed (**BREAKING** at compile time)
83
+
84
+ - `fetch.get` no longer accepts the eleven markdown-tuning options (`markdown_mode`,
85
+ `markdown_query`, `markdown_links`, `markdown_images`, `with_links_summary`,
86
+ `with_images_summary`, `markdown_compact`, `markdown_filter_images`,
87
+ `markdown_include_media`, `markdown_include_warnings`, `markdown_include_stats`).
88
+ The spec's `GET /v1/fetch` declares only `url`, `format`, `country`, `timeout_ms`
89
+ and `cache_ttl`, so those options were silently dropped by the query builder — a
90
+ caller asking for `markdown_mode: 'llm'` compiled and received default article-mode
91
+ markdown. They are now compile errors instead of silent no-ops; use `fetch.create`
92
+ (POST), which accepts and serializes all of them unchanged. `FetchGetOpts` is now
93
+ derived from the generated `getFetch` query type, so future spec drift fails
94
+ `tsc --noEmit` rather than reaching production. (#2539)
95
+
11
96
  ## [0.3.0] - 2026-07-21
12
97
 
13
98
  ### Removed (**BREAKING**)
@@ -135,6 +220,8 @@ against 0.1.0 — read each one before upgrading.
135
220
  (scrape, screenshot, search, and related endpoints), published under the original
136
221
  `@rapidcrawl/sdk` name.
137
222
 
223
+ [0.5.0]: https://github.com/Hunt-Labs-Inc/ByteKit/tree/main/packages/sdk-typescript
224
+ [0.4.0]: https://github.com/Hunt-Labs-Inc/ByteKit/tree/main/packages/sdk-typescript
138
225
  [0.3.0]: https://github.com/Hunt-Labs-Inc/ByteKit/tree/main/packages/sdk-typescript
139
226
  [0.2.2]: https://github.com/Hunt-Labs-Inc/ByteKit/tree/main/packages/sdk-typescript
140
227
  [0.2.1]: https://github.com/Hunt-Labs-Inc/ByteKit/tree/main/packages/sdk-typescript
package/README.md CHANGED
@@ -155,13 +155,14 @@ await client.monitors.delete('mon_123abc');
155
155
  ### Sitemap crawl
156
156
 
157
157
  ```ts
158
+ // `url` is the only required field — every other option can be omitted.
159
+ const minimal = await client.sitemap.create({ url: 'https://example.com' });
160
+
158
161
  const crawl = await client.sitemap.create({
159
162
  url: 'https://example.com',
160
163
  strategy: 'standard',
161
164
  max_depth: 2,
162
165
  max_urls: 5000,
163
- process: null,
164
- webhook_secret: null,
165
166
  cache_ttl: '30d',
166
167
  compact: false,
167
168
  });
@@ -181,15 +182,45 @@ const byEndpoint = await client.usage.byEndpoint();
181
182
 
182
183
  // Webhook deliveries
183
184
  const deliveries = await client.webhooks.list();
184
- await client.webhooks.retry('whd_123abc');
185
+ await client.webhooks.retry('wh_123abc');
186
+ ```
187
+
188
+ > **Note (the real auth split):** `account.get()` authenticates with the same Bearer
189
+ > API key as every other v0.1 method above — it succeeds normally. `client.account.apiKeys.*`
190
+ > is different: it targets `/v1/account/api-keys*`, which authenticates with a Clerk
191
+ > dashboard session, **not** a Bearer API key — calling any `apiKeys.*` method with an
192
+ > API-key client fails auth. `apiKeys.*` remains on the SDK surface for dashboard/session
193
+ > consumers (deliberate retention — see `DOCUMENTED_SURFACE_EXCEPTIONS` in
194
+ > `src/allowlist.ts`) and is marked `x-sdk-scope: internal` in the spec, so it is
195
+ > intentionally excluded from the v0.1 Bearer-key parity guarantee. `account.get()` is not:
196
+ > it is a real `V01_METHODS` entry.
197
+
198
+ ### Response headers
199
+
200
+ The API carries billing and capture metadata in `X-*` response headers alongside the
201
+ JSON body — `X-Raw-Bytes`, `X-Billable-Bytes`, `X-Billing-Multiplier`, and the
202
+ `X-Screenshot-*` family. Every resource method returns just the parsed body, so use a
203
+ `*WithResponse` sibling when you also need the headers:
204
+
205
+ ```ts
206
+ const { data, headers, status } = await client.sitemap.getWithResponse('sm_123abc');
207
+
208
+ console.log(data.status); // exactly what sitemap.get('sm_123abc') resolves to
209
+ console.log(headers.get('X-Raw-Bytes')); // e.g. "12345"
210
+ console.log(status); // 200
185
211
  ```
186
212
 
187
- > **Note:** `client.account.*` (including `client.account.apiKeys.*`) targets the
188
- > `/v1/account/*` endpoints, which authenticate with a Clerk dashboard session —
189
- > **not** a Bearer API key. Calling them with an API-key client will fail auth.
190
- > They remain on the SDK surface for dashboard/session consumers and are marked
191
- > `x-sdk-scope: internal` in the spec, so they are intentionally excluded from the
192
- > v0.1 Bearer-key parity guarantee.
213
+ `client.screenshots.createWithResponse()` works the same way, and is where the
214
+ `X-Screenshot-*`/`X-Raw-Bytes`/`X-Billable-Bytes`/`X-Billing-Multiplier` headers
215
+ actually live — they're declared on `POST /v1/screenshots` only. Its sibling
216
+ `client.screenshots.getWithResponse()` is still additive and safe to call, but
217
+ `GET /v1/screenshots/{id}` emits none of those headers, so `headers` will be
218
+ empty for it. Any other endpoint's headers are reachable through the generic
219
+ transport, `client.requestWithResponse('GET', '/v1/scrape/sc_123abc')`.
220
+
221
+ These siblings are purely additive: they issue the identical request and accept the
222
+ identical options as their base methods, and `data` is exactly what the base method
223
+ resolves to — no existing return type changes.
193
224
 
194
225
  ## Error handling
195
226
 
@@ -10,3 +10,25 @@
10
10
  */
11
11
  export declare const V01_METHODS: readonly ["scrape.create", "scrape.get", "screenshots.create", "screenshots.get", "bulk.create", "bulk.get", "bulk.cancel", "bulk.screenshots.list", "scrape.bulk.create", "scrape.bulk.get", "fetch.create", "fetch.get", "fetch.bulk.create", "fetch.bulk.get", "monitors.create", "monitors.list", "monitors.get", "monitors.update", "monitors.delete", "monitors.captures.list", "sitemap.create", "sitemap.get", "search.query", "account.get", "usage.get", "usage.daily", "usage.byEndpoint", "webhooks.list", "webhooks.retry"];
12
12
  export type V01Method = (typeof V01_METHODS)[number];
13
+ /**
14
+ * Methods that exist on the real, instantiated `ByteKit` client surface but are
15
+ * deliberately EXCLUDED from `V01_METHODS` — each with the reason it stays off the
16
+ * v0.1 parity guard.
17
+ *
18
+ * `parity.test.ts` only compares the OpenAPI spec's `x-sdk-scope: v0.1` operations
19
+ * against `V01_METHODS` — it says nothing about what the actual client *instance*
20
+ * exposes. That gap is how the six `account.apiKeys.*` methods shipped unguarded
21
+ * (issue #2595): they are real, callable methods on `client.account.apiKeys`, are
22
+ * NOT in `V01_METHODS`, and no test walked the instantiated surface to notice.
23
+ *
24
+ * `surface-guard.test.ts` walks the real client and asserts every callable is
25
+ * either in `V01_METHODS` or listed here — removing a method (from either list) or
26
+ * adding an undocumented one fails that test. To add a new client-surface method
27
+ * that is deliberately NOT part of the v0.1 Bearer-key parity guarantee, add an
28
+ * entry here with the reason; to add one that IS part of it, add it to
29
+ * `V01_METHODS` instead (and annotate the spec operation with `x-sdk-method`).
30
+ */
31
+ export declare const DOCUMENTED_SURFACE_EXCEPTIONS: ReadonlyArray<{
32
+ readonly method: string;
33
+ readonly reason: string;
34
+ }>;
package/dist/allowlist.js CHANGED
@@ -42,3 +42,67 @@ export const V01_METHODS = [
42
42
  'webhooks.list',
43
43
  'webhooks.retry',
44
44
  ];
45
+ /**
46
+ * Methods that exist on the real, instantiated `ByteKit` client surface but are
47
+ * deliberately EXCLUDED from `V01_METHODS` — each with the reason it stays off the
48
+ * v0.1 parity guard.
49
+ *
50
+ * `parity.test.ts` only compares the OpenAPI spec's `x-sdk-scope: v0.1` operations
51
+ * against `V01_METHODS` — it says nothing about what the actual client *instance*
52
+ * exposes. That gap is how the six `account.apiKeys.*` methods shipped unguarded
53
+ * (issue #2595): they are real, callable methods on `client.account.apiKeys`, are
54
+ * NOT in `V01_METHODS`, and no test walked the instantiated surface to notice.
55
+ *
56
+ * `surface-guard.test.ts` walks the real client and asserts every callable is
57
+ * either in `V01_METHODS` or listed here — removing a method (from either list) or
58
+ * adding an undocumented one fails that test. To add a new client-surface method
59
+ * that is deliberately NOT part of the v0.1 Bearer-key parity guarantee, add an
60
+ * entry here with the reason; to add one that IS part of it, add it to
61
+ * `V01_METHODS` instead (and annotate the spec operation with `x-sdk-method`).
62
+ */
63
+ export const DOCUMENTED_SURFACE_EXCEPTIONS = [
64
+ // account.apiKeys.* — x-sdk-scope: internal in the spec (Clerk-session-only auth;
65
+ // a Bearer API key 401s on every one of these). Deliberate retention: the resource
66
+ // stays on the SDK surface for dashboard/session consumers rather than being
67
+ // removed, matching the split documented in README.md's "Account, usage, and API
68
+ // keys" section (issue #2595).
69
+ {
70
+ method: 'account.apiKeys.create',
71
+ reason: 'Clerk-session-only (x-sdk-scope: internal) — deliberate retention for dashboard/session consumers, not part of the Bearer-key v0.1 surface.',
72
+ },
73
+ {
74
+ method: 'account.apiKeys.list',
75
+ reason: 'Clerk-session-only (x-sdk-scope: internal) — deliberate retention for dashboard/session consumers, not part of the Bearer-key v0.1 surface.',
76
+ },
77
+ {
78
+ method: 'account.apiKeys.get',
79
+ reason: 'Clerk-session-only (x-sdk-scope: internal) — deliberate retention for dashboard/session consumers, not part of the Bearer-key v0.1 surface.',
80
+ },
81
+ {
82
+ method: 'account.apiKeys.update',
83
+ reason: 'Clerk-session-only (x-sdk-scope: internal) — deliberate retention for dashboard/session consumers, not part of the Bearer-key v0.1 surface.',
84
+ },
85
+ {
86
+ method: 'account.apiKeys.revoke',
87
+ reason: 'Clerk-session-only (x-sdk-scope: internal) — deliberate retention for dashboard/session consumers, not part of the Bearer-key v0.1 surface.',
88
+ },
89
+ {
90
+ method: 'account.apiKeys.reveal',
91
+ reason: 'Clerk-session-only (x-sdk-scope: internal) — deliberate retention for dashboard/session consumers, not part of the Bearer-key v0.1 surface.',
92
+ },
93
+ // *WithResponse siblings (#2547) — additive `ApiResponse<T>` shorthands over an
94
+ // already-allowlisted base method (same HTTP method, path, and body). Not a
95
+ // distinct v0.1 API operation, so not a `V01_METHODS` entry in its own right.
96
+ {
97
+ method: 'screenshots.createWithResponse',
98
+ reason: 'Additive #2547 ApiResponse<T> shorthand over screenshots.create (same request) — not a distinct v0.1 API operation.',
99
+ },
100
+ {
101
+ method: 'screenshots.getWithResponse',
102
+ reason: 'Additive #2547 ApiResponse<T> shorthand over screenshots.get (same request) — not a distinct v0.1 API operation.',
103
+ },
104
+ {
105
+ method: 'sitemap.getWithResponse',
106
+ reason: 'Additive #2547 ApiResponse<T> shorthand over sitemap.get (same request) — not a distinct v0.1 API operation.',
107
+ },
108
+ ];
package/dist/client.d.ts CHANGED
@@ -13,7 +13,9 @@ export interface ByteKitOptions {
13
13
  baseUrl?: string;
14
14
  /**
15
15
  * Default CLIENT-SIDE request timeout in ms — the abort budget for the whole HTTP
16
- * round-trip made by this SDK. `0` disables the SDK-side timeout. Default 120000.
16
+ * round-trip made by this SDK, covering time-to-headers **and** the response body
17
+ * read (so a large legitimate body counts against it too). `0` disables the SDK-side
18
+ * timeout. Default 120000.
17
19
  *
18
20
  * NOT the same thing as `ScrapeOpts.timeout_ms`, which is the SERVER-side budget the
19
21
  * gateway applies to the scrape itself. Override per call with
@@ -26,12 +28,14 @@ export interface RequestOptions {
26
28
  /**
27
29
  * Caller-owned abort signal. When supplied WITHOUT a per-call `requestTimeoutMs`, it
28
30
  * fully replaces the constructor timeout — supply `requestTimeoutMs` alongside it to
29
- * keep a bound (the request then aborts on whichever fires first).
31
+ * keep a bound (the request then aborts on whichever fires first). Firing it aborts an
32
+ * in-flight response body read as well, not only the wait for headers.
30
33
  */
31
34
  signal?: AbortSignal;
32
35
  /**
33
- * Per-call CLIENT-SIDE abort budget in ms. Wins over the constructor's `timeoutMs`.
34
- * `0` disables the SDK-side timeout for this call.
36
+ * Per-call CLIENT-SIDE abort budget in ms, covering headers **and** the response body
37
+ * read. Wins over the constructor's `timeoutMs`. `0` disables the SDK-side timeout for
38
+ * this call.
35
39
  *
36
40
  * Deliberately NOT named `timeoutMs`: it rides on the same call as `ScrapeOpts.timeout_ms`
37
41
  * (the SERVER-side scrape budget), and the two mean different things —
@@ -40,6 +44,29 @@ export interface RequestOptions {
40
44
  */
41
45
  requestTimeoutMs?: number;
42
46
  }
47
+ /**
48
+ * A JSON response together with its transport metadata (#2547).
49
+ *
50
+ * Returned by {@link ByteKit.requestWithResponse} and by the `*WithResponse` sibling
51
+ * methods. `data` is exactly what the corresponding base method resolves to, so a caller
52
+ * can migrate from `x = await r.get(id)` to `{ data: x, headers } = await
53
+ * r.getWithResponse(id)` with no change to how `x` is used.
54
+ *
55
+ * This is an ADDITIVE surface: it never changes the return type, the request bytes, or the
56
+ * abort semantics of any existing method.
57
+ */
58
+ export interface ApiResponse<T> {
59
+ /** The parsed JSON body — identical to what the base method returns. `undefined` on 204/empty. */
60
+ data: T;
61
+ /**
62
+ * The full RESPONSE headers, e.g. `X-Raw-Bytes`, `X-Billable-Bytes`,
63
+ * `X-Billing-Multiplier`. The `Authorization` header lives on the REQUEST and is never
64
+ * reachable here.
65
+ */
66
+ headers: Headers;
67
+ /** The HTTP status code of the successful response (2xx; non-2xx already threw). */
68
+ status: number;
69
+ }
43
70
  /** Why a request never produced an HTTP response. */
44
71
  export type ConnectionErrorCode = 'timeout' | 'aborted' | 'connection';
45
72
  /** Thrown when the API responds with a 4xx or 5xx status code. */
@@ -91,17 +118,28 @@ export declare class ByteKit {
91
118
  */
92
119
  private resolveSignal;
93
120
  /**
94
- * Perform the authenticated round-trip and return the raw `Response`, having
95
- * already thrown for the two non-body failure modes:
121
+ * Perform the authenticated round-trip and return whatever the caller's `readBody`
122
+ * continuation makes of the successful response, having already thrown for the two
123
+ * non-body failure modes:
96
124
  *
97
125
  * - `ByteKitConnectionError` when no HTTP response was produced at all
98
- * (DNS/socket/TLS failure, client-side timeout, or caller abort).
126
+ * (DNS/socket/TLS failure, client-side timeout, or caller abort) — including one
127
+ * that fires while the body is streaming.
99
128
  * - `ByteKitError` on any 4xx/5xx, carrying the API's `code`/`message`.
100
129
  *
101
- * The body is left unread so each caller can decode it as it needs — JSON for
102
- * `request()`, raw text + headers for `requestRaw()`. This is the single place
103
- * that owns auth, the abort budget, and the error taxonomy; the two public
104
- * transports differ only in how they read a successful body.
130
+ * The body is read through a continuation each caller supplies — JSON text for
131
+ * `request()`, raw bytes for `requestRaw()`. This is the single place that owns auth,
132
+ * the abort budget, and the error taxonomy; the two public transports differ only in
133
+ * how they read a successful body.
134
+ *
135
+ * The abort budget spans headers **and** body (#2546). Returning the `Response` used to
136
+ * end the guarded region at the headers boundary, leaving `response.text()` /
137
+ * `response.arrayBuffer()` — and the `!response.ok` branch's `response.json()` —
138
+ * unbounded: a server that sent headers and then stalled its body hung the caller
139
+ * forever. `readBody` must therefore ONLY consume the stream; `JSON.parse` and
140
+ * `TextDecoder` run in the callers, outside the guard, so a malformed body still
141
+ * surfaces its own parse error rather than being remapped to a connection error.
142
+ * `cleanup()` still runs exactly once per call, now after the body completes.
105
143
  */
106
144
  private send;
107
145
  /**
@@ -135,4 +173,23 @@ export declare class ByteKit {
135
173
  headers: Headers;
136
174
  status: number;
137
175
  }>;
176
+ /**
177
+ * Make an authenticated JSON request and return the parsed body TOGETHER WITH the
178
+ * response headers and status (#2547).
179
+ *
180
+ * Identical to {@link ByteKit.request} in every observable respect — same method, path,
181
+ * body, `RequestOptions` precedence, error taxonomy, and empty-body handling (both drain
182
+ * the body through {@link readSuccessText} and parse it through {@link parseJsonBody}). It
183
+ * differs only in what it returns, so nothing about an existing call changes by switching
184
+ * to it.
185
+ *
186
+ * Use it to read the documented `X-*` metadata the API emits alongside a JSON envelope —
187
+ * `X-Raw-Bytes`, `X-Billable-Bytes`, `X-Billing-Multiplier`, the `X-Screenshot-*` family
188
+ * — for any endpoint. `sitemap.getWithResponse` and `screenshots.createWithResponse` are
189
+ * typed shorthands over this for the endpoints whose headers are documented in the API
190
+ * reference. `screenshots.getWithResponse` is the same shorthand shape applied to
191
+ * `GET /v1/screenshots/{id}`, which the spec declares with NO `X-*` response headers — call
192
+ * it for the typed body, not for header metadata.
193
+ */
194
+ requestWithResponse<T>(method: string, path: string, body?: unknown, options?: RequestOptions): Promise<ApiResponse<T>>;
138
195
  }
package/dist/client.js CHANGED
@@ -124,12 +124,59 @@ function composeSignals(caller, timeout) {
124
124
  }
125
125
  return { signal: controller.signal, cleanup };
126
126
  }
127
- /** Map a `fetch` rejection onto the connection-error taxonomy. Never leaks the API key. */
127
+ /**
128
+ * Read a successful response body as text INSIDE the transport's abort-guarded region
129
+ * (#2546), short-circuiting a 204 to `undefined`. The `JSON.parse` step is deliberately
130
+ * kept OUT of the guard — see {@link parseJsonBody} — so a malformed body still surfaces
131
+ * its own SyntaxError rather than being remapped to a `ByteKitConnectionError`.
132
+ *
133
+ * Shared as the `readBody` continuation of `request()` and (wrapped) `requestWithResponse()`
134
+ * so empty-body/204 handling can never diverge between the two (#2547).
135
+ */
136
+ async function readSuccessText(response) {
137
+ return response.status === 204 ? undefined : await response.text();
138
+ }
139
+ /**
140
+ * Parse an already-read success body (see {@link readSuccessText}) into its JSON value.
141
+ *
142
+ * A 204 or otherwise-empty body resolves to `undefined` — this is how a successful
143
+ * `revoke()` (DELETE → 204) resolves instead of throwing a JSON-parse error. Runs OUTSIDE
144
+ * the transport's abort-guarded region (#2546), so `JSON.parse` failures stay ordinary
145
+ * SyntaxErrors. Shared by `request()` and `requestWithResponse()` (#2547).
146
+ */
147
+ function parseJsonBody(text) {
148
+ if (text === undefined || text.trim().length === 0) {
149
+ return undefined;
150
+ }
151
+ return JSON.parse(text);
152
+ }
153
+ /** The `name` of an error-shaped value, or `undefined` when it carries none. */
154
+ const nameOf = (e) => typeof e === 'object' && e !== null && 'name' in e ? String(e.name) : undefined;
155
+ /** The `cause` of an error-shaped value, or `undefined` when it carries none. */
156
+ const causeOf = (e) => typeof e === 'object' && e !== null && 'cause' in e ? e.cause : undefined;
157
+ /**
158
+ * The abort-taxonomy name of a rejection, seeing through undici's mid-body wrapper.
159
+ *
160
+ * When a request is aborted while its BODY is streaming, undici does not reject with the abort
161
+ * reason itself — it rejects with `TypeError: terminated` carrying the reason on `.cause` (#2546).
162
+ * A name-only check would read that as a plain connection failure. Nothing that reached this
163
+ * function before #2546 carried a `.cause`, so every previously-asserted classification is
164
+ * unchanged; a `.cause` whose own name is not abort-shaped still falls through to `'connection'`.
165
+ */
166
+ function abortNameOf(err) {
167
+ const top = nameOf(err);
168
+ if (top === 'TimeoutError' || top === 'AbortError')
169
+ return top;
170
+ return nameOf(causeOf(err));
171
+ }
172
+ /**
173
+ * Map a `fetch` or body-read rejection onto the connection-error taxonomy. Never leaks the API key.
174
+ */
128
175
  function toConnectionError(err, method, url, timeoutSignal) {
129
176
  // undici rejects an aborted fetch with the signal's own reason, so the thrown value's
130
- // `name` is the primary discriminator (`TimeoutError` / `AbortError`).
131
- const rawName = typeof err === 'object' && err !== null && 'name' in err ? err.name : undefined;
132
- const name = typeof rawName === 'string' ? rawName : '';
177
+ // `name` is the primary discriminator (`TimeoutError` / `AbortError`) — read through
178
+ // undici's mid-body `TypeError: terminated` wrapper when there is one.
179
+ const name = abortNameOf(err) ?? '';
133
180
  let code;
134
181
  if (name === 'TimeoutError') {
135
182
  code = 'timeout';
@@ -225,19 +272,30 @@ export class ByteKit {
225
272
  };
226
273
  }
227
274
  /**
228
- * Perform the authenticated round-trip and return the raw `Response`, having
229
- * already thrown for the two non-body failure modes:
275
+ * Perform the authenticated round-trip and return whatever the caller's `readBody`
276
+ * continuation makes of the successful response, having already thrown for the two
277
+ * non-body failure modes:
230
278
  *
231
279
  * - `ByteKitConnectionError` when no HTTP response was produced at all
232
- * (DNS/socket/TLS failure, client-side timeout, or caller abort).
280
+ * (DNS/socket/TLS failure, client-side timeout, or caller abort) — including one
281
+ * that fires while the body is streaming.
233
282
  * - `ByteKitError` on any 4xx/5xx, carrying the API's `code`/`message`.
234
283
  *
235
- * The body is left unread so each caller can decode it as it needs — JSON for
236
- * `request()`, raw text + headers for `requestRaw()`. This is the single place
237
- * that owns auth, the abort budget, and the error taxonomy; the two public
238
- * transports differ only in how they read a successful body.
284
+ * The body is read through a continuation each caller supplies — JSON text for
285
+ * `request()`, raw bytes for `requestRaw()`. This is the single place that owns auth,
286
+ * the abort budget, and the error taxonomy; the two public transports differ only in
287
+ * how they read a successful body.
288
+ *
289
+ * The abort budget spans headers **and** body (#2546). Returning the `Response` used to
290
+ * end the guarded region at the headers boundary, leaving `response.text()` /
291
+ * `response.arrayBuffer()` — and the `!response.ok` branch's `response.json()` —
292
+ * unbounded: a server that sent headers and then stalled its body hung the caller
293
+ * forever. `readBody` must therefore ONLY consume the stream; `JSON.parse` and
294
+ * `TextDecoder` run in the callers, outside the guard, so a malformed body still
295
+ * surfaces its own parse error rather than being remapped to a connection error.
296
+ * `cleanup()` still runs exactly once per call, now after the body completes.
239
297
  */
240
- async send(method, path, body, options) {
298
+ async send(method, path, body, options, readBody) {
241
299
  const url = `${this.baseUrl}${path}`;
242
300
  const { signal, timeoutSignal, cleanup } = this.resolveSignal(options);
243
301
  const init = {
@@ -254,32 +312,58 @@ export class ByteKit {
254
312
  if (signal !== undefined) {
255
313
  init.signal = signal;
256
314
  }
257
- let response;
258
315
  try {
259
- response = await fetch(url, init);
260
- }
261
- catch (err) {
262
- throw toConnectionError(err, method, url, timeoutSignal);
263
- }
264
- finally {
265
- // Runs on success, on HTTP error, and on abort alike — no dangling timer,
266
- // no listener left behind on the caller's (long-lived) signal.
267
- cleanup();
268
- }
269
- if (!response.ok) {
270
- let code = 'internal_error';
271
- let message = response.statusText;
316
+ let response;
272
317
  try {
273
- const json = (await response.json());
274
- code = json.error?.code ?? code;
275
- message = json.error?.message ?? message;
318
+ response = await fetch(url, init);
319
+ }
320
+ catch (err) {
321
+ throw toConnectionError(err, method, url, timeoutSignal);
276
322
  }
277
- catch {
278
- // leave defaults
323
+ if (!response.ok) {
324
+ let code = 'internal_error';
325
+ let message = response.statusText;
326
+ try {
327
+ const json = (await response.json());
328
+ // Canonical unified envelope. Tested first, so it always wins: the flat
329
+ // form below is strictly a fallback and can never override an envelope
330
+ // (issue #2541). The explicit `!== null` guard matters — `typeof null`
331
+ // is `'object'`.
332
+ if (typeof json.error === 'object' && json.error !== null) {
333
+ code = json.error.code ?? code;
334
+ message = json.error.message ?? message;
335
+ }
336
+ else if (typeof json.error === 'string' && json.error.length > 0) {
337
+ // Flat vendor-masked shape, e.g. /v1/search 502
338
+ // {"error":"search_provider_error"} (docs/api/openapi.yaml §/v1/search
339
+ // 502). The string IS the error code. `message` falls back to it only
340
+ // when the response carried no statusText, so the thrown error is
341
+ // never message-empty.
342
+ code = json.error;
343
+ message = message || json.error;
344
+ }
345
+ }
346
+ catch {
347
+ // leave defaults — including when the error body itself stalls and the
348
+ // budget aborts the read (#2546), so a stuck 500 still surfaces its status.
349
+ }
350
+ throw new ByteKitError(response.status, code, message);
279
351
  }
280
- throw new ByteKitError(response.status, code, message);
352
+ try {
353
+ return await readBody(response);
354
+ }
355
+ catch (err) {
356
+ // A body read that never completes is aborted by the same budget; the runtime
357
+ // then errors the stream (#2546). Map it into the same taxonomy the headers
358
+ // phase uses, so a mid-body timeout/abort is not a bare `TypeError: terminated`.
359
+ throw toConnectionError(err, method, url, timeoutSignal);
360
+ }
361
+ }
362
+ finally {
363
+ // Runs on success (body read included), on HTTP error, and on abort alike — no
364
+ // dangling timer, no listener left behind on the caller's (long-lived) signal.
365
+ cleanup();
281
366
  }
282
- return response;
283
367
  }
284
368
  /**
285
369
  * Make an authenticated JSON request to the ByteKit API.
@@ -289,21 +373,18 @@ export class ByteKit {
289
373
  * timeout, or caller abort).
290
374
  */
291
375
  async request(method, path, body, options) {
292
- const response = await this.send(method, path, body, options);
293
376
  // A 204 No Content has an empty body, and a genuinely empty body can arrive on
294
377
  // other success codes too (defensive). Calling response.json() on an empty body
295
378
  // rejects with a SyntaxError — which is how a successful revoke() (DELETE → 204)
296
379
  // used to throw instead of resolving. Read the body once as text and short-circuit
297
380
  // to `undefined` when it is empty; a JSON-bearing response (scrape.create,
298
381
  // apiKeys.create/get, …) still parses and returns unchanged.
299
- if (response.status === 204) {
300
- return undefined;
301
- }
302
- const text = await response.text();
303
- if (text.trim().length === 0) {
304
- return undefined;
305
- }
306
- return JSON.parse(text);
382
+ //
383
+ // Only the read runs inside the transport's guarded region (#2546) — `JSON.parse`
384
+ // stays out here (in `parseJsonBody`), so a malformed body still throws its own
385
+ // SyntaxError rather than being remapped to a ByteKitConnectionError.
386
+ const text = await this.send(method, path, body, options, readSuccessText);
387
+ return parseJsonBody(text);
307
388
  }
308
389
  /**
309
390
  * Make an authenticated request whose response body IS the content, not a JSON
@@ -323,9 +404,44 @@ export class ByteKit {
323
404
  * content is text.
324
405
  */
325
406
  async requestRaw(method, path, body, options) {
326
- const response = await this.send(method, path, body, options);
327
- const bytes = new Uint8Array(await response.arrayBuffer());
407
+ // As in `request()`: the continuation only drains the stream, so the abort budget
408
+ // covers the read (#2546); the UTF-8 decode happens outside the guarded region.
409
+ const { bytes, headers, status } = await this.send(method, path, body, options, async (response) => ({
410
+ bytes: new Uint8Array(await response.arrayBuffer()),
411
+ headers: response.headers,
412
+ status: response.status,
413
+ }));
328
414
  const text = new TextDecoder('utf-8').decode(bytes);
329
- return { body: text, bytes, headers: response.headers, status: response.status };
415
+ return { body: text, bytes, headers, status };
416
+ }
417
+ /**
418
+ * Make an authenticated JSON request and return the parsed body TOGETHER WITH the
419
+ * response headers and status (#2547).
420
+ *
421
+ * Identical to {@link ByteKit.request} in every observable respect — same method, path,
422
+ * body, `RequestOptions` precedence, error taxonomy, and empty-body handling (both drain
423
+ * the body through {@link readSuccessText} and parse it through {@link parseJsonBody}). It
424
+ * differs only in what it returns, so nothing about an existing call changes by switching
425
+ * to it.
426
+ *
427
+ * Use it to read the documented `X-*` metadata the API emits alongside a JSON envelope —
428
+ * `X-Raw-Bytes`, `X-Billable-Bytes`, `X-Billing-Multiplier`, the `X-Screenshot-*` family
429
+ * — for any endpoint. `sitemap.getWithResponse` and `screenshots.createWithResponse` are
430
+ * typed shorthands over this for the endpoints whose headers are documented in the API
431
+ * reference. `screenshots.getWithResponse` is the same shorthand shape applied to
432
+ * `GET /v1/screenshots/{id}`, which the spec declares with NO `X-*` response headers — call
433
+ * it for the typed body, not for header metadata.
434
+ */
435
+ async requestWithResponse(method, path, body, options) {
436
+ // Same continuation shape as request()/requestRaw() (#2546): the readBody continuation
437
+ // ONLY drains the stream (and captures headers/status) inside the abort-guarded region,
438
+ // so a mid-body stall counts against the budget. `JSON.parse` runs afterwards, outside
439
+ // the guard, in `parseJsonBody` — a malformed body stays a SyntaxError.
440
+ const { text, headers, status } = await this.send(method, path, body, options, async (response) => ({
441
+ text: await readSuccessText(response),
442
+ headers: response.headers,
443
+ status: response.status,
444
+ }));
445
+ return { data: parseJsonBody(text), headers, status };
330
446
  }
331
447
  }