@billkit-eu/sdk 0.1.0 → 0.2.1

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/src/transport.ts CHANGED
@@ -33,6 +33,14 @@ export interface RequestOptions {
33
33
  body?: Record<string, unknown> | undefined;
34
34
  idempotencyKey?: string | undefined;
35
35
  extraHeaders?: Record<string, string>;
36
+ /**
37
+ * How to read a **successful** response body. `"json"` (the default)
38
+ * parses it; `"binary"` hands back the raw `ArrayBuffer`, for
39
+ * endpoints that serve a document rather than a resource (the invoice
40
+ * PDF). Error responses are always read as JSON either way, so the
41
+ * typed error hierarchy behaves identically on both paths.
42
+ */
43
+ responseType?: "json" | "binary";
36
44
  }
37
45
 
38
46
  export interface TransportConfig {
@@ -126,8 +134,7 @@ function buildHeaders(
126
134
  return headers;
127
135
  }
128
136
 
129
- async function parseJson(response: Response): Promise<unknown> {
130
- const text = await response.text();
137
+ function parseJsonText(text: string): unknown {
131
138
  if (!text) return null;
132
139
  try {
133
140
  return JSON.parse(text);
@@ -136,6 +143,32 @@ async function parseJson(response: Response): Promise<unknown> {
136
143
  }
137
144
  }
138
145
 
146
+ async function parseJson(response: Response): Promise<unknown> {
147
+ return parseJsonText(await response.text());
148
+ }
149
+
150
+ /**
151
+ * Read the body once, as the caller asked for it.
152
+ *
153
+ * A `Response` body can only be consumed once, so the choice has to be
154
+ * made here rather than after the status check. On the binary path a
155
+ * *failed* response is still decoded as UTF-8 JSON: an error is an error
156
+ * envelope no matter which endpoint produced it, and losing that would
157
+ * mean the PDF call throwing a shapeless error where every other call
158
+ * throws a typed one.
159
+ */
160
+ async function readBody(
161
+ response: Response,
162
+ responseType: "json" | "binary",
163
+ ): Promise<{ parsed: unknown; binary: ArrayBuffer | undefined }> {
164
+ if (responseType !== "binary") {
165
+ return { parsed: await parseJson(response), binary: undefined };
166
+ }
167
+ const buffer = await response.arrayBuffer();
168
+ if (response.ok) return { parsed: null, binary: buffer };
169
+ return { parsed: parseJsonText(new TextDecoder().decode(buffer)), binary: undefined };
170
+ }
171
+
139
172
  function parseRetryAfterMs(header: string | null): number | undefined {
140
173
  if (!header) return undefined;
141
174
  const n = Number.parseFloat(header);
@@ -198,7 +231,22 @@ export class Transport {
198
231
  this.fetchFn = fetchFn.bind(globalThis);
199
232
  }
200
233
 
234
+ /**
235
+ * Fetch a binary document (currently only the invoice PDF).
236
+ *
237
+ * Same retry policy, same timeout, same typed errors as
238
+ * {@link Transport.request}; only the success-path decoding differs.
239
+ * `fetch` follows the storage adapter's `302` to the signed URL by
240
+ * itself, and the WHATWG spec drops the `Authorization` header on that
241
+ * cross-origin hop — which is correct, since a presigned URL carries
242
+ * its own credential and must not be handed BillKit's API key.
243
+ */
244
+ requestBinary(options: Omit<RequestOptions, "responseType">): Promise<ArrayBuffer> {
245
+ return this.request<ArrayBuffer>({ ...options, responseType: "binary" });
246
+ }
247
+
201
248
  async request<T = unknown>(options: RequestOptions): Promise<T> {
249
+ const responseType = options.responseType ?? "json";
202
250
  const idempotencyKey = autoIdempotencyKey(options.method, options.idempotencyKey);
203
251
  const url = buildUrl(this.baseUrl, options.path, options.query);
204
252
  const headers = buildHeaders(
@@ -222,6 +270,7 @@ export class Transport {
222
270
  const startedAt = Date.now();
223
271
  let response: Response;
224
272
  let parsedBody: unknown;
273
+ let binaryBody: ArrayBuffer | undefined;
225
274
  try {
226
275
  // ``body`` is only spread when present so a GET request goes
227
276
  // out without a body field. Some hosts (Cloudflare Workers'
@@ -240,7 +289,7 @@ export class Transport {
240
289
  };
241
290
  if (body !== undefined) init.body = body;
242
291
  response = await this.fetchFn(url, init);
243
- parsedBody = await parseJson(response);
292
+ ({ parsed: parsedBody, binary: binaryBody } = await readBody(response, responseType));
244
293
  } catch (err) {
245
294
  lastError = connectionError(err, this.timeoutMs);
246
295
  if (!shouldRetry(null, attempt, this.retryPolicy)) throw lastError;
@@ -267,6 +316,7 @@ export class Transport {
267
316
  });
268
317
 
269
318
  if (response.ok) {
319
+ if (responseType === "binary") return binaryBody as T;
270
320
  return (parsedBody ?? undefined) as T;
271
321
  }
272
322
 
@@ -278,7 +328,11 @@ export class Transport {
278
328
  retryAfter: retryAfterMs === undefined ? undefined : retryAfterMs / 1000,
279
329
  });
280
330
 
281
- if (!shouldRetry(response.status, attempt, this.retryPolicy, retryAfterMs)) {
331
+ // `error.code` is what separates a transient
332
+ // `409 idempotency_in_progress` from every other (permanent) 409;
333
+ // see `IN_PROGRESS_CODE`. The key on the wire is unchanged across
334
+ // attempts, so the retry replays rather than re-charges.
335
+ if (!shouldRetry(response.status, attempt, this.retryPolicy, retryAfterMs, error.code)) {
282
336
  throw error;
283
337
  }
284
338
  lastError = error;
package/src/version.ts CHANGED
@@ -1 +1 @@
1
- export const VERSION = "0.1.0";
1
+ export const VERSION = "0.2.1";