bc-api-client
Version:
A client for the BigCommerce management API and app authentication
1,229 lines • 64.4 kB
JavaScript
import * as jose from "jose";
import ky, { isHTTPError, isKyError, isTimeoutError } from "ky";
import pLimit from "p-limit";
//#region src/lib/common.ts
/** Maximum allowed concurrency value. */
const MAX_CONCURRENCY = 1e3;
/** Maximum allowed URL length before chunking is required. */
const MAX_URL_LENGTH = 2048;
/** Regex to strip leading slashes from API paths. */
const LEADING_SLASHES = /^\/+/;
/**
* Random positive jitter within 0-500 ms in increments of 100
* @param {number} delay
*/
const rateLimitJitter = (delay) => delay + Math.floor(Math.random() * 6) * 100;
/**
* HTTP header names used by the BigCommerce API.
*/
const HEADERS = {
AUTH_TOKEN: "X-Auth-Token",
ACCEPT: "Accept",
CONTENT_TYPE: "Content-Type",
RATE_LIMIT_LEFT: "x-rate-limit-requests-left",
RATE_LIMIT_RESET: "x-rate-limit-time-reset-ms",
RATE_LIMIT_QUOTA: "x-rate-limit-requests-quota",
RATE_LIMIT_WINDOW: "x-rate-limit-time-window-ms"
};
/**
* Default configuration for the underlying ky HTTP client.
*/
const BASE_KY_CONFIG = {
prefix: "https://api.bigcommerce.com",
throwHttpErrors: true,
timeout: 12e4,
retry: {
limit: 3,
methods: ["get", "delete"],
statusCodes: [
429,
500,
502,
503,
504
],
afterStatusCodes: [],
jitter: true,
maxRetryAfter: 12e4
},
headers: {
[HEADERS.ACCEPT]: "application/json",
[HEADERS.CONTENT_TYPE]: "application/json"
}
};
//#endregion
//#region src/lib/errors.ts
/**
* Abstract base class for all library errors. Carries a typed `context` object with
* structured diagnostic data and a machine-readable `code` string.
*
* Use `instanceof` checks against specific subclasses rather than this base class.
*/
var BaseError = class extends Error {
context;
constructor(message, context, options) {
super(message, options);
this.context = context;
this.name = this.constructor.name;
}
/** @internal */
toJSON() {
return {
name: this.name,
code: this.code,
message: this.message,
context: this.context,
cause: this.cause
};
}
};
/** Catch-all for unexpected client-side errors not covered by a more specific subclass. */
var BCClientError = class extends BaseError {
code = "BC_CLIENT_ERROR";
constructor(message, context, cause) {
super(message, context ?? {}, { cause });
}
};
/** Thrown by the {@link BigCommerceClient} constructor when credentials or config are invalid. */
var BCCredentialsError = class extends BaseError {
code = "BC_CLIENT_CREDENTIALS_ERROR";
constructor(errors) {
super("Failed to initialize BigCommerceClient", { errors });
}
};
/** Thrown before a request is sent when the constructed URL exceeds 2048 characters. */
var BCUrlTooLongError = class extends BaseError {
code = "BC_URL_TOO_LONG";
constructor(url, max) {
super(`Url length (${url.length}) exceeds max allowed length of ${max}`, {
url,
max,
len: url.length
});
}
};
/**
* Thrown during retry when a 429 response is received but the expected
* `X-Rate-Limit-*` headers are absent, making it impossible to determine the backoff delay.
*/
var BCRateLimitNoHeadersError = class extends BaseError {
code = "BC_RATE_LIMIT_NO_HEADERS";
constructor(request, attempts) {
super("Rate limit reached but the X-Rate-Limit-* headers were not returned. Unable to retry", {
url: request.url,
method: request.method,
attempts
});
}
};
/**
* Thrown during retry when a 429 response specifies a reset window that exceeds
* `config.retry.maxRetryAfter`, preventing an unbounded wait.
*/
var BCRateLimitDelayTooLongError = class extends BaseError {
code = "BC_RATE_LIMIT_DELAY_TOO_LONG";
constructor(request, attempts, maxDelay, delay) {
super("Rate limit reached, and the rate limit reset window is too high.", {
url: request.url,
method: request.method,
attempts,
maxDelay,
delay
});
}
};
/**
* Abstract base for all StandardSchema validation errors. Carries the raw `data` that failed
* validation and the schema `error` result. Use specific subclasses for `instanceof` checks.
*/
var BCSchemaValidationError = class extends BaseError {
constructor(message, method, path, data, error) {
super(message, {
method,
path,
data,
error
});
}
};
/** Thrown when `options.querySchema` validation fails before a request is sent. */
var BCQueryValidationError = class extends BCSchemaValidationError {
code = "BC_QUERY_VALIDATION_FAILED";
};
/** Thrown when `options.bodySchema` validation fails before a request is sent. */
var BCRequestBodyValidationError = class extends BCSchemaValidationError {
code = "BC_REQUEST_BODY_VALIDATION_FAILED";
};
/** Thrown when `options.responseSchema` validation fails after a response is received. */
var BCResponseValidationError = class extends BCSchemaValidationError {
code = "BC_RESPONSE_VALIDATION_FAILED";
};
/** Thrown or yielded when `options.itemSchema` validation fails for an item in a page response. */
var BCPaginatedItemValidationError = class extends BCSchemaValidationError {
code = "BC_PAGINATED_ITEM_VALIDATION_FAILED";
};
/**
* Thrown when the BigCommerce API returns a non-2xx HTTP response.
* `context.status` and `context.responseBody` are the most useful fields for debugging.
*/
var BCApiError = class extends BaseError {
code = "BC_API_ERROR";
constructor(err, requestBody) {
const { request, response, data } = err;
super("BigCommerce API request failed", {
method: request.method,
url: request.url,
status: response.status,
statusMessage: response.statusText,
headers: Object.fromEntries(response.headers),
requestBody,
responseBody: data
});
}
};
/** Thrown when a request exceeds the configured timeout (default 120 s). */
var BCTimeoutError = class extends BaseError {
code = "BC_TIMEOUT_ERROR";
constructor(err) {
super("BigCommerce API request timed out", {
method: err.request.method,
url: err.request.url
});
}
};
/**
* Thrown when the response body cannot be read or parsed as JSON.
* `context.rawBody` contains the raw text that failed to parse (empty string if the body was empty).
*/
var BCResponseParseError = class extends BaseError {
code = "BC_RESPONSE_PARSE_ERROR";
constructor(method, path, status, cause, query, rawBody) {
super("Failed to parse BigCommerce API response", {
status,
method,
path,
query,
rawBody
}, { cause });
}
};
/**
* Thrown when a pagination option (`limit`, `page`, or `count`) is not a positive number.
* `context.option` names the offending field; `context.value` is the value that was passed.
*/
var BCPaginatedOptionError = class extends BaseError {
code = "BC_PAGINATED_OPTION_ERROR";
constructor(path, value, option) {
super("The pagination option must be a positive number", {
path,
option,
value
});
}
};
/**
* Thrown or yielded when a paginated response is missing required v3 envelope fields
* (`data`, `meta.pagination`, etc.). Usually means the path is not a v3 collection endpoint.
*/
var BCPaginatedResponseError = class extends BaseError {
code = "BC_PAGINATED_RESPONSE_ERROR";
constructor(path, data, reason) {
super("Paginated response structure is invalid", {
path,
data,
reason
});
}
};
/** Thrown by {@link BigCommerceAuth} constructor when `config.redirectUri` is not a valid URL. */
var BCAuthInvalidRedirectUriError = class extends BaseError {
code = "BC_AUTH_INVALID_REDIRECT_URI";
constructor(redirectUri, cause) {
super("Invalid redirect URI", { redirectUri }, { cause });
}
};
/** Thrown by {@link BigCommerceAuth.requestToken} when a required OAuth callback param is absent. */
var BCAuthMissingParamError = class extends BaseError {
code = "BC_AUTH_MISSING_PARAM";
constructor(param) {
super(`Missing required auth callback parameter: ${param}`, { param });
}
};
/**
* Thrown by {@link BigCommerceAuth.requestToken} when the scopes granted by BigCommerce
* do not include all scopes listed in `config.scopes`.
* `context.missing` lists the scopes that were expected but not granted.
*/
var BCAuthScopeMismatchError = class extends BaseError {
code = "BC_AUTH_SCOPE_MISMATCH";
constructor(granted, expected, missing) {
super("Granted scopes do not match expected scopes", {
granted,
expected,
missing
});
}
};
/** Thrown by {@link BigCommerceAuth.verify} when the JWT signature, audience, issuer, or subject is invalid. */
var BCAuthInvalidJwtError = class extends BaseError {
code = "BC_AUTH_INVALID_JWT";
constructor(storeHash, cause) {
super("Invalid JWT payload", { storeHash }, { cause });
}
};
//#endregion
//#region src/lib/logger.ts
/** @internal */
const LOG_LEVELS = [
"debug",
"info",
"warn",
"error"
];
/**
* Adapts an AWS Lambda Powertools logger to the {@link Logger} interface expected by
* {@link BigCommerceClient} and {@link BigCommerceAuth}.
*
* Powertools loggers use `(message, ...data)` argument order whereas this library uses
* `(data, message)`. This adapter swaps the arguments.
*
* @param logger - An AWS Lambda Powertools (or any {@link PowertoolsLikeLogger}-compatible) logger.
* @returns A {@link Logger} wrapper suitable for `config.logger`.
*/
const fromAwsPowertoolsLogger = (logger) => ({
debug: (data, message) => logger.debug(message ?? "", data),
info: (data, message) => logger.info(message ?? "", data),
warn: (data, message) => logger.warn(message ?? "", data),
error: (data, message) => logger.error(message ?? "", data)
});
/**
* Console-based {@link Logger} that filters messages below a minimum level.
*
* Used automatically when `config.logger` is `true`, `undefined`, or a {@link LogLevel} string.
* Can also be instantiated directly for custom log level control.
*
* @example
* ```ts
* new BigCommerceClient({ ..., logger: new FallbackLogger('debug') });
* ```
*/
var FallbackLogger = class {
level;
/**
* @param level - Minimum level to output. Messages below this level are silently dropped.
*/
constructor(level) {
this.level = level;
}
debug(data, message) {
this.log("debug", data, message);
}
info(data, message) {
this.log("info", data, message);
}
warn(data, message) {
this.log("warn", data, message);
}
error(data, message) {
this.log("error", data, message);
}
log(level, data, message) {
if (LOG_LEVELS.indexOf(level) < LOG_LEVELS.indexOf(this.level)) return;
const fn = console[level];
message !== void 0 ? fn(message, data) : fn(data);
}
};
/**
* @internal
*/
const initLogger = (logger) => {
if (logger === false) return;
if (logger === void 0 || logger === true) return new FallbackLogger("info");
if (typeof logger === "string") if (LOG_LEVELS.includes(logger)) return new FallbackLogger(logger);
else {
const logger = new FallbackLogger("info");
logger.warn({ level: logger }, "Unknown log level passed, using info");
return logger;
}
return logger;
};
//#endregion
//#region src/auth.ts
const GRANT_TYPE = "authorization_code";
const TOKEN_ENDPOINT = "https://login.bigcommerce.com/oauth2/token";
const ISSUER = "bc";
/**
* Handles authentication with BigCommerce OAuth
*/
var BigCommerceAuth = class {
config;
logger;
client;
/**
* Creates a new BigCommerceAuth instance for handling OAuth authentication
* @param config - Configuration options for BigCommerce authentication
* @param config.clientId - The OAuth client ID from BigCommerce
* @param config.secret - The OAuth client secret from BigCommerce
* @param config.redirectUri - The redirect URI registered with BigCommerce
* @param config.scopes - Optional array of scopes to validate during auth callback
* @param config.logger - Optional logger instance for debugging and error tracking
* @throws {BCAuthInvalidRedirectUriError} If the redirect URI is invalid
*/
constructor(config) {
this.config = config;
try {
new URL(this.config.redirectUri);
} catch (error) {
throw new BCAuthInvalidRedirectUriError(this.config.redirectUri, error);
}
this.logger = initLogger(config.logger);
const { prefix: _, ...authKyConfig } = BASE_KY_CONFIG;
this.client = ky.create({
...authKyConfig,
retry: {
...authKyConfig.retry,
methods: ["post"]
}
});
}
/**
* Exchanges an OAuth authorization code for an access token.
*
* @param data - The auth callback payload: a raw query string, `URLSearchParams`, or a
* pre-parsed object with `code`, `scope`, and `context`.
* @returns The token response including `access_token`, `user`, and `context`.
* @throws {@link BCAuthMissingParamError} if `code`, `scope`, or `context` are absent.
* @throws {@link BCAuthScopeMismatchError} if the granted scopes don't include all `config.scopes`.
* @throws {@link BCApiError} on HTTP error responses from the token endpoint.
* @throws {@link BCTimeoutError} if the token request times out.
* @throws {@link BCClientError} on any other error.
*/
async requestToken(data) {
const query = typeof data === "string" || data instanceof URLSearchParams ? this.parseQueryString(data) : data;
this.validateScopes(query.scope);
const tokenRequest = {
client_id: this.config.clientId,
client_secret: this.config.secret,
...query,
grant_type: GRANT_TYPE,
redirect_uri: this.config.redirectUri
};
this.logger?.debug({
clientId: this.config.clientId,
context: query.context,
scopes: query.scope
}, "Requesting OAuth token");
let res;
try {
res = await this.client(TOKEN_ENDPOINT, {
method: "POST",
json: tokenRequest
});
} catch (error) {
if (isHTTPError(error)) {
const err = new BCApiError(error, await error.request.text().catch(() => ""));
this.logger?.error(err.context, "Failed to request token");
throw err;
}
if (isTimeoutError(error)) {
const err = new BCTimeoutError(error);
this.logger?.error(err.context, "Token request timed out");
throw err;
}
throw new BCClientError("Failed to request token", {}, error);
}
return res.json();
}
/**
* Verifies a JWT payload from BigCommerce
* @param jwtPayload - The JWT string to verify
* @param storeHash - The store hash for the BigCommerce store
* @returns Promise resolving to the verified JWT claims
* @throws {BCAuthInvalidJwtError} If the JWT is invalid
*/
async verify(jwtPayload, storeHash) {
try {
const secret = new TextEncoder().encode(this.config.secret);
const { payload } = await jose.jwtVerify(jwtPayload, secret, {
audience: this.config.clientId,
issuer: ISSUER,
subject: `stores/${storeHash}`
});
this.logger?.debug({
userId: payload.user?.id,
storeHash: payload.sub.split("/")[1]
}, "JWT verified successfully");
return payload;
} catch (error) {
const err = new BCAuthInvalidJwtError(storeHash, error);
this.logger?.error(err.context, "JWT verification failed");
throw err;
}
}
/**
* Parses and validates a query string from BigCommerce auth callback
* @param queryString - The query string to parse
* @returns The parsed auth query parameters
* @throws {BCAuthMissingParamError} If required parameters are missing
*/
parseQueryString(queryString) {
const params = typeof queryString === "string" ? new URLSearchParams(queryString) : queryString;
const code = params.get("code");
const scope = params.get("scope");
const context = params.get("context");
if (!code) throw new BCAuthMissingParamError("code");
if (!scope) throw new BCAuthMissingParamError("scope");
else if (this.config.scopes?.length) this.validateScopes(scope);
if (!context) throw new BCAuthMissingParamError("context");
return {
code,
scope,
context
};
}
/**
* Validates that the granted scopes match the expected scopes
* @param scopes - Space-separated list of granted scopes
* @throws {BCAuthScopeMismatchError} If the scopes don't match the expected scopes
*/
validateScopes(scopes) {
if (!this.config.scopes) return;
const granted = scopes.split(" ");
const expected = this.config.scopes;
const missing = expected.filter((scope) => !granted.includes(scope));
if (missing.length) throw new BCAuthScopeMismatchError(granted, expected, missing);
}
};
//#endregion
//#region src/lib/util.ts
const parseIntHeader = (headers, key) => {
const value = Number.parseInt(headers.get(key) ?? "", 10);
return Number.isNaN(value) ? void 0 : value;
};
const extractRateLimitHeaders = (headers) => {
const resetIn = parseIntHeader(headers, HEADERS.RATE_LIMIT_RESET);
if (resetIn === void 0) return;
return {
resetIn,
requestsLeft: parseIntHeader(headers, HEADERS.RATE_LIMIT_LEFT),
quota: parseIntHeader(headers, HEADERS.RATE_LIMIT_QUOTA),
window: parseIntHeader(headers, HEADERS.RATE_LIMIT_WINDOW)
};
};
const chunkStrLength = (items, options = {}) => {
const { maxLength = 2048, chunkLength = 250, offset = 0, separatorSize = 1 } = options;
const chunks = [];
let currentStrLength = offset;
let currentChunk = [];
for (const item of items) {
const itemLength = encodeURIComponent(item).length;
const totalItemLength = itemLength + (currentChunk.length > 0 ? separatorSize : 0);
const wouldExceedLength = currentStrLength + totalItemLength > maxLength;
const wouldExceedCount = currentChunk.length >= chunkLength;
if ((wouldExceedLength || wouldExceedCount) && currentChunk.length > 0) {
chunks.push(currentChunk);
currentChunk = [];
currentStrLength = offset;
}
if (itemLength + offset > maxLength) throw new Error(`Item too large: ${itemLength} exceeds maxLength ${maxLength}`);
currentChunk.push(item);
currentStrLength += itemLength + (currentChunk.length > 1 ? separatorSize : 0);
}
if (currentChunk.length > 0) chunks.push(currentChunk);
return chunks;
};
var AsyncChannel = class {
queue = [];
notify = null;
done = false;
push(item) {
this.queue.push(item);
this.notify?.();
this.notify = null;
}
close() {
this.done = true;
this.notify?.();
this.notify = null;
}
async *[Symbol.asyncIterator]() {
while (!this.done || this.queue.length > 0) {
if (this.queue.length === 0) await new Promise((r) => {
if (this.queue.length > 0) return r();
this.notify = r;
});
while (this.queue.length > 0) {
const item = this.queue.shift();
if (item !== void 0) yield item;
}
}
}
};
//#endregion
//#region src/lib/hooks.ts
const validateUrlLength = ({ request }) => {
if (request.url.length > 2048) throw new BCUrlTooLongError(request.url, MAX_URL_LENGTH);
};
const bcRateLimitRetry = (logger) => async ({ request, options, error, retryCount }) => {
if (isHTTPError(error) && error.response.status === 429) {
const retryMeta = extractRateLimitHeaders(error.response.headers);
if (!retryMeta) throw new BCRateLimitNoHeadersError(request, retryCount);
if (options.retry.maxRetryAfter && retryMeta.resetIn > options.retry.maxRetryAfter) throw new BCRateLimitDelayTooLongError(request, retryCount, options.retry.maxRetryAfter, retryMeta.resetIn);
const delay = typeof options.retry.jitter === "function" ? options.retry.jitter(retryMeta.resetIn) : rateLimitJitter(retryMeta.resetIn);
logger?.warn({
attempt: retryCount,
url: request.url,
method: request.method,
retryMeta
}, `Rate limit reached, retrying in ${delay} (with jitter)`);
await new Promise((resolve) => setTimeout(resolve, delay));
} else logger?.warn({
url: request.url,
method: request.method,
attempt: retryCount
}, "Retrying request");
};
//#endregion
//#region src/lib/request.ts
/**
* Converts a Query object to URLSearchParams.
* Array values are joined with commas (e.g., `id:in=1,2,3`).
*/
const toUrlSearchParams = (query) => {
if (!query) return;
const params = new URLSearchParams();
for (const [key, value] of Object.entries(query)) if (Array.isArray(value)) params.append(key, value.map(String).join(","));
else params.append(key, String(value));
return params;
};
/**
* Helpers for building typed request descriptors to pass to
* {@link BigCommerceClient.batchSafe} or {@link BigCommerceClient.batchStream}.
*
* @example
* ```ts
* const results = await client.batchSafe([
* req.get('catalog/products/1'),
* req.post('catalog/products', { body: { name: 'Widget' } }),
* ]);
* ```
*/
const req = {
/**
* Builds a GET request descriptor.
* @param path - API path relative to the store's versioned base URL.
* @param options - Optional query params, schemas, and ky options.
*/
get: (path, options) => ({
method: "GET",
path,
...options
}),
/**
* Builds a POST request descriptor.
* @param path - API path relative to the store's versioned base URL.
* @param options - Optional body, query params, schemas, and ky options.
*/
post: (path, options) => ({
method: "POST",
path,
...options
}),
/**
* Builds a PUT request descriptor.
* @param path - API path relative to the store's versioned base URL.
* @param options - Optional body, query params, schemas, and ky options.
*/
put: (path, options) => ({
method: "PUT",
path,
...options
}),
/**
* Builds a DELETE request descriptor.
* @param path - API path relative to the store's versioned base URL.
* @param options - Optional query params and ky options.
*/
delete: (path, options) => ({
method: "DELETE",
path,
...options
})
};
//#endregion
//#region src/lib/result.ts
/**
* Creates a successful {@link Result}. Check `result.ok` or `result.err` before accessing `data`.
* @param data - The success value.
*/
const Ok = (data) => ({
ok: true,
data,
err: void 0
});
/**
* Creates a failed {@link Result}. Check `result.ok` or `result.err` before accessing `err`.
* @param err - The error value.
*/
const Err = (err) => ({
ok: false,
data: void 0,
err
});
//#endregion
//#region src/client.ts
var BigCommerceClient = class {
config;
logger;
client;
storeHash;
/**
* Creates a new BigCommerceClient.
*
* @param config - Client configuration. Ky options (e.g. `prefix`, `timeout`, `retry`,
* `hooks`) are forwarded to the underlying ky instance.
* @param config.storeHash - BigCommerce store hash. Must be a non-empty string.
* @param config.accessToken - BigCommerce API access token. Must be a non-empty string.
* @param config.logger - A {@link Logger} instance, a log level string
* (`'debug' | 'info' | 'warn' | 'error'`), `true` to enable console logging at `'info'`
* level, or `false` to disable logging entirely. Omitting also defaults to `'info'` level.
* @param config.concurrency - Default max concurrent requests for batch/stream operations.
* Must be between 1 and 1000. Pass `false` to disable concurrency (sequential execution).
* Defaults to 10.
* @param config.rateLimitBackoff - Concurrency cap applied when a 429 response is received.
* Defaults to 1.
* @param config.backoff - Divisor (or `(concurrency, status) => number` function) applied to
* concurrency on non-429 error responses. Defaults to 2.
* @param config.backoffRecover - Amount (or `(concurrency) => number` function) added to
* concurrency per successful response while below the configured max. Defaults to 1.
*
* @throws {@link BCCredentialsError} if `storeHash` or `accessToken` are missing.
* @throws {@link BCClientError} if `prefix` is not a valid URL or `concurrency` is out of range.
*/
constructor(config) {
this.config = config;
this.validateConfig();
const { storeHash, accessToken, logger, concurrency: _, ...kyOptions } = config;
this.logger = initLogger(logger);
this.storeHash = storeHash;
this.client = ky.create({
...BASE_KY_CONFIG,
...kyOptions,
headers: {
...BASE_KY_CONFIG.headers,
...kyOptions.headers ?? {},
[HEADERS.AUTH_TOKEN]: accessToken
},
hooks: {
beforeRequest: [...kyOptions.hooks?.beforeRequest ?? [], validateUrlLength],
beforeRetry: [
({ error }) => {
if (error instanceof BaseError) throw error;
},
bcRateLimitRetry(this.logger),
...kyOptions.hooks?.beforeRetry ?? []
],
beforeError: [...kyOptions.hooks?.beforeError ?? []],
afterResponse: [...kyOptions.hooks?.afterResponse ?? []]
}
});
}
/**
* Sends a GET request to the given path.
*
* @param path - API path relative to the store's versioned base URL (e.g. `catalog/products`).
* @param options - Ky options are forwarded to the underlying request.
* @param options.version - API version segment inserted into the URL. Defaults to `'v3'`.
* @param options.query - Query parameters to append to the URL.
* @param options.querySchema - StandardSchemaV1 schema to validate `query` before the request
* is sent. Requires `query` to be provided.
* @param options.responseSchema - StandardSchemaV1 schema to validate the parsed response body.
*
* @returns Parsed and optionally validated response body.
*
* @throws {@link BCApiError} on HTTP error responses.
* @throws {@link BCTimeoutError} if the request times out.
* @throws {@link BCResponseParseError} if the response body cannot be parsed.
* @throws {@link BCUrlTooLongError} if the constructed URL exceeds 2048 characters.
* @throws {@link BCRateLimitNoHeadersError} if a 429 is received without rate-limit headers.
* @throws {@link BCRateLimitDelayTooLongError} if the rate-limit reset window exceeds
* `config.retry.maxRetryAfter`.
* @throws {@link BCQueryValidationError} if `querySchema` validation fails.
* @throws {@link BCResponseValidationError} if `responseSchema` validation fails.
* @throws {@link BCClientError} on any other ky or unknown error.
*/
async get(path, options) {
return this.request(path, {
...options,
method: "GET"
});
}
/**
* Sends a POST request to the given path.
*
* @param path - API path relative to the store's versioned base URL.
* @param options - Ky options are forwarded to the underlying request.
* @param options.version - API version segment inserted into the URL. Defaults to `'v3'`.
* @param options.body - Request body, serialized as JSON.
* @param options.bodySchema - StandardSchemaV1 schema to validate `body` before the request
* is sent. Requires `body` to be provided.
* @param options.query - Query parameters to append to the URL.
* @param options.querySchema - StandardSchemaV1 schema to validate `query` before the request
* is sent. Requires `query` to be provided.
* @param options.responseSchema - StandardSchemaV1 schema to validate the parsed response body.
*
* @returns Parsed and optionally validated response body.
*
* @throws {@link BCApiError} on HTTP error responses.
* @throws {@link BCTimeoutError} if the request times out.
* @throws {@link BCResponseParseError} if the response body cannot be parsed.
* @throws {@link BCUrlTooLongError} if the constructed URL exceeds 2048 characters.
* @throws {@link BCRateLimitNoHeadersError} if a 429 is received without rate-limit headers.
* @throws {@link BCRateLimitDelayTooLongError} if the rate-limit reset window exceeds
* `config.retry.maxRetryAfter`.
* @throws {@link BCQueryValidationError} if `querySchema` validation fails.
* @throws {@link BCRequestBodyValidationError} if `bodySchema` validation fails.
* @throws {@link BCResponseValidationError} if `responseSchema` validation fails.
* @throws {@link BCClientError} on any other ky or unknown error.
*/
async post(path, options) {
return this.request(path, {
...options,
method: "POST"
});
}
/**
* Sends a PUT request to the given path.
*
* @param path - API path relative to the store's versioned base URL.
* @param options - Ky options are forwarded to the underlying request.
* @param options.version - API version segment inserted into the URL. Defaults to `'v3'`.
* @param options.body - Request body, serialized as JSON.
* @param options.bodySchema - StandardSchemaV1 schema to validate `body` before the request
* is sent. Requires `body` to be provided.
* @param options.query - Query parameters to append to the URL.
* @param options.querySchema - StandardSchemaV1 schema to validate `query` before the request
* is sent. Requires `query` to be provided.
* @param options.responseSchema - StandardSchemaV1 schema to validate the parsed response body.
*
* @returns Parsed and optionally validated response body.
*
* @throws {@link BCApiError} on HTTP error responses.
* @throws {@link BCTimeoutError} if the request times out.
* @throws {@link BCResponseParseError} if the response body cannot be parsed.
* @throws {@link BCUrlTooLongError} if the constructed URL exceeds 2048 characters.
* @throws {@link BCRateLimitNoHeadersError} if a 429 is received without rate-limit headers.
* @throws {@link BCRateLimitDelayTooLongError} if the rate-limit reset window exceeds
* `config.retry.maxRetryAfter`.
* @throws {@link BCQueryValidationError} if `querySchema` validation fails.
* @throws {@link BCRequestBodyValidationError} if `bodySchema` validation fails.
* @throws {@link BCResponseValidationError} if `responseSchema` validation fails.
* @throws {@link BCClientError} on any other ky or unknown error.
*/
async put(path, options) {
return this.request(path, {
...options,
method: "PUT"
});
}
/**
* Sends a DELETE request to the given path.
*
* Silently suppresses 404 responses (resource already gone) and empty response bodies.
*
* @param path - API path relative to the store's versioned base URL.
* @param options - Ky options are forwarded to the underlying request.
* @param options.version - API version segment inserted into the URL. Defaults to `'v3'`.
* @param options.query - Query parameters to append to the URL.
* @param options.querySchema - StandardSchemaV1 schema to validate `query` before the request
* is sent. Requires `query` to be provided.
*
* @throws {@link BCApiError} on non-404 HTTP error responses.
* @throws {@link BCTimeoutError} if the request times out.
* @throws {@link BCResponseParseError} if the response body is non-empty and cannot be parsed.
* @throws {@link BCUrlTooLongError} if the constructed URL exceeds 2048 characters.
* @throws {@link BCRateLimitNoHeadersError} if a 429 is received without rate-limit headers.
* @throws {@link BCRateLimitDelayTooLongError} if the rate-limit reset window exceeds
* `config.retry.maxRetryAfter`.
* @throws {@link BCQueryValidationError} if `querySchema` validation fails.
* @throws {@link BCClientError} on any other ky or unknown error.
*/
async delete(path, options) {
try {
await this.request(path, {
...options,
method: "DELETE"
});
} catch (err) {
if (err instanceof BCResponseParseError && err.context.rawBody === "") return;
if (err instanceof BCApiError && err.context.status === 404) {
this.logger?.warn({ err }, "Attempted to delete the resource that is already gone");
return;
}
throw err;
}
}
/**
* Fetches items from a v3 paginated endpoint by splitting `values` across multiple requests
* using the given `key` query param, chunking to stay within URL length limits.
*
* Collects all results into an array. Use {@link queryStream} to process items lazily.
*
* **Sorting and concurrency:** when `concurrency > 1`, chunks complete out of order so
* sorted output is not preserved across the full result set. Pass `concurrency: false`
* if sort order matters.
*
* @param path - API path relative to the store's versioned base URL.
* @param options - Query options including `key`, `values`, pagination params, and concurrency
* controls.
* @param options.key - Query parameter name used for value filtering (e.g. `'id:in'`).
* @param options.values - Values to filter by. Automatically chunked across multiple requests
* to keep each URL under 2048 characters.
* @param options.query - Additional query parameters. `query.limit` controls page size
* (default 250, must be > 0). If `options.key` is present in `query` it will be ignored.
* @param options.querySchema - StandardSchemaV1 schema to validate `query`. Requires `query`
* to be provided.
* @param options.itemSchema - StandardSchemaV1 schema to validate each returned item.
* @param options.concurrency - Max concurrent chunk requests. Must be 1–1000. `false` for
* sequential. Defaults to `config.concurrency`, or 10 if not set on the client.
* @param options.rateLimitBackoff - Concurrency cap on 429 responses. Defaults to
* `config.rateLimitBackoff`, or 1 if not set on the client.
* @param options.backoff - Divisor (or function) applied to concurrency on error responses.
* Defaults to `config.backoff`, or 2 if not set on the client.
* @param options.backoffRecover - Amount (or function) added to concurrency per successful
* response. Defaults to `config.backoffRecover`, or 1 if not set on the client.
*
* @returns All matching items across all chunked requests.
* @throws {@link BCPaginatedOptionError} if `query.limit` is not a positive number.
* @throws {@link BCQueryValidationError} if `querySchema` validation fails.
* @throws {@link BCApiError} on HTTP error responses.
* @throws {@link BCTimeoutError} if a request times out.
* @throws {@link BCResponseParseError} if a response body cannot be parsed.
* @throws {@link BCUrlTooLongError} if a constructed URL exceeds 2048 characters.
* @throws {@link BCRateLimitNoHeadersError} if a 429 is received without rate-limit headers.
* @throws {@link BCRateLimitDelayTooLongError} if the rate-limit reset window exceeds
* `config.retry.maxRetryAfter`.
* @throws {@link BCPaginatedResponseError} if a page response has an unexpected shape.
* @throws {@link BCPaginatedItemValidationError} if `itemSchema` validation fails for an item.
* @throws {@link BCClientError} on any other ky or unknown error.
*/
async query(path, options) {
const results = [];
for await (const { data, err } of this.queryStream(path, options)) if (err) throw err;
else results.push(data);
return results;
}
/**
* Streaming variant of {@link query}. Yields each item individually as results arrive,
* splitting `values` into URL-length-safe chunks across concurrent requests.
*
* Each yielded value is a {@link Result} — check `err` before using `data`.
*
* **Sorting and concurrency:** when `concurrency > 1`, chunks complete out of order so
* sorted output is not preserved across the full result set. Pass `concurrency: false`
* if sort order matters.
*
* @param path - API path relative to the store's versioned base URL.
* @param options - Query options including `key`, `values`, pagination params, and concurrency
* controls.
* @param options.key - Query parameter name used for value filtering (e.g. `'id:in'`).
* @param options.values - Values to filter by. Automatically chunked across multiple requests
* to keep each URL under 2048 characters.
* @param options.query - Additional query parameters. `query.limit` controls page size
* (default 250, must be > 0). If `options.key` is present in `query` it will be ignored.
* @param options.querySchema - StandardSchemaV1 schema to validate `query`. Requires `query`
* to be provided.
* @param options.itemSchema - StandardSchemaV1 schema to validate each returned item.
* @param options.concurrency - Max concurrent chunk requests. Must be 1–1000. `false` for
* sequential. Defaults to `config.concurrency`, or 10 if not set on the client.
* @param options.rateLimitBackoff - Concurrency cap on 429 responses. Defaults to
* `config.rateLimitBackoff`, or 1 if not set on the client.
* @param options.backoff - Divisor (or function) applied to concurrency on error responses.
* Defaults to `config.backoff`, or 2 if not set on the client.
* @param options.backoffRecover - Amount (or function) added to concurrency per successful
* response. Defaults to `config.backoffRecover`, or 1 if not set on the client.
* @throws {@link BCPaginatedOptionError} if `query.limit` is not a positive number.
* @throws {@link BCQueryValidationError} if `querySchema` validation fails.
*/
async *queryStream(path, options) {
const { key, values, query, querySchema, itemSchema, concurrency, rateLimitBackoff, backoff, backoffRecover, ...requestOptions } = options;
const limit = this.validatePaginationOption(path, "limit", query?.limit ?? 250);
const newQuery = {
...await this.validate(query, querySchema, BCQueryValidationError, "GET", path, "Invalid query parameters"),
limit
};
if (key in newQuery) {
this.logger?.warn({ key }, "The provided key is already in the query params, this param will be ignored");
delete newQuery[key];
}
const fullUrl = `${this.config.prefix ?? requestOptions.prefix ?? BASE_KY_CONFIG.prefix}/${this.makePath("v3", path)}?${toUrlSearchParams({
...newQuery,
page: 1
})}`;
const keyOverhead = encodeURIComponent(key).length + 2;
const requests = chunkStrLength(values.map(String), {
chunkLength: limit,
maxLength: MAX_URL_LENGTH,
offset: fullUrl.length + keyOverhead,
separatorSize: 3
}).map((chunk) => req.get(path, {
...requestOptions,
query: {
...newQuery,
page: 1,
[key]: chunk
}
}));
for await (const { err, data } of this.batchStream(requests, {
concurrency,
rateLimitBackoff,
backoff,
backoffRecover
})) {
if (err) {
yield Err(err);
continue;
}
try {
const { data: items } = this.assertPaginatedResponse(path, data);
for (const item of items) yield this.validatePaginatedItem(path, item, itemSchema);
} catch (err) {
if (err instanceof BaseError) yield Err(err);
else yield Err(new BCClientError("Unknown error occurred processing page", {}, { cause: err }));
}
}
}
/**
* Fetches all pages from a v3 paginated endpoint and collects items into an array.
*
* Use {@link stream} to process items lazily without buffering the full result set.
*
* **Sorting and concurrency:** the first page is fetched sequentially; remaining pages are
* fetched concurrently and may complete out of order. When `concurrency > 1`, sort order
* is not preserved across pages. Pass `concurrency: false` if sort order matters.
*
* @param path - API path relative to the store's versioned base URL.
* @param options - Ky options are forwarded to page requests.
* @param options.query - Query parameters. `query.limit` controls page size (default 250,
* must be > 0). `query.page` sets the starting page (default 1, must be > 0).
* @param options.querySchema - StandardSchemaV1 schema to validate `query`. Requires `query`
* to be provided.
* @param options.itemSchema - StandardSchemaV1 schema to validate each returned item.
* @param options.concurrency - Max concurrent page requests for pages after the first.
* Must be 1–1000. `false` for sequential. Defaults to `config.concurrency`, or 10 if not
* set on the client.
* @param options.rateLimitBackoff - Concurrency cap on 429 responses. Defaults to
* `config.rateLimitBackoff`, or 1 if not set on the client.
* @param options.backoff - Divisor (or function) applied to concurrency on error responses.
* Defaults to `config.backoff`, or 2 if not set on the client.
* @param options.backoffRecover - Amount (or function) added to concurrency per successful
* response. Defaults to `config.backoffRecover`, or 1 if not set on the client.
* @returns All items across all pages.
*
* @throws {@link BCPaginatedOptionError} if `query.limit` or `query.page` is not a positive number.
* @throws {@link BCQueryValidationError} if `querySchema` validation fails.
* @throws {@link BCApiError} on HTTP error responses.
* @throws {@link BCTimeoutError} if a request times out.
* @throws {@link BCResponseParseError} if a response body cannot be parsed.
* @throws {@link BCUrlTooLongError} if a constructed URL exceeds 2048 characters.
* @throws {@link BCRateLimitNoHeadersError} if a 429 is received without rate-limit headers.
* @throws {@link BCRateLimitDelayTooLongError} if the rate-limit reset window exceeds
* `config.retry.maxRetryAfter`.
* @throws {@link BCPaginatedResponseError} if a page response has an unexpected shape.
* @throws {@link BCPaginatedItemValidationError} if `itemSchema` validation fails for an item.
* @throws {@link BCClientError} on any other ky or unknown error.
*/
async collect(path, options) {
const items = [];
for await (const { data, err } of this.stream(path, options)) if (err) throw err;
else items.push(data);
return items;
}
/**
* Fetches all pages from a v2 flat-array endpoint and collects items into an array.
*
* Pagination is discovered dynamically — pages are fetched in batches until an empty page,
* a 404, or a 204 response is received. No prior knowledge of total count is required.
*
* Use {@link streamBlind} to process items lazily without buffering the full result set.
*
* **Sorting and concurrency:** pages within each batch are fetched concurrently and may
* complete out of order. When `concurrency > 1`, sort order is not preserved across pages.
* Pass `concurrency: false` if sort order matters.
*
* @param path - API path relative to the store's versioned base URL (always requests v2).
* @param options - Ky options are forwarded to page requests.
* @param options.query - Query parameters. `query.limit` controls page size (default 250,
* must be > 0). `query.page` sets the starting page (default 1, must be > 0).
* @param options.querySchema - StandardSchemaV1 schema to validate `query`. Requires `query`
* to be provided.
* @param options.itemSchema - StandardSchemaV1 schema to validate each returned item.
* @param options.maxPages - Maximum number of pages to fetch before stopping (default 500,
* must be > 0). A warning is logged if this limit is reached.
* @param options.concurrency - Max concurrent page requests per batch. Must be 1–1000.
* `false` for sequential. Defaults to `config.concurrency`, or 10 if not set on the client.
* @param options.rateLimitBackoff - Concurrency cap on 429 responses. Defaults to
* `config.rateLimitBackoff`, or 1 if not set on the client.
* @param options.backoff - Divisor (or function) applied to concurrency on error responses.
* Defaults to `config.backoff`, or 2 if not set on the client.
* @param options.backoffRecover - Amount (or function) added to concurrency per successful
* response. Defaults to `config.backoffRecover`, or 1 if not set on the client.
* @returns All items across all pages.
*
* @throws {@link BCPaginatedOptionError} if `query.limit`, `query.page`, or `maxPages` is not a positive number.
* @throws {@link BCQueryValidationError} if `querySchema` validation fails.
* @throws {@link BCPaginatedItemValidationError} if `itemSchema` validation fails for an item.
* @throws {@link BCClientError} if a page returns a non-array response, or on any other error.
* @throws {@link BCApiError} on non-terminating HTTP error responses.
* @throws {@link BCTimeoutError} if a request times out.
* @throws {@link BCResponseParseError} if a response body cannot be parsed.
*/
async collectBlind(path, options) {
const results = [];
for await (const { err, data } of this.streamBlind(path, options)) if (err) throw err;
else results.push(data);
return results;
}
/**
* Lazily streams items from a v2 flat-array endpoint, page by page.
*
* Pagination is discovered dynamically — pages are fetched in concurrent batches until an
* empty page, a 404, or a 204 response is received. No prior knowledge of total count is
* required. Each item is yielded as a {@link PageResult}: `Ok(item)` on success or
* `Err(error)` for item-level validation failures and non-terminating page errors.
* Use `page` to correlate the item back to its source page.
*
* Use {@link collectBlind} to buffer all results into an array (throws on any error).
*
* **Sorting and concurrency:** pages within each batch are fetched concurrently and may
* complete out of order. When `concurrency > 1`, sort order is not preserved across pages.
* Pass `concurrency: false` if sort order matters.
*
* @param path - API path relative to the store's versioned base URL (always requests v2).
* @param options - Ky options are forwarded to page requests.
* @param options.query - Query parameters. `query.limit` controls page size (default 250,
* must be > 0). `query.page` sets the starting page (default 1, must be > 0).
* @param options.querySchema - StandardSchemaV1 schema to validate `query`. Requires `query`
* to be provided.
* @param options.itemSchema - StandardSchemaV1 schema to validate each returned item.
* @param options.maxPages - Maximum number of pages to fetch before stopping (default 500,
* must be > 0). A warning is logged if this limit is reached.
* @param options.concurrency - Max concurrent page requests per batch. Must be 1–1000.
* `false` for sequential. Defaults to `config.concurrency`, or 10 if not set on the client.
* @param options.rateLimitBackoff - Concurrency cap on 429 responses. Defaults to
* `config.rateLimitBackoff`, or 1 if not set on the client.
* @param options.backoff - Divisor (or function) applied to concurrency on error responses.
* Defaults to `config.backoff`, or 2 if not set on the client.
* @param options.backoffRecover - Amount (or function) added to concurrency per successful
* response. Defaults to `config.backoffRecover`, or 1 if not set on the client.
*
* @throws {@link BCPaginatedOptionError} if `query.limit`, `query.page`, or `maxPages` is not a positive number.
* @throws {@link BCQueryValidationError} if `querySchema` validation fails.
*
* @yields `Ok(item)` for each successfully fetched and validated item.
* @yields `Err(BCPaginatedItemValidationError)` when `itemSchema` rejects an item.
* @yields `Err(BCClientError)` when a page returns a non-array response.
* @yields `Err(error)` for non-terminating page errors (e.g. non-404/204 HTTP errors).
*/
async *streamBlind(path, options) {
const { query: rawQuery, querySchema, itemSchema, maxPages: rawMaxPages, concurrency: rawConcurrency, rateLimitBackoff, backoff, backoffRecover, pLimit: rawPLimit, ...requestOptions } = options ?? {};
const concurrency = this.validateConcurrency(rawConcurrency ?? this.config.concurrency ?? 10);
const limiter = rawPLimit ?? (concurrency ? pLimit({
concurrency,
rejectOnClear: true
}) : void 0);
const concurrencyOptions = {
concurrency: rawConcurrency,
rateLimitBackoff,
backoff,
backoffRecover,
pLimit: limiter
};
const query = await this.validate(rawQuery, querySchema, BCQueryValidationError, "GET", path);
const page = this.validatePaginationOption(path, "page", query?.page ?? 1);
const limit = this.validatePaginationOption(path, "limit", query?.limit ?? 250);
const maxPages = this.validatePaginationOption(path, "maxPages", rawMaxPages ?? 500);
let done = false;
let currentPage = page;
do {
if (currentPage > maxPages) {
this.logger?.warn({ currentPage }, "Blind pagination reached maxPages before the end of the data");
break;
}
const batchSize = (limiter?.concurrency ?? concurrency) || 1;
const batchStartPage = currentPage;
const pageRequests = Array.from({ length: batchSize }, (_, i) => currentPage + i).map((page) => req.get(path, {
...requestOptions,
version: "v2",
query: {
...query,
page,
limit
}
}));
currentPage += batchSize;
const pages = await this.batchSafe(pageRequests, concurrencyOptions);
for (const { err, data, index } of pages) {
const itemPage = batchStartPage + index;
if (err) {
done = err instanceof BCApiError && err.context.status === 404 || err instanceof BCResponseParseError && err.context.rawBody === "" && err.context.status === 204;
if (!done) yield {
...Err(err),
page: itemPage
};
} else if (Array.isArray(data)) {
if (data.length === 0) {
done = true;
break;
}
for (const item of data) yield {
...await this.validatePaginatedItem(path, item, itemSchema),
page: itemPage
};
} else yield {
...Err(new BCClientError("Received non array response from blind pagination page endpoint", {
data,
path
})),
page: itemPage
};
}
} while (!done);
}
/**
* Executes multiple requests concurrently and returns all results as {@link BatchResult}
* values, never throwing. Errors from individual requests are captured as `Err` results.
*
* Results arrive in completion order, not input order. Use the `index` field on each
* {@link BatchResult} to correlate a result back to its position in the `requests` array.
*
* Use {@link batchStream} to process results as they arrive rather than waiting for all.
*
* @param requests - Array of request descriptors built with the {@link req} helpers.
* @param options.concurrency - Max concurrent requests. Must be 1–1000. `false` for
* sequential. Defaults to `config.concurrency`, or 10 if not set on the client.
* @param options.rateLimitBackoff - Concurrency cap on 429 responses. Defaults to
* `config.rateLimitBackoff`, or 1 if not set on the client.
* @param options.backoff - Divisor (or function) applied to concurrency on error responses.
* Defaults to `config.backoff`, or 2 if not set on the client.
* @param options.backoffRecover - Amount (or function) added to concurrency per successful
* response. Defaults to `config.backoffRecover`, or 1 if not set on the client.
*
* @returns {@link BatchResult} array in the order requests completed (not input order).
*/
async batchSafe(requests, options) {
const results = [];
for await (const res of this.batchStream(requests, options)) results.push(res);
return results;
}
/**
* Streams all items from a v3 paginated endpoint, fetching the first page sequentially
* and remaining pages concurrently via {@link batchStream}.
*
* Each yielded value is a {@link PageResult} — check `err` before using `data`, and use
* `page` to correlate the item back to its source page. Use
* {@link collect} to gather all items into an array.
*
* **Sorting and concurrency:** the first page is fetched sequentially; remaining pages are
* fetched concurrently and may complete out of order. When `concurrency > 1`, sort order
* is not prese