@hunt-labs/bytekit-sdk 0.4.0 → 0.6.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,126 @@ 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.6.0] - 2026-08-07
12
+
13
+ ### Added
14
+
15
+ - **`ByteKitError.details`** — the API error envelope's structured rejection reason is now
16
+ reachable from the thrown error. A `422 validation_error` carries
17
+ `{ formErrors, fieldErrors }`, so a caller can finally read WHICH request field was
18
+ rejected rather than only that the request was invalid; a `400 invalid_url` carries a flat
19
+ `{ url, field, reason }`. Typed as `ByteKitErrorDetails | undefined` where
20
+ `ByteKitErrorDetails = Record<string, unknown>` (also exported), deliberately loose because
21
+ the contents vary by error code — narrow the member you read. Strictly ADDITIVE: `details`
22
+ is an optional FOURTH constructor argument, so existing three-argument
23
+ `new ByteKitError(status, code, message)` construction is unchanged, and `status` / `code` /
24
+ `message` are byte-identical. Populated only from the canonical `{ error: { … } }`
25
+ envelope — a flat vendor-masked body (`/v1/search` `502 {"error":"search_provider_error"}`)
26
+ carries no structured detail and grows none. `http_status` and `failed_at` are deliberately
27
+ NOT exposed: `http_status` would duplicate the existing `status` property, and a duplicated
28
+ field that can disagree with its twin is a bug surface. (#2685)
29
+
30
+ - A `tsc --noEmit`-checked parity pin in `src/types.ts` ties the hand-authored search types
31
+ (`SearchOpts`, `SearchResult`, `SearchResultImage`, `SearchResponse` — the SDK's only
32
+ hand-authored request/response types) to the generated `operations['createSearch']`
33
+ request and `200` response. It asserts key-set and optionality parity in both directions
34
+ and assignability in each meaningful direction, so a future spec change that search's
35
+ types do not follow fails the build instead of shipping. Value types are erased before the
36
+ parity comparison, so a deliberate hand-authored refinement (a branded id, a narrowed
37
+ union) cannot trip it. Type-level only — the assertions erase at emit and add nothing to
38
+ `dist/`. (#2678)
39
+
40
+ ### Fixed
41
+
42
+ - **README: the `fetch` sample's `res.cache` type is corrected to include `null`.** The
43
+ documented type read `"hit" | "miss" | "bypass"`, but `FetchResult.cache` is
44
+ `FetchCacheStatus | null` (null when the response carried no recognizable `X-Fetch-Cache`
45
+ header) — the one strict-`tsc` error a real consumption file copied from the README ever
46
+ produced. The line is now a TYPED ASSIGNMENT rather than a comment, so the README compile
47
+ guard (`src/__tests__/readme-compiles.test.ts`) typechecks it: comment-vs-type drift on
48
+ this field now fails the build instead of shipping. Documentation only — no runtime or type
49
+ change. (#2685)
50
+
51
+ - **CHANGELOG: the `[0.1.0]` heading's malformed `- 2026` date is now `- 2026-04-24`**, the
52
+ commit date of `caf17e56b`, which introduced `packages/sdk-typescript/package.json` at
53
+ version `0.1.0`. Every other heading in this file already carried a full ISO date; this one
54
+ broke the Keep a Changelog `## [x.y.z] - YYYY-MM-DD` form. (#2685)
55
+
56
+ - **Test-only: nine SDK test files mocked wire bodies the API can never send.** A
57
+ `monitors.create` 200 (the operation declares 201 as its only success arm), a scrape 200
58
+ carrying `data: { markdown }` (the envelope has no `data` member — content lives under
59
+ `formats`), a `screenshots.getWithResponse` mock asserting `X-Screenshot-*` headers that
60
+ `GET /v1/screenshots/{id}` declares on no arm, error bodies omitting the `Error` schema's
61
+ required `status: 'failed'` / `http_status` / `failed_at`, and twelve `402`/`429` arms on
62
+ operations that declare neither status. Each is rewritten to a spec-valid shape with a
63
+ comment naming the arm it now matches, and every test keeps its original assertion target.
64
+ No shipped code changed. (#2685)
65
+
66
+ - **`SearchResult.snippet` is now optional (`snippet?: string`) — BREAKING AT COMPILE TIME.**
67
+ `type: 'images'` search results omit `snippet` entirely (the spec has always declared a
68
+ result's `required` set as `[position, title, url]`), but the SDK typed it as a required
69
+ `string`. `hit.snippet.length` therefore compiled under strict TypeScript and threw
70
+ `TypeError: Cannot read properties of undefined` on every images hit — the TypeScript twin
71
+ of the Python SDK's #2579 images `KeyError`. **Consumers reading `.snippet` unguarded now
72
+ see `string | undefined`** and must narrow it (`if (typeof hit.snippet === 'string')` or
73
+ `hit.snippet ?? ''`), exactly as they already do for `date`. No runtime behavior and no
74
+ wire bytes change: the field was already absent from those payloads. `web`/`news` results
75
+ are unaffected — they still carry `snippet`, and a narrowed read yields the same string.
76
+ (#2678)
77
+
78
+ - **Path parameters are now percent-encoded.** Every id-bearing method built its request path
79
+ with a raw template literal, so a crafted or interpolated id silently retargeted the
80
+ request: `scrape.get('sc_/../../account')` addressed `GET /v1/account` once WHATWG URL
81
+ parsing collapsed the `../` segments, and `monitors.get('mon_x?admin=1#frag')` turned the id
82
+ into a real query string plus fragment. The branded id types constrain only the PREFIX of an
83
+ id, never its tail, so they were never a defence. An id now travels as one percent-encoded
84
+ path segment — a malformed one 404s server-side instead of reaching a different resource.
85
+ Ports the Python SDK's #2593 fix. (#2677)
86
+
87
+ ### Changed
88
+
89
+ - Request paths are assembled by a single internal `buildPath()` helper in `client.ts` rather
90
+ than by per-resource template literals. It encodes SEGMENTS only — every fixed segment and
91
+ every `/` separator stays literal — and encodes each value exactly once, so an id that
92
+ already contains a `%` is not double-encoded. **A well-formed id (`sc_<hex>`, `mon_<hex>`, …)
93
+ produces a byte-identical URL to before**, on every id-bearing method: encoding is a no-op
94
+ for those characters, so no existing call changes on the wire. (#2677)
95
+
96
+ - Four of the guards shipped above could not fail on the change they appeared to protect, and
97
+ now can. `buildPath()`'s two `TypeError` throws — the fail-loud that replaced the
98
+ compile-time checking a template literal used to give — each survived its own removal with
99
+ the whole suite green, and are now pinned by tests separated by input shape rather than by
100
+ message text. The in-suite path-helper gate read its directory non-recursively while the
101
+ shell grep it reproduces is `-r`, so an offender one directory down was invisible to it; it
102
+ now walks the whole of `src/`, a strict superset of the shell gate's set. The `images`
103
+ search-result literal's key ABSENCE (no `snippet`, no `date`) was held only by a
104
+ one-directional subtype check, which an extra key cannot break; it is now pinned by explicit
105
+ key-absence assertions plus an exact key-set assertion. And the spec's `error.details` —
106
+ read at one site in `client.ts` off a hand-written inline type — is now tied to the
107
+ generated schema by a PRESENCE pin, so dropping the field from `docs/api/openapi.yaml` fails
108
+ the build. Every pin is key-level, never value-level, so a deliberate hand-authored value
109
+ refinement still cannot trip it. Test- and type-level only: no runtime behavior, no exported
110
+ type, and nothing in `dist/` changes. (#2793)
111
+
112
+ ## [0.5.0] - 2026-07-29
113
+
114
+ ### Added
115
+
116
+ - `src/generated/api.d.ts` now models the `400` responses that the gateway's new
117
+ request-body-size guards can return. `operations['stripeWebhook']['responses']` gains a
118
+ `400` with this route's bespoke `{ error?: string }` body, and
119
+ `operations['retryWebhookDelivery']['responses']` gains a `400` carrying the unified
120
+ `components['schemas']['Error']` envelope. Both are purely additive — no existing member
121
+ changed shape — so every current call site still compiles. (#2640)
122
+
123
+ ### Changed
124
+
125
+ - Regenerated `src/generated/api.d.ts` against the merged spec. Beyond the two added `400`
126
+ members above, the delta is JSDoc only: every guarded endpoint's description now carries a
127
+ `**Limits:**` block stating its byte ceiling and the `request_too_large` error code
128
+ (#2640), and `POST /v1/bulk`'s description documents `defaults.type` inheritance and the
129
+ 422 on unrecognized keys (#2643). No runtime code changed. (#2640, #2643)
130
+
11
131
  ## [0.4.0] - 2026-07-24
12
132
 
13
133
  ### Added
@@ -133,7 +253,12 @@ today, see [README.md](./README.md), which remains the authoritative usage guide
133
253
  rebuilding the shared `dist`), which is now fixed. No consumer-facing changes
134
254
  beyond 0.2.1.
135
255
 
136
- ## [0.2.1] - 2026-07-16
256
+ ## [0.2.1] - 2026-07-16 [NEVER PUBLISHED]
257
+
258
+ > **Never published to npm.** This version was cut in-tree but no `@hunt-labs/bytekit-sdk@0.2.1`
259
+ > tarball exists — `npm i @hunt-labs/bytekit-sdk@0.2.1` fails. Everything below shipped to users in
260
+ > **0.2.2**, which is the first published release containing it. Reconciled by issue #2680; the
261
+ > `release-drift` CI gate now blocks a new phantom heading from being introduced.
137
262
 
138
263
  ### Changed
139
264
 
@@ -193,7 +318,7 @@ against 0.1.0 — read each one before upgrading.
193
318
  - Publish-ready packaging metadata: types-first `exports`, `engines`, `keywords`,
194
319
  `homepage`, `bugs`, `README.md`, `LICENSE`, and an MIT `license` field.
195
320
 
196
- ## [0.1.0] - 2026
321
+ ## [0.1.0] - 2026-04-24
197
322
 
198
323
  ### Added
199
324
 
@@ -201,6 +326,9 @@ against 0.1.0 — read each one before upgrading.
201
326
  (scrape, screenshot, search, and related endpoints), published under the original
202
327
  `@rapidcrawl/sdk` name.
203
328
 
329
+ [0.6.0]: https://github.com/Hunt-Labs-Inc/ByteKit/tree/main/packages/sdk-typescript
330
+ [0.5.0]: https://github.com/Hunt-Labs-Inc/ByteKit/tree/main/packages/sdk-typescript
331
+ [0.4.0]: https://github.com/Hunt-Labs-Inc/ByteKit/tree/main/packages/sdk-typescript
204
332
  [0.3.0]: https://github.com/Hunt-Labs-Inc/ByteKit/tree/main/packages/sdk-typescript
205
333
  [0.2.2]: https://github.com/Hunt-Labs-Inc/ByteKit/tree/main/packages/sdk-typescript
206
334
  [0.2.1]: https://github.com/Hunt-Labs-Inc/ByteKit/tree/main/packages/sdk-typescript
package/README.md CHANGED
@@ -77,16 +77,24 @@ binary content (images, PDFs, etc. — `/v1/fetch` supports raw passthrough)
77
77
  use `bytes` instead, which is byte-for-byte identical to the wire content:
78
78
 
79
79
  ```ts
80
+ import type { FetchCacheStatus } from '@hunt-labs/bytekit-sdk';
81
+
80
82
  const res = await client.fetch.create({ url: 'https://example.com' });
81
83
 
82
84
  console.log(res.body); // raw content (e.g. "<!doctype html>…") — UTF-8 decoded
83
85
  console.log(res.bytes); // Uint8Array — byte-exact, safe for binary content
84
86
  console.log(res.id); // X-Fetch-ID, e.g. "ft_0123abcd…"
85
87
  console.log(res.creditsCharged); // X-Fetch-Credits-Charged, as a number
86
- console.log(res.cache); // "hit" | "miss" | "bypass"
87
88
  console.log(res.custom); // base64 X-Fetch-Custom echo, or null
88
89
  console.log(res.contentType); // response Content-Type, e.g. "text/html"
89
90
 
91
+ // `cache` is NULLABLE — null when the response carried no X-Fetch-Cache header
92
+ // (or an unrecognized value), so narrow it before use. The annotation is
93
+ // deliberate: the README compile guard typechecks this line, so the documented
94
+ // type can no longer drift from the real one.
95
+ const cache: FetchCacheStatus | null = res.cache; // "hit" | "miss" | "bypass" | null
96
+ if (cache !== null) console.log(cache);
97
+
90
98
  // Anything not surfaced as a named field is on res.headers:
91
99
  console.log(res.headers.get('X-Fetch-Waf-Vendor'));
92
100
  ```
@@ -97,6 +105,9 @@ console.log(res.headers.get('X-Fetch-Waf-Vendor'));
97
105
  const results = await client.search.query({ query: 'ai web scraping', limit: 10 });
98
106
  for (const hit of results.results) {
99
107
  console.log(hit.position, hit.title, hit.url);
108
+ // `snippet` and `date` are present for `web`/`news` results and omitted
109
+ // entirely for `type: 'images'` — read them behind a guard.
110
+ if (typeof hit.snippet === 'string') console.log(hit.snippet);
100
111
  }
101
112
  ```
102
113
 
@@ -185,12 +196,15 @@ const deliveries = await client.webhooks.list();
185
196
  await client.webhooks.retry('wh_123abc');
186
197
  ```
187
198
 
188
- > **Note:** `client.account.*` (including `client.account.apiKeys.*`) targets the
189
- > `/v1/account/*` endpoints, which authenticate with a Clerk dashboard session —
190
- > **not** a Bearer API key. Calling them with an API-key client will fail auth.
191
- > They remain on the SDK surface for dashboard/session consumers and are marked
192
- > `x-sdk-scope: internal` in the spec, so they are intentionally excluded from the
193
- > v0.1 Bearer-key parity guarantee.
199
+ > **Note (the real auth split):** `account.get()` authenticates with the same Bearer
200
+ > API key as every other v0.1 method above — it succeeds normally. `client.account.apiKeys.*`
201
+ > is different: it targets `/v1/account/api-keys*`, which authenticates with a Clerk
202
+ > dashboard session, **not** a Bearer API key — calling any `apiKeys.*` method with an
203
+ > API-key client fails auth. `apiKeys.*` remains on the SDK surface for dashboard/session
204
+ > consumers (deliberate retention — see `DOCUMENTED_SURFACE_EXCEPTIONS` in
205
+ > `src/allowlist.ts`) and is marked `x-sdk-scope: internal` in the spec, so it is
206
+ > intentionally excluded from the v0.1 Bearer-key parity guarantee. `account.get()` is not:
207
+ > it is a real `V01_METHODS` entry.
194
208
 
195
209
  ### Response headers
196
210
 
@@ -207,8 +221,12 @@ console.log(headers.get('X-Raw-Bytes')); // e.g. "12345"
207
221
  console.log(status); // 200
208
222
  ```
209
223
 
210
- `client.screenshots.createWithResponse()` and `client.screenshots.getWithResponse()`
211
- work the same way. Any other endpoint's headers are reachable through the generic
224
+ `client.screenshots.createWithResponse()` works the same way, and is where the
225
+ `X-Screenshot-*`/`X-Raw-Bytes`/`X-Billable-Bytes`/`X-Billing-Multiplier` headers
226
+ actually live — they're declared on `POST /v1/screenshots` only. Its sibling
227
+ `client.screenshots.getWithResponse()` is still additive and safe to call, but
228
+ `GET /v1/screenshots/{id}` emits none of those headers, so `headers` will be
229
+ empty for it. Any other endpoint's headers are reachable through the generic
212
230
  transport, `client.requestWithResponse('GET', '/v1/scrape/sc_123abc')`.
213
231
 
214
232
  These siblings are purely additive: they issue the identical request and accept the
@@ -232,6 +250,42 @@ try {
232
250
  }
233
251
  ```
234
252
 
253
+ ### Reading which field was rejected
254
+
255
+ `err.details` carries the API envelope's structured rejection reason when the
256
+ response supplied one, and is `undefined` otherwise. Its type is
257
+ `ByteKitErrorDetails | undefined`, where `ByteKitErrorDetails` is
258
+ `Record<string, unknown>` — deliberately loose, because the contents vary by
259
+ error code:
260
+
261
+ - a `422 validation_error` carries `{ formErrors, fieldErrors }`, and
262
+ `fieldErrors` names exactly which request field failed;
263
+ - a `400 invalid_url` carries a flat `{ url, field, reason }`.
264
+
265
+ Narrow the member you want before using it:
266
+
267
+ ```ts
268
+ import { ByteKit, ByteKitError } from '@hunt-labs/bytekit-sdk';
269
+
270
+ try {
271
+ await client.scrape.create({ url: 'https://example.com' });
272
+ } catch (err) {
273
+ if (err instanceof ByteKitError && err.status === 422) {
274
+ const fieldErrors = err.details?.['fieldErrors'];
275
+ if (typeof fieldErrors === 'object' && fieldErrors !== null) {
276
+ // e.g. { formats: ["Invalid enum value. Expected 'markdown' | …"] }
277
+ console.error(Object.keys(fieldErrors));
278
+ }
279
+ }
280
+ }
281
+ ```
282
+
283
+ Note that `details` is populated only from the API's canonical
284
+ `{ error: { … } }` envelope. A few endpoints answer with a flat, vendor-masked
285
+ body instead (`/v1/search` returns `502 {"error":"search_provider_error"}`);
286
+ those carry no structured detail, so `err.details` stays `undefined` there —
287
+ `err.code` is still the machine-readable reason.
288
+
235
289
  ## Documentation
236
290
 
237
291
  Full API reference: [bytekit.com/docs](https://bytekit.com/docs).
@@ -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
@@ -69,11 +69,57 @@ export interface ApiResponse<T> {
69
69
  }
70
70
  /** Why a request never produced an HTTP response. */
71
71
  export type ConnectionErrorCode = 'timeout' | 'aborted' | 'connection';
72
+ /**
73
+ * Structured, machine-readable detail about WHY a request was rejected — the
74
+ * `error.details` member of the API's canonical error envelope
75
+ * (`docs/api/openapi.yaml` §components.schemas.Error, issue #2685).
76
+ *
77
+ * The spec declares the field with no schema at all ("Optional structured details
78
+ * (e.g. which fields failed validation)"), because its contents vary by error code.
79
+ * Two shapes are live today and both must survive unchanged:
80
+ *
81
+ * - A 422 `validation_error` carries zod's `flatten()` output —
82
+ * `{ formErrors: string[], fieldErrors: Record<string, string[]> }` — which is
83
+ * the shape that names WHICH field failed.
84
+ * - A 400 `invalid_url` carries a flat record — `{ url, field, reason }` — with
85
+ * none of zod's keys.
86
+ *
87
+ * It is therefore typed as a loose record rather than a union of the shapes seen
88
+ * so far: a narrower type would have to drop or mangle the next variant the API
89
+ * adds, and the SDK must never lose information the envelope carried. Read a
90
+ * member and narrow it yourself:
91
+ *
92
+ * ```ts
93
+ * const fieldErrors = err.details?.['fieldErrors'];
94
+ * if (fieldErrors !== undefined && typeof fieldErrors === 'object') { … }
95
+ * ```
96
+ */
97
+ export type ByteKitErrorDetails = Record<string, unknown>;
72
98
  /** Thrown when the API responds with a 4xx or 5xx status code. */
73
99
  export declare class ByteKitError extends Error {
74
100
  readonly status: number;
75
101
  readonly code: string;
76
- constructor(status: number, code: string, message: string);
102
+ /**
103
+ * The envelope's `error.details`, when it carried one — `undefined` otherwise.
104
+ *
105
+ * Populated ONLY from the canonical `{ error: { … } }` envelope. A flat
106
+ * vendor-masked body (`/v1/search` 502 `{"error":"search_provider_error"}`)
107
+ * has no envelope to read a detail from, so it never grows one (#2685 E3).
108
+ *
109
+ * A `details` that is not a JSON object — which the untyped spec field
110
+ * technically permits — is dropped rather than surfaced, because the declared
111
+ * type promises something indexable and a value that contradicts its own type
112
+ * is worse than an absent one.
113
+ *
114
+ * `declare` is load-bearing. `target: ES2022` turns on `useDefineForClassFields`,
115
+ * so a plain `details?: …` declaration would emit a class field and DEFINE the
116
+ * key as `undefined` on every instance — every error the SDK has ever thrown
117
+ * would silently grow a `details` key, changing `'details' in err`, spreads, and
118
+ * `JSON.stringify` output for errors that carry none. `declare` emits no field,
119
+ * so the key exists only when the envelope really supplied one.
120
+ */
121
+ readonly details?: ByteKitErrorDetails;
122
+ constructor(status: number, code: string, message: string, details?: ByteKitErrorDetails);
77
123
  }
78
124
  /**
79
125
  * Thrown when the request never produced an HTTP response at all — DNS, socket, TLS,
@@ -88,6 +134,43 @@ export declare class ByteKitConnectionError extends Error {
88
134
  cause?: unknown;
89
135
  });
90
136
  }
137
+ /**
138
+ * Assemble an API request path from a fixed template and caller-supplied dynamic segments
139
+ * (issue #2677).
140
+ *
141
+ * This is the SINGLE place the SDK turns a caller-supplied id into a URL, mirroring the
142
+ * Python SDK's single-site fix (#2593). Every resource routes through it; no resource
143
+ * interpolates an id into a path template of its own. Before it existed, every path was a
144
+ * raw template literal, so a crafted or interpolated id silently retargeted the request —
145
+ * `scrape.get('sc_/../../account')` addressed `GET /v1/account` once WHATWG URL parsing
146
+ * collapsed the `../` segments, and an id carrying `?` or `#` grew a real query string and
147
+ * fragment. Branded id types (`sc_${string}`) constrain the PREFIX, never the tail, so they
148
+ * were never a defence.
149
+ *
150
+ * `template` is an OpenAPI-style path whose `{name}` placeholders are the only parts that
151
+ * get encoded. Everything else — including every `/` separator and every fixed segment —
152
+ * is emitted verbatim: the helper encodes SEGMENTS, never the assembled path. Lifting the
153
+ * encoding to the whole string would turn `/v1/scrape/{id}` into one giant escaped segment
154
+ * and break every request.
155
+ *
156
+ * Each value is passed through `encodeURIComponent` EXACTLY once, so an id that already
157
+ * contains a `%` becomes `%25…` rather than being double-encoded.
158
+ *
159
+ * `query` is appended verbatim after the path. It is the caller's job to hand over an
160
+ * ALREADY-ENCODED string — `toQueryString()` and `URLSearchParams.toString()` both do that
161
+ * — because a query string is a composed `?a=1&b=2` structure whose separators must survive,
162
+ * exactly as the path's separators do.
163
+ *
164
+ * A `{name}` with no matching value, or a value with no matching `{name}`, throws a
165
+ * `TypeError`: a typo in either half would otherwise ship a literal `{id}` in the URL or
166
+ * silently drop the id.
167
+ */
168
+ export declare function buildPath(template: string, options?: {
169
+ /** Dynamic path segments by placeholder name. Each is percent-encoded exactly once. */
170
+ readonly segments?: Readonly<Record<string, string>>;
171
+ /** An already-encoded query string, including its leading `?`. Appended verbatim. */
172
+ readonly query?: string;
173
+ }): string;
91
174
  export declare class ByteKit {
92
175
  private readonly apiKey;
93
176
  private readonly baseUrl;
@@ -125,7 +208,8 @@ export declare class ByteKit {
125
208
  * - `ByteKitConnectionError` when no HTTP response was produced at all
126
209
  * (DNS/socket/TLS failure, client-side timeout, or caller abort) — including one
127
210
  * that fires while the body is streaming.
128
- * - `ByteKitError` on any 4xx/5xx, carrying the API's `code`/`message`.
211
+ * - `ByteKitError` on any 4xx/5xx, carrying the API's `code`/`message` — plus the
212
+ * envelope's structured `details` when it carried one (#2685).
129
213
  *
130
214
  * The body is read through a continuation each caller supplies — JSON text for
131
215
  * `request()`, raw bytes for `requestRaw()`. This is the single place that owns auth,
@@ -185,9 +269,11 @@ export declare class ByteKit {
185
269
  *
186
270
  * Use it to read the documented `X-*` metadata the API emits alongside a JSON envelope —
187
271
  * `X-Raw-Bytes`, `X-Billable-Bytes`, `X-Billing-Multiplier`, the `X-Screenshot-*` family
188
- * — for any endpoint. `sitemap.getWithResponse`, `screenshots.createWithResponse` and
189
- * `screenshots.getWithResponse` are typed shorthands over this for the endpoints whose
190
- * headers are documented in the API reference.
272
+ * — for any endpoint. `sitemap.getWithResponse` and `screenshots.createWithResponse` are
273
+ * typed shorthands over this for the endpoints whose headers are documented in the API
274
+ * reference. `screenshots.getWithResponse` is the same shorthand shape applied to
275
+ * `GET /v1/screenshots/{id}`, which the spec declares with NO `X-*` response headers — call
276
+ * it for the typed body, not for header metadata.
191
277
  */
192
278
  requestWithResponse<T>(method: string, path: string, body?: unknown, options?: RequestOptions): Promise<ApiResponse<T>>;
193
279
  }
package/dist/client.js CHANGED
@@ -21,11 +21,16 @@ const DEFAULT_TIMEOUT_MS = 120_000;
21
21
  export class ByteKitError extends Error {
22
22
  status;
23
23
  code;
24
- constructor(status, code, message) {
24
+ constructor(status, code, message, details) {
25
25
  super(message);
26
26
  this.name = 'ByteKitError';
27
27
  this.status = status;
28
28
  this.code = code;
29
+ // Assigned conditionally under `exactOptionalPropertyTypes`: a three-argument
30
+ // construction leaves the instance without the key at all, exactly as before.
31
+ if (details !== undefined) {
32
+ this.details = details;
33
+ }
29
34
  }
30
35
  }
31
36
  /**
@@ -44,6 +49,59 @@ export class ByteKitConnectionError extends Error {
44
49
  }
45
50
  }
46
51
  const NOOP = () => { };
52
+ /** A `{name}` placeholder in a {@link buildPath} template. */
53
+ const PATH_PLACEHOLDER = /\{([A-Za-z_][A-Za-z0-9_]*)\}/g;
54
+ /**
55
+ * Assemble an API request path from a fixed template and caller-supplied dynamic segments
56
+ * (issue #2677).
57
+ *
58
+ * This is the SINGLE place the SDK turns a caller-supplied id into a URL, mirroring the
59
+ * Python SDK's single-site fix (#2593). Every resource routes through it; no resource
60
+ * interpolates an id into a path template of its own. Before it existed, every path was a
61
+ * raw template literal, so a crafted or interpolated id silently retargeted the request —
62
+ * `scrape.get('sc_/../../account')` addressed `GET /v1/account` once WHATWG URL parsing
63
+ * collapsed the `../` segments, and an id carrying `?` or `#` grew a real query string and
64
+ * fragment. Branded id types (`sc_${string}`) constrain the PREFIX, never the tail, so they
65
+ * were never a defence.
66
+ *
67
+ * `template` is an OpenAPI-style path whose `{name}` placeholders are the only parts that
68
+ * get encoded. Everything else — including every `/` separator and every fixed segment —
69
+ * is emitted verbatim: the helper encodes SEGMENTS, never the assembled path. Lifting the
70
+ * encoding to the whole string would turn `/v1/scrape/{id}` into one giant escaped segment
71
+ * and break every request.
72
+ *
73
+ * Each value is passed through `encodeURIComponent` EXACTLY once, so an id that already
74
+ * contains a `%` becomes `%25…` rather than being double-encoded.
75
+ *
76
+ * `query` is appended verbatim after the path. It is the caller's job to hand over an
77
+ * ALREADY-ENCODED string — `toQueryString()` and `URLSearchParams.toString()` both do that
78
+ * — because a query string is a composed `?a=1&b=2` structure whose separators must survive,
79
+ * exactly as the path's separators do.
80
+ *
81
+ * A `{name}` with no matching value, or a value with no matching `{name}`, throws a
82
+ * `TypeError`: a typo in either half would otherwise ship a literal `{id}` in the URL or
83
+ * silently drop the id.
84
+ */
85
+ export function buildPath(template, options) {
86
+ const segments = options?.segments;
87
+ const filled = new Set();
88
+ const path = template.replace(PATH_PLACEHOLDER, (_match, name) => {
89
+ const value = segments === undefined ? undefined : segments[name];
90
+ if (value === undefined) {
91
+ throw new TypeError(`ByteKit: no value supplied for path segment "${name}" in "${template}"`);
92
+ }
93
+ filled.add(name);
94
+ return encodeURIComponent(String(value));
95
+ });
96
+ if (segments !== undefined) {
97
+ for (const name of Object.keys(segments)) {
98
+ if (!filled.has(name)) {
99
+ throw new TypeError(`ByteKit: path template "${template}" has no "{${name}}" placeholder`);
100
+ }
101
+ }
102
+ }
103
+ return `${path}${options?.query ?? ''}`;
104
+ }
47
105
  /**
48
106
  * Validate a timeout option. `undefined` passes through; anything that is not a
49
107
  * non-negative finite number throws a `TypeError` naming the option as the caller
@@ -279,7 +337,8 @@ export class ByteKit {
279
337
  * - `ByteKitConnectionError` when no HTTP response was produced at all
280
338
  * (DNS/socket/TLS failure, client-side timeout, or caller abort) — including one
281
339
  * that fires while the body is streaming.
282
- * - `ByteKitError` on any 4xx/5xx, carrying the API's `code`/`message`.
340
+ * - `ByteKitError` on any 4xx/5xx, carrying the API's `code`/`message` — plus the
341
+ * envelope's structured `details` when it carried one (#2685).
283
342
  *
284
343
  * The body is read through a continuation each caller supplies — JSON text for
285
344
  * `request()`, raw bytes for `requestRaw()`. This is the single place that owns auth,
@@ -323,6 +382,7 @@ export class ByteKit {
323
382
  if (!response.ok) {
324
383
  let code = 'internal_error';
325
384
  let message = response.statusText;
385
+ let details;
326
386
  try {
327
387
  const json = (await response.json());
328
388
  // Canonical unified envelope. Tested first, so it always wins: the flat
@@ -332,6 +392,15 @@ export class ByteKit {
332
392
  if (typeof json.error === 'object' && json.error !== null) {
333
393
  code = json.error.code ?? code;
334
394
  message = json.error.message ?? message;
395
+ // Structured rejection reason (issue #2685). This is the ONLY site
396
+ // that reads it: the flat branch below has no envelope to read from
397
+ // and must never grow a phantom `details` (E3). Arrays and scalars
398
+ // are rejected because `ByteKitErrorDetails` promises an indexable
399
+ // record — see the type's doc comment.
400
+ const raw = json.error.details;
401
+ if (typeof raw === 'object' && raw !== null && !Array.isArray(raw)) {
402
+ details = raw;
403
+ }
335
404
  }
336
405
  else if (typeof json.error === 'string' && json.error.length > 0) {
337
406
  // Flat vendor-masked shape, e.g. /v1/search 502
@@ -347,7 +416,7 @@ export class ByteKit {
347
416
  // leave defaults — including when the error body itself stalls and the
348
417
  // budget aborts the read (#2546), so a stuck 500 still surfaces its status.
349
418
  }
350
- throw new ByteKitError(response.status, code, message);
419
+ throw new ByteKitError(response.status, code, message, details);
351
420
  }
352
421
  try {
353
422
  return await readBody(response);
@@ -426,9 +495,11 @@ export class ByteKit {
426
495
  *
427
496
  * Use it to read the documented `X-*` metadata the API emits alongside a JSON envelope —
428
497
  * `X-Raw-Bytes`, `X-Billable-Bytes`, `X-Billing-Multiplier`, the `X-Screenshot-*` family
429
- * — for any endpoint. `sitemap.getWithResponse`, `screenshots.createWithResponse` and
430
- * `screenshots.getWithResponse` are typed shorthands over this for the endpoints whose
431
- * headers are documented in the API reference.
498
+ * — for any endpoint. `sitemap.getWithResponse` and `screenshots.createWithResponse` are
499
+ * typed shorthands over this for the endpoints whose headers are documented in the API
500
+ * reference. `screenshots.getWithResponse` is the same shorthand shape applied to
501
+ * `GET /v1/screenshots/{id}`, which the spec declares with NO `X-*` response headers — call
502
+ * it for the typed body, not for header metadata.
432
503
  */
433
504
  async requestWithResponse(method, path, body, options) {
434
505
  // Same continuation shape as request()/requestRaw() (#2546): the readBody continuation