bc-api-client
Version:
A client for the BigCommerce management API and app authentication
1 lines • 122 kB
Source Map (JSON)
{"version":3,"file":"index.mjs","names":[],"sources":["../src/lib/common.ts","../src/lib/errors.ts","../src/lib/logger.ts","../src/auth.ts","../src/lib/util.ts","../src/lib/hooks.ts","../src/lib/request.ts","../src/lib/result.ts","../src/client.ts"],"sourcesContent":["import type { Options as KyOptions } from 'ky';\nimport type { LimitFunction } from 'p-limit';\nimport type { Logger, LogLevel } from './logger';\n\nexport type { Logger, LogLevel };\n\nexport type ConcurrencyOptions = {\n /** Max concurrent requests. Must be 1–1000. `false` for sequential. Defaults to 10. */\n concurrency?: number | false;\n /**\n * Divisor (or `(concurrency, status) => number` function) applied to concurrency on\n * non-429 error responses. Defaults to 2.\n */\n backoff?: ((concurrency: number, status: number) => number) | number;\n /** Concurrency cap applied when a 429 response is received. Defaults to 1. */\n rateLimitBackoff?: number;\n /**\n * Amount (or `(concurrency) => number` function) added to concurrency per successful\n * response while below the configured max. Defaults to 1.\n */\n backoffRecover?: ((concurrency: number) => number) | number;\n /**\n * A p-limit instance to reuse across calls. When provided, `batchStream` uses it instead of\n * creating a new one, allowing callers to observe and react to live concurrency changes.\n */\n pLimit?: LimitFunction;\n};\n\n/** Maximum allowed concurrency value. */\nexport const MAX_CONCURRENCY = 1000;\n/** Default concurrency for batch/stream operations. */\nexport const DEFAULT_CONCURRENCY = 10;\n/** Default concurrency cap on 429 rate-limit responses. */\nexport const DEFAULT_RATE_LIMIT_BACKOFF = 1;\n/** Default divisor applied to concurrency on non-429 errors. */\nexport const DEFAULT_BACKOFF_RATE = 2;\n/** Default amount added to concurrency per successful response. */\nexport const DEFAULT_BACKOFF_RECOVER = 1;\n/** Default page size for paginated requests. */\nexport const DEFAULT_LIMIT = 250;\n/** Maximum allowed URL length before chunking is required. */\nexport const MAX_URL_LENGTH = 2048;\n/** Regex to strip leading slashes from API paths. */\nexport const LEADING_SLASHES = /^\\/+/;\n/** Maximum pages to fetch during blind pagination **/\nexport const DEFAULT_MAX_BLIND_PAGES = 500;\n\n/**\n * Configuration options for the BigCommerce client.\n */\nexport interface ClientConfig\n extends Omit<KyOptions, 'throwHttpErrors' | 'parseJson' | 'method' | 'body' | 'json' | 'searchParams'>,\n ConcurrencyOptions {\n storeHash: string;\n accessToken: string;\n logger?: Logger | LogLevel | boolean;\n}\n\n/**\n * Random positive jitter within 0-500 ms in increments of 100\n * @param {number} delay\n */\nexport const rateLimitJitter = (delay: number) => delay + Math.floor(Math.random() * 6) * 100;\n\n/**\n * HTTP header names used by the BigCommerce API.\n */\nexport const HEADERS = {\n AUTH_TOKEN: 'X-Auth-Token',\n ACCEPT: 'Accept',\n CONTENT_TYPE: 'Content-Type',\n RATE_LIMIT_LEFT: 'x-rate-limit-requests-left',\n RATE_LIMIT_RESET: 'x-rate-limit-time-reset-ms',\n RATE_LIMIT_QUOTA: 'x-rate-limit-requests-quota',\n RATE_LIMIT_WINDOW: 'x-rate-limit-time-window-ms',\n} as const;\n\n/**\n * Metadata extracted from rate-limit headers in API responses.\n */\nexport type RateLimitMeta = {\n /** Time in milliseconds until the rate limit resets. */\n resetIn: number;\n /** Number of requests remaining in the current window. */\n requestsLeft?: number;\n /** Total request quota for the current window. */\n quota?: number;\n /** Time window size in milliseconds. */\n window?: number;\n};\n\n/**\n * Default configuration for the underlying ky HTTP client.\n */\nexport const BASE_KY_CONFIG = {\n prefix: 'https://api.bigcommerce.com',\n throwHttpErrors: true,\n // Some BC endpoints may take a while.\n // For example /catalog/product/options* endpoints may fully\n // recreate all variants in some cases\n timeout: 120e3,\n\n retry: {\n limit: 3,\n // BC uses PUT for many upsert operations, it's not guaranteed to be idempotent\n methods: ['get', 'delete'],\n statusCodes: [429, 500, 502, 503, 504],\n // BC does not send standart Retry-After. We'll use custom beforeRetry hook\n afterStatusCodes: [],\n jitter: true,\n maxRetryAfter: 120e3,\n },\n\n headers: {\n [HEADERS.ACCEPT]: 'application/json',\n [HEADERS.CONTENT_TYPE]: 'application/json',\n },\n};\n\n/**\n * Concurrency options with all values resolved to their defaults.\n */\nexport type ResolvedConcurrencyOptions = Required<Omit<ConcurrencyOptions, 'pLimit'>> &\n Pick<ConcurrencyOptions, 'pLimit'>;\n","import type { HTTPError, KyRequest, TimeoutError as KyTimeoutError } from 'ky';\nimport type { Query } from './request';\nimport type { StandardSchemaV1 } from './standard-schema';\n\nexport type ErrorContext = Record<string, unknown>;\n\n/**\n * Abstract base class for all library errors. Carries a typed `context` object with\n * structured diagnostic data and a machine-readable `code` string.\n *\n * Use `instanceof` checks against specific subclasses rather than this base class.\n */\nexport abstract class BaseError<TContext extends ErrorContext = ErrorContext> extends Error {\n /** Machine-readable error code. Unique per subclass. */\n abstract readonly code: string;\n\n constructor(\n message: string,\n readonly context: TContext,\n options?: ErrorOptions,\n ) {\n super(message, options);\n\n this.name = this.constructor.name;\n }\n\n /** @internal */\n toJSON() {\n return {\n name: this.name,\n code: this.code,\n message: this.message,\n context: this.context,\n cause: this.cause,\n };\n }\n}\n\n/** Catch-all for unexpected client-side errors not covered by a more specific subclass. */\nexport class BCClientError extends BaseError<Record<string, unknown>> {\n code = 'BC_CLIENT_ERROR';\n\n constructor(message: string, context?: Record<string, unknown>, cause?: unknown) {\n super(message, context ?? {}, { cause });\n }\n}\n\n/** Thrown by the {@link BigCommerceClient} constructor when credentials or config are invalid. */\nexport class BCCredentialsError extends BaseError<{\n errors: string[];\n}> {\n code = 'BC_CLIENT_CREDENTIALS_ERROR';\n\n constructor(errors: string[]) {\n super('Failed to initialize BigCommerceClient', { errors });\n }\n}\n\n/** Thrown before a request is sent when the constructed URL exceeds 2048 characters. */\nexport class BCUrlTooLongError extends BaseError<{\n url: string;\n max: number;\n len: number;\n}> {\n code = 'BC_URL_TOO_LONG';\n\n constructor(url: string, max: number) {\n super(`Url length (${url.length}) exceeds max allowed length of ${max}`, { url, max, len: url.length });\n }\n}\n\n/**\n * Thrown during retry when a 429 response is received but the expected\n * `X-Rate-Limit-*` headers are absent, making it impossible to determine the backoff delay.\n */\nexport class BCRateLimitNoHeadersError extends BaseError<{\n url: string;\n method: string;\n attempts: number;\n}> {\n code = 'BC_RATE_LIMIT_NO_HEADERS';\n\n constructor(request: KyRequest, attempts: number) {\n super('Rate limit reached but the X-Rate-Limit-* headers were not returned. Unable to retry', {\n url: request.url,\n method: request.method,\n attempts,\n });\n }\n}\n\n/**\n * Thrown during retry when a 429 response specifies a reset window that exceeds\n * `config.retry.maxRetryAfter`, preventing an unbounded wait.\n */\nexport class BCRateLimitDelayTooLongError extends BaseError<{\n url: string;\n method: string;\n attempts: number;\n maxDelay: number;\n delay: number;\n}> {\n code = 'BC_RATE_LIMIT_DELAY_TOO_LONG';\n\n constructor(request: KyRequest, attempts: number, maxDelay: number, delay: number) {\n super('Rate limit reached, and the rate limit reset window is too high.', {\n url: request.url,\n method: request.method,\n attempts,\n maxDelay,\n delay,\n });\n }\n}\n\n/**\n * Abstract base for all StandardSchema validation errors. Carries the raw `data` that failed\n * validation and the schema `error` result. Use specific subclasses for `instanceof` checks.\n */\nexport abstract class BCSchemaValidationError extends BaseError<{\n method: string;\n path: string;\n data: unknown;\n error: StandardSchemaV1.FailureResult;\n}> {\n constructor(message: string, method: string, path: string, data: unknown, error: StandardSchemaV1.FailureResult) {\n super(message, { method, path, data, error });\n }\n}\n\n/** Thrown when `options.querySchema` validation fails before a request is sent. */\nexport class BCQueryValidationError extends BCSchemaValidationError {\n code = 'BC_QUERY_VALIDATION_FAILED';\n}\n\n/** Thrown when `options.bodySchema` validation fails before a request is sent. */\nexport class BCRequestBodyValidationError extends BCSchemaValidationError {\n code = 'BC_REQUEST_BODY_VALIDATION_FAILED';\n}\n\n/** Thrown when `options.responseSchema` validation fails after a response is received. */\nexport class BCResponseValidationError extends BCSchemaValidationError {\n code = 'BC_RESPONSE_VALIDATION_FAILED';\n}\n\n/** Thrown or yielded when `options.itemSchema` validation fails for an item in a page response. */\nexport class BCPaginatedItemValidationError extends BCSchemaValidationError {\n code = 'BC_PAGINATED_ITEM_VALIDATION_FAILED';\n}\n\n/**\n * Thrown when the BigCommerce API returns a non-2xx HTTP response.\n * `context.status` and `context.responseBody` are the most useful fields for debugging.\n */\nexport class BCApiError extends BaseError<{\n method: string;\n url: string;\n status: number;\n statusMessage: string;\n headers: Record<string, string>;\n requestBody: string;\n responseBody: unknown;\n}> {\n code = 'BC_API_ERROR';\n\n constructor(err: HTTPError, requestBody: string) {\n const { request, response, data } = err;\n\n super('BigCommerce API request failed', {\n method: request.method,\n url: request.url,\n status: response.status,\n statusMessage: response.statusText,\n headers: Object.fromEntries(response.headers as unknown as Iterable<[string, string]>),\n requestBody,\n responseBody: data,\n });\n }\n}\n\n/** Thrown when a request exceeds the configured timeout (default 120 s). */\nexport class BCTimeoutError extends BaseError<{\n method: string;\n url: string;\n}> {\n code = 'BC_TIMEOUT_ERROR';\n\n constructor(err: KyTimeoutError) {\n super('BigCommerce API request timed out', {\n method: err.request.method,\n url: err.request.url,\n });\n }\n}\n\n/**\n * Thrown when the response body cannot be read or parsed as JSON.\n * `context.rawBody` contains the raw text that failed to parse (empty string if the body was empty).\n */\nexport class BCResponseParseError extends BaseError<{\n method: string;\n status: number;\n path: string;\n query?: Query;\n rawBody?: string;\n}> {\n code = 'BC_RESPONSE_PARSE_ERROR';\n\n constructor(method: string, path: string, status: number, cause: unknown, query?: Query, rawBody?: string) {\n super(\n 'Failed to parse BigCommerce API response',\n {\n status,\n method,\n path,\n query,\n rawBody,\n },\n { cause },\n );\n }\n}\n\n/**\n * Thrown when a pagination option (`limit`, `page`, or `count`) is not a positive number.\n * `context.option` names the offending field; `context.value` is the value that was passed.\n */\nexport class BCPaginatedOptionError extends BaseError<{ path: string; option: string; value: unknown }> {\n code = 'BC_PAGINATED_OPTION_ERROR';\n\n constructor(path: string, value: unknown, option: string) {\n super('The pagination option must be a positive number', { path, option, value });\n }\n}\n\n/**\n * Thrown or yielded when a paginated response is missing required v3 envelope fields\n * (`data`, `meta.pagination`, etc.). Usually means the path is not a v3 collection endpoint.\n */\nexport class BCPaginatedResponseError extends BaseError<{ path: string; data: unknown; reason: string }> {\n code = 'BC_PAGINATED_RESPONSE_ERROR';\n\n constructor(path: string, data: unknown, reason: string) {\n super('Paginated response structure is invalid', { path, data, reason });\n }\n}\n\n/** Thrown by {@link BigCommerceAuth} constructor when `config.redirectUri` is not a valid URL. */\nexport class BCAuthInvalidRedirectUriError extends BaseError<{ redirectUri: string }> {\n code = 'BC_AUTH_INVALID_REDIRECT_URI';\n\n constructor(redirectUri: string, cause: unknown) {\n super('Invalid redirect URI', { redirectUri }, { cause });\n }\n}\n\n/** Thrown by {@link BigCommerceAuth.requestToken} when a required OAuth callback param is absent. */\nexport class BCAuthMissingParamError extends BaseError<{ param: string }> {\n code = 'BC_AUTH_MISSING_PARAM';\n\n constructor(param: string) {\n super(`Missing required auth callback parameter: ${param}`, { param });\n }\n}\n\n/**\n * Thrown by {@link BigCommerceAuth.requestToken} when the scopes granted by BigCommerce\n * do not include all scopes listed in `config.scopes`.\n * `context.missing` lists the scopes that were expected but not granted.\n */\nexport class BCAuthScopeMismatchError extends BaseError<{\n granted: string[];\n expected: string[];\n missing: string[];\n}> {\n code = 'BC_AUTH_SCOPE_MISMATCH';\n\n constructor(granted: string[], expected: string[], missing: string[]) {\n super('Granted scopes do not match expected scopes', { granted, expected, missing });\n }\n}\n\n/** Thrown by {@link BigCommerceAuth.verify} when the JWT signature, audience, issuer, or subject is invalid. */\nexport class BCAuthInvalidJwtError extends BaseError<{ storeHash: string }> {\n code = 'BC_AUTH_INVALID_JWT';\n\n constructor(storeHash: string, cause: unknown) {\n super('Invalid JWT payload', { storeHash }, { cause });\n }\n}\n","import type { ClientConfig } from './common';\n\n/**\n * Logging interface for the BigCommerce client.\n *\n * Implement this interface to provide custom logging. The client passes context data\n * as the first argument, making it compatible with structured loggers.\n */\nexport interface Logger {\n debug(data: Record<string, unknown>, message?: string): void;\n info(data: Record<string, unknown>, message?: string): void;\n warn(data: Record<string, unknown>, message?: string): void;\n error(data: Record<string, unknown>, message?: string): void;\n}\n\nexport type PowertoolsLikeLogger = {\n debug(message: string, ...data: Record<string, unknown>[]): void;\n info(message: string, ...data: Record<string, unknown>[]): void;\n warn(message: string, ...data: Record<string, unknown>[]): void;\n error(message: string, ...data: Record<string, unknown>[]): void;\n};\n\n/** @internal */\nexport const LOG_LEVELS = ['debug', 'info', 'warn', 'error'] as const;\n\n/** Supported log levels. */\nexport type LogLevel = (typeof LOG_LEVELS)[number];\n\n/**\n * Adapts an AWS Lambda Powertools logger to the {@link Logger} interface expected by\n * {@link BigCommerceClient} and {@link BigCommerceAuth}.\n *\n * Powertools loggers use `(message, ...data)` argument order whereas this library uses\n * `(data, message)`. This adapter swaps the arguments.\n *\n * @param logger - An AWS Lambda Powertools (or any {@link PowertoolsLikeLogger}-compatible) logger.\n * @returns A {@link Logger} wrapper suitable for `config.logger`.\n */\nexport const fromAwsPowertoolsLogger = (logger: PowertoolsLikeLogger): Logger => ({\n debug: (data, message) => logger.debug(message ?? '', data),\n info: (data, message) => logger.info(message ?? '', data),\n warn: (data, message) => logger.warn(message ?? '', data),\n error: (data, message) => logger.error(message ?? '', data),\n});\n\n/**\n * Console-based {@link Logger} that filters messages below a minimum level.\n *\n * Used automatically when `config.logger` is `true`, `undefined`, or a {@link LogLevel} string.\n * Can also be instantiated directly for custom log level control.\n *\n * @example\n * ```ts\n * new BigCommerceClient({ ..., logger: new FallbackLogger('debug') });\n * ```\n */\nexport class FallbackLogger implements Logger {\n /**\n * @param level - Minimum level to output. Messages below this level are silently dropped.\n */\n constructor(public readonly level: LogLevel) {}\n\n debug(data: Record<string, unknown>, message?: string): void {\n this.log('debug', data, message);\n }\n\n info(data: Record<string, unknown>, message?: string): void {\n this.log('info', data, message);\n }\n\n warn(data: Record<string, unknown>, message?: string): void {\n this.log('warn', data, message);\n }\n\n error(data: Record<string, unknown>, message?: string): void {\n this.log('error', data, message);\n }\n\n private log(level: LogLevel, data: Record<string, unknown>, message?: string) {\n if (LOG_LEVELS.indexOf(level) < LOG_LEVELS.indexOf(this.level)) {\n return;\n }\n\n const fn = console[level];\n\n message !== undefined ? fn(message, data) : fn(data);\n }\n}\n\n/**\n * @internal\n */\nexport const initLogger = (logger: ClientConfig['logger']): Logger | undefined => {\n if (logger === false) {\n return;\n }\n\n if (logger === undefined || logger === true) {\n return new FallbackLogger('info');\n }\n\n if (typeof logger === 'string') {\n if (LOG_LEVELS.includes(logger)) {\n return new FallbackLogger(logger);\n } else {\n const logger = new FallbackLogger('info');\n\n logger.warn({ level: logger }, 'Unknown log level passed, using info');\n\n return logger;\n }\n }\n\n return logger;\n};\n","import * as jose from 'jose';\nimport ky, { isHTTPError, isTimeoutError } from 'ky';\nimport { BASE_KY_CONFIG } from './lib/common';\nimport {\n BCApiError,\n BCAuthInvalidJwtError,\n BCAuthInvalidRedirectUriError,\n BCAuthMissingParamError,\n BCAuthScopeMismatchError,\n BCClientError,\n BCTimeoutError,\n} from './lib/errors';\nimport { initLogger, type Logger, type LogLevel } from './lib/logger';\n\n/**\n * Configuration options for BigCommerce authentication\n */\nexport type BigCommerceAuthConfig = {\n /** The OAuth client ID from BigCommerce */\n clientId: string;\n /** The OAuth client secret from BigCommerce */\n secret: string;\n /** The redirect URI registered with BigCommerce */\n redirectUri: string;\n /** Optional array of scopes to validate during auth callback */\n scopes?: string[];\n /** Optional logger instance */\n logger?: Logger | LogLevel | boolean;\n};\n\nconst GRANT_TYPE = 'authorization_code';\nconst TOKEN_ENDPOINT = 'https://login.bigcommerce.com/oauth2/token';\nconst ISSUER = 'bc';\n\n/**\n * Query parameters received from BigCommerce auth callback\n */\nexport type BigCommerceAuthQuery = {\n /** The authorization code from BigCommerce */\n code: string;\n /** The granted OAuth scopes */\n scope: string;\n /** The store context */\n context: string;\n};\n\n/**\n * Request payload for token endpoint\n */\ntype TokenRequest = {\n client_id: string;\n client_secret: string;\n code: string;\n context: string;\n scope: string;\n grant_type: typeof GRANT_TYPE;\n redirect_uri: string;\n};\n\n/**\n * User information returned from BigCommerce\n */\nexport type User = {\n /** The user's ID */\n id: number;\n /** The user's username */\n username: string;\n /** The user's email address */\n email: string;\n};\n\n/**\n * Response from BigCommerce token endpoint\n */\nexport type TokenResponse = {\n /** The OAuth access token */\n access_token: string;\n /** The granted OAuth scopes */\n scope: string;\n /** Information about the authenticated user */\n user: User;\n /** Information about the store owner */\n owner: User;\n /** The store context */\n context: string;\n /** The BigCommerce account UUID */\n account_uuid: string;\n};\n\n/**\n * JWT claims from BigCommerce\n */\nexport type Claims = {\n /** JWT audience */\n aud: string;\n /** JWT issuer */\n iss: string;\n /** JWT issued at timestamp */\n iat: number;\n /** JWT not before timestamp */\n nbf: number;\n /** JWT expiration timestamp */\n exp: number;\n /** JWT unique identifier */\n jti: string;\n /** JWT subject */\n sub: string;\n /** Information about the authenticated user */\n user: {\n id: number;\n email: string;\n locale: string;\n };\n /** Information about the store owner */\n owner: {\n id: number;\n email: string;\n };\n /** The store URL */\n url: string;\n /** The channel ID (if applicable) */\n channel_id: number | null;\n};\n\n/**\n * Handles authentication with BigCommerce OAuth\n */\nexport class BigCommerceAuth {\n private readonly logger: Logger | undefined;\n private readonly client: ReturnType<typeof ky.create>;\n\n /**\n * Creates a new BigCommerceAuth instance for handling OAuth authentication\n * @param config - Configuration options for BigCommerce authentication\n * @param config.clientId - The OAuth client ID from BigCommerce\n * @param config.secret - The OAuth client secret from BigCommerce\n * @param config.redirectUri - The redirect URI registered with BigCommerce\n * @param config.scopes - Optional array of scopes to validate during auth callback\n * @param config.logger - Optional logger instance for debugging and error tracking\n * @throws {BCAuthInvalidRedirectUriError} If the redirect URI is invalid\n */\n constructor(private readonly config: BigCommerceAuthConfig) {\n try {\n new URL(this.config.redirectUri);\n } catch (error) {\n throw new BCAuthInvalidRedirectUriError(this.config.redirectUri, error);\n }\n\n this.logger = initLogger(config.logger);\n\n const { prefix: _, ...authKyConfig } = BASE_KY_CONFIG;\n\n this.client = ky.create({\n ...authKyConfig,\n retry: {\n ...authKyConfig.retry,\n methods: ['post'],\n },\n });\n }\n\n /**\n * Exchanges an OAuth authorization code for an access token.\n *\n * @param data - The auth callback payload: a raw query string, `URLSearchParams`, or a\n * pre-parsed object with `code`, `scope`, and `context`.\n * @returns The token response including `access_token`, `user`, and `context`.\n * @throws {@link BCAuthMissingParamError} if `code`, `scope`, or `context` are absent.\n * @throws {@link BCAuthScopeMismatchError} if the granted scopes don't include all `config.scopes`.\n * @throws {@link BCApiError} on HTTP error responses from the token endpoint.\n * @throws {@link BCTimeoutError} if the token request times out.\n * @throws {@link BCClientError} on any other error.\n */\n async requestToken(data: string | BigCommerceAuthQuery | URLSearchParams): Promise<TokenResponse> {\n const query = typeof data === 'string' || data instanceof URLSearchParams ? this.parseQueryString(data) : data;\n\n this.validateScopes(query.scope);\n\n const tokenRequest: TokenRequest = {\n client_id: this.config.clientId,\n client_secret: this.config.secret,\n ...query,\n grant_type: GRANT_TYPE,\n redirect_uri: this.config.redirectUri,\n };\n\n this.logger?.debug(\n {\n clientId: this.config.clientId,\n context: query.context,\n scopes: query.scope,\n },\n 'Requesting OAuth token',\n );\n\n let res: Response;\n\n try {\n res = await this.client(TOKEN_ENDPOINT, {\n method: 'POST',\n json: tokenRequest,\n });\n } catch (error) {\n if (isHTTPError(error)) {\n const requestBody = await error.request.text().catch(() => '');\n const err = new BCApiError(error, requestBody);\n\n this.logger?.error(err.context, 'Failed to request token');\n\n throw err;\n }\n\n if (isTimeoutError(error)) {\n const err = new BCTimeoutError(error);\n\n this.logger?.error(err.context, 'Token request timed out');\n\n throw err;\n }\n\n throw new BCClientError('Failed to request token', {}, error);\n }\n\n return res.json() as Promise<TokenResponse>;\n }\n\n /**\n * Verifies a JWT payload from BigCommerce\n * @param jwtPayload - The JWT string to verify\n * @param storeHash - The store hash for the BigCommerce store\n * @returns Promise resolving to the verified JWT claims\n * @throws {BCAuthInvalidJwtError} If the JWT is invalid\n */\n async verify(jwtPayload: string, storeHash: string): Promise<Claims> {\n try {\n const secret = new TextEncoder().encode(this.config.secret);\n\n const { payload }: { payload: Claims } = await jose.jwtVerify(jwtPayload, secret, {\n audience: this.config.clientId,\n issuer: ISSUER,\n subject: `stores/${storeHash}`,\n });\n\n this.logger?.debug(\n {\n userId: payload.user?.id,\n storeHash: payload.sub.split('/')[1],\n },\n 'JWT verified successfully',\n );\n\n return payload;\n } catch (error) {\n const err = new BCAuthInvalidJwtError(storeHash, error);\n\n this.logger?.error(err.context, 'JWT verification failed');\n\n throw err;\n }\n }\n\n /**\n * Parses and validates a query string from BigCommerce auth callback\n * @param queryString - The query string to parse\n * @returns The parsed auth query parameters\n * @throws {BCAuthMissingParamError} If required parameters are missing\n */\n private parseQueryString(queryString: string | URLSearchParams): BigCommerceAuthQuery {\n const params = typeof queryString === 'string' ? new URLSearchParams(queryString) : queryString;\n\n const code = params.get('code');\n const scope = params.get('scope');\n const context = params.get('context');\n\n if (!code) {\n throw new BCAuthMissingParamError('code');\n }\n\n if (!scope) {\n throw new BCAuthMissingParamError('scope');\n } else if (this.config.scopes?.length) {\n this.validateScopes(scope);\n }\n\n if (!context) {\n throw new BCAuthMissingParamError('context');\n }\n\n return {\n code,\n scope,\n context,\n };\n }\n\n /**\n * Validates that the granted scopes match the expected scopes\n * @param scopes - Space-separated list of granted scopes\n * @throws {BCAuthScopeMismatchError} If the scopes don't match the expected scopes\n */\n private validateScopes(scopes: string) {\n if (!this.config.scopes) {\n return;\n }\n\n const granted = scopes.split(' ');\n const expected = this.config.scopes;\n const missing = expected.filter((scope) => !granted.includes(scope));\n\n if (missing.length) {\n throw new BCAuthScopeMismatchError(granted, expected, missing);\n }\n }\n}\n","import { HEADERS, type RateLimitMeta } from './common';\n\nexport function stripKeys<T extends object, K extends PropertyKey>(\n obj: T | undefined,\n keys: K[],\n): Omit<T, K> | undefined {\n if (!obj) {\n return obj;\n }\n\n const result = { ...obj } as Record<PropertyKey, unknown>;\n\n for (const key of keys) {\n delete result[key];\n }\n\n return result as Omit<T, K>;\n}\n\nconst parseIntHeader = (headers: Headers, key: string): number | undefined => {\n const value = Number.parseInt(headers.get(key) ?? '', 10);\n\n return Number.isNaN(value) ? undefined : value;\n};\n\nexport const extractRateLimitHeaders = (headers: Headers): RateLimitMeta | undefined => {\n const resetIn = parseIntHeader(headers, HEADERS.RATE_LIMIT_RESET);\n\n // Can't retry without this header - treat as unrecoverable\n if (resetIn === undefined) {\n return undefined;\n }\n\n return {\n resetIn,\n requestsLeft: parseIntHeader(headers, HEADERS.RATE_LIMIT_LEFT),\n quota: parseIntHeader(headers, HEADERS.RATE_LIMIT_QUOTA),\n window: parseIntHeader(headers, HEADERS.RATE_LIMIT_WINDOW),\n };\n};\n\nexport const chunkStrLength = (\n items: string[],\n options: {\n maxLength?: number;\n chunkLength?: number;\n offset?: number;\n separatorSize?: number;\n } = {},\n) => {\n const { maxLength = 2048, chunkLength = 250, offset = 0, separatorSize = 1 } = options;\n\n const chunks: string[][] = [];\n let currentStrLength = offset;\n let currentChunk: string[] = [];\n\n for (const item of items) {\n const itemLength = encodeURIComponent(item).length;\n const separatorLength = currentChunk.length > 0 ? separatorSize : 0;\n const totalItemLength = itemLength + separatorLength;\n\n const wouldExceedLength = currentStrLength + totalItemLength > maxLength;\n const wouldExceedCount = currentChunk.length >= chunkLength;\n\n if ((wouldExceedLength || wouldExceedCount) && currentChunk.length > 0) {\n chunks.push(currentChunk);\n currentChunk = [];\n currentStrLength = offset;\n }\n\n if (itemLength + offset > maxLength) {\n throw new Error(`Item too large: ${itemLength} exceeds maxLength ${maxLength}`);\n }\n\n currentChunk.push(item);\n currentStrLength += itemLength + (currentChunk.length > 1 ? separatorSize : 0);\n }\n\n if (currentChunk.length > 0) {\n chunks.push(currentChunk);\n }\n\n return chunks;\n};\n\nexport class AsyncChannel<T> {\n private readonly queue: T[] = [];\n private notify: (() => void) | null = null;\n private done = false;\n\n push(item: T) {\n this.queue.push(item);\n this.notify?.();\n this.notify = null;\n }\n\n close() {\n this.done = true;\n this.notify?.();\n this.notify = null;\n }\n\n async *[Symbol.asyncIterator](): AsyncGenerator<T> {\n while (!this.done || this.queue.length > 0) {\n if (this.queue.length === 0) {\n await new Promise<void>((r) => {\n if (this.queue.length > 0) {\n return r();\n }\n\n this.notify = r;\n });\n }\n\n while (this.queue.length > 0) {\n const item = this.queue.shift();\n\n if (item !== undefined) {\n yield item;\n }\n }\n }\n }\n}\n","import { type BeforeRequestHook, type BeforeRetryHook, isHTTPError } from 'ky';\nimport { MAX_URL_LENGTH, rateLimitJitter } from './common';\nimport { BCRateLimitDelayTooLongError, BCRateLimitNoHeadersError, BCUrlTooLongError } from './errors';\nimport type { Logger } from './logger';\nimport { extractRateLimitHeaders } from './util';\n\nexport const validateUrlLength: BeforeRequestHook = ({ request }) => {\n if (request.url.length > MAX_URL_LENGTH) {\n throw new BCUrlTooLongError(request.url, MAX_URL_LENGTH);\n }\n};\n\nexport const bcRateLimitRetry =\n (logger?: Logger): BeforeRetryHook =>\n async ({ request, options, error, retryCount }) => {\n if (isHTTPError(error) && error.response.status === 429) {\n const retryMeta = extractRateLimitHeaders(error.response.headers);\n\n if (!retryMeta) {\n throw new BCRateLimitNoHeadersError(request, retryCount);\n }\n\n if (options.retry.maxRetryAfter && retryMeta.resetIn > options.retry.maxRetryAfter) {\n throw new BCRateLimitDelayTooLongError(\n request,\n retryCount,\n options.retry.maxRetryAfter,\n retryMeta.resetIn,\n );\n }\n\n const delay =\n typeof options.retry.jitter === 'function'\n ? options.retry.jitter(retryMeta.resetIn)\n : rateLimitJitter(retryMeta.resetIn);\n\n logger?.warn(\n { attempt: retryCount, url: request.url, method: request.method, retryMeta },\n `Rate limit reached, retrying in ${delay} (with jitter)`,\n );\n\n await new Promise((resolve) => setTimeout(resolve, delay));\n } else {\n logger?.warn({ url: request.url, method: request.method, attempt: retryCount }, 'Retrying request');\n }\n };\n","import type { Options as KyOptions } from 'ky';\nimport type { ConcurrencyOptions } from './common';\nimport type { StandardSchemaV1 } from './standard-schema';\n\n/** BigCommerce API versions supported by the client. */\nexport type ApiVersion = 'v3' | 'v2';\n\n/** Valid query parameter value types. */\nexport type QueryValue = string | number | Array<string | number>;\n\n/** Query parameter object for API requests. */\nexport type Query = Record<string, QueryValue>;\n\n/**\n * Converts a Query object to URLSearchParams.\n * Array values are joined with commas (e.g., `id:in=1,2,3`).\n */\nexport const toUrlSearchParams = (query?: Query): URLSearchParams | undefined => {\n if (!query) {\n return;\n }\n\n const params = new URLSearchParams();\n\n for (const [key, value] of Object.entries(query)) {\n if (Array.isArray(value)) {\n params.append(key, value.map(String).join(','));\n } else {\n params.append(key, String(value));\n }\n }\n\n return params;\n};\n\n/** Supported HTTP methods for API requests. */\nexport type HttpMethod = 'POST' | 'GET' | 'PUT' | 'DELETE';\n\n/** @internal */\ntype BaseKyRequest = Omit<\n KyOptions,\n 'json' | 'method' | 'searchQueryParams' | 'body' | 'throwHttpErrors' | 'parseJson'\n>;\n\n/** @internal */\ntype QuerySchemaOptions<TQuery extends Query> =\n /** Query parameters to send with the request. */\n { query: TQuery; querySchema: StandardSchemaV1<TQuery> } | { query?: TQuery; querySchema?: never };\n\n/** @internal */\ntype BodySchemaOptions<TBody> =\n /** Request body, serialized as JSON. */\n { body: TBody; bodySchema: StandardSchemaV1<TBody> } | { body?: TBody; bodySchema?: never };\n\n/**\n * Full request options for direct API calls.\n * @see {@link GetOptions}, {@link PostOptions}, {@link PutOptions}, {@link DeleteOptions}\n */\nexport type RequestOptions<TBody, TRes, TQuery extends Query> = BaseKyRequest &\n QuerySchemaOptions<TQuery> &\n BodySchemaOptions<TBody> & {\n /** HTTP method for the request. */\n method: HttpMethod;\n /** API version to use. Defaults to `'v3'`. */\n version?: ApiVersion;\n /** Schema to validate the response body. */\n responseSchema?: StandardSchemaV1<TRes>;\n };\n\n/** Options for GET requests. */\nexport type GetOptions<TRes, TQuery extends Query> = Omit<\n RequestOptions<never, TRes, TQuery>,\n 'body' | 'bodySchema' | 'method'\n>;\n\n/** Options for POST requests. */\nexport type PostOptions<TBody, TRes, TQuery extends Query> = Omit<RequestOptions<TBody, TRes, TQuery>, 'method'>;\n\n/** Options for PUT requests. */\nexport type PutOptions<TBody, TRes, TQuery extends Query> = PostOptions<TBody, TRes, TQuery>;\n\n/** Options for DELETE requests. */\nexport type DeleteOptions<TQuery extends Query> = Omit<\n RequestOptions<never, never, TQuery>,\n 'body' | 'bodySchema' | 'method' | 'responseSchema'\n>;\n\n/**\n * Request descriptor for batch operations.\n * Use the {@link req} helpers to construct these.\n */\nexport type BatchRequestOptions<TBody, TRes, TQuery extends Query> = {\n path: string;\n} & RequestOptions<TBody, TRes, TQuery>;\n\n/**\n * Helpers for building typed request descriptors to pass to\n * {@link BigCommerceClient.batchSafe} or {@link BigCommerceClient.batchStream}.\n *\n * @example\n * ```ts\n * const results = await client.batchSafe([\n * req.get('catalog/products/1'),\n * req.post('catalog/products', { body: { name: 'Widget' } }),\n * ]);\n * ```\n */\nexport const req = {\n /**\n * Builds a GET request descriptor.\n * @param path - API path relative to the store's versioned base URL.\n * @param options - Optional query params, schemas, and ky options.\n */\n get: <TRes, TQuery extends Query = Query>(\n path: string,\n options?: GetOptions<TRes, TQuery>,\n ): BatchRequestOptions<never, TRes, TQuery> =>\n ({ method: 'GET', path, ...options }) as BatchRequestOptions<never, TRes, TQuery>,\n\n /**\n * Builds a POST request descriptor.\n * @param path - API path relative to the store's versioned base URL.\n * @param options - Optional body, query params, schemas, and ky options.\n */\n post: <TRes, TBody = unknown, TQuery extends Query = Query>(\n path: string,\n options?: PostOptions<TBody, TRes, TQuery>,\n ): BatchRequestOptions<TBody, TRes, TQuery> =>\n ({ method: 'POST', path, ...options }) as BatchRequestOptions<TBody, TRes, TQuery>,\n\n /**\n * Builds a PUT request descriptor.\n * @param path - API path relative to the store's versioned base URL.\n * @param options - Optional body, query params, schemas, and ky options.\n */\n put: <TRes, TBody = unknown, TQuery extends Query = Query>(\n path: string,\n options?: PutOptions<TBody, TRes, TQuery>,\n ): BatchRequestOptions<TBody, TRes, TQuery> =>\n ({ method: 'PUT', path, ...options }) as BatchRequestOptions<TBody, TRes, TQuery>,\n\n /**\n * Builds a DELETE request descriptor.\n * @param path - API path relative to the store's versioned base URL.\n * @param options - Optional query params and ky options.\n */\n delete: <TQuery extends Query = Query>(\n path: string,\n options?: DeleteOptions<TQuery>,\n ): BatchRequestOptions<never, never, TQuery> =>\n ({ method: 'DELETE', path, ...options }) as BatchRequestOptions<never, never, TQuery>,\n};\n\n/**\n * Options for v3 paginated collection operations ({@link BigCommerceClient.collect}, {@link BigCommerceClient.stream}).\n */\nexport type CollectOptions<TItem, TQuery extends Query> = ConcurrencyOptions &\n Omit<GetOptions<TItem, TQuery>, 'responseSchema' | 'version'> & {\n /** Schema to validate each item in the response. */\n itemSchema?: StandardSchemaV1<TItem>;\n };\n\nexport type BlindOptions<TItem, TQuery extends Query> = Omit<CollectOptions<TItem, TQuery>, 'version'> & {\n maxPages?: number;\n};\n\n/**\n * Options for v2 paginated operations with known count ({@link BigCommerceClient.collectCount}, {@link BigCommerceClient.streamCount}).\n */\nexport type CountedCollectOptions<TItem, TQuery extends Query> = CollectOptions<TItem, TQuery> & {\n /** Total number of items expected (for v2 endpoints without pagination metadata). */\n count?: number;\n};\n\n/**\n * Options for query-based filtering operations ({@link BigCommerceClient.query}, {@link BigCommerceClient.queryStream}).\n */\nexport type QueryOptions<TItem, TQuery extends Query> = CollectOptions<TItem, TQuery> & {\n /** Query parameter name for value filtering (e.g., `'id:in'`). */\n key: string;\n /** Values to filter by. Automatically chunked across multiple requests. */\n values: (string | number)[];\n};\n","export type Ok<T> = {\n ok: true;\n data: T;\n err: undefined;\n};\n\nexport type Err<E> = {\n ok: false;\n data: undefined;\n err: E;\n};\n\nexport type Result<T, E> = Ok<T> | Err<E>;\n\n/**\n * A {@link Result} extended with the zero-based index of the originating request in the input\n * array passed to {@link BigCommerceClient.batchStream} or {@link BigCommerceClient.batchSafe}.\n *\n * Because concurrent requests complete out of insertion order, `index` is the only reliable way\n * to correlate a result back to its input.\n *\n * @example\n * ```ts\n * const requests = ids.map(id => req.get(`catalog/products/${id}`));\n * for await (const { index, err, data } of client.batchStream(requests)) {\n * const originalId = ids[index];\n * if (err) { console.error(originalId, err); continue; }\n * console.log(originalId, data);\n * }\n * ```\n */\nexport type BatchResult<T, E> = Result<T, E> & { index: number };\n\n/**\n * A {@link Result} extended with the one-based page number from which the item was fetched.\n *\n * Because concurrent requests complete out of page order, `page` is the only reliable way\n * to correlate a result back to its source page when using {@link BigCommerceClient.stream}\n * or {@link BigCommerceClient.streamBlind}.\n *\n * @example\n * ```ts\n * for await (const { page, err, data } of client.stream('catalog/products')) {\n * if (err) { console.error(`page ${page}:`, err); continue; }\n * console.log(`page ${page}:`, data);\n * }\n * ```\n */\nexport type PageResult<T, E> = Result<T, E> & { page: number };\n\n/**\n * Creates a successful {@link Result}. Check `result.ok` or `result.err` before accessing `data`.\n * @param data - The success value.\n */\nexport const Ok = <T, E>(data: T): Result<T, E> => ({ ok: true, data, err: undefined });\n\n/**\n * Creates a failed {@link Result}. Check `result.ok` or `result.err` before accessing `err`.\n * @param err - The error value.\n */\nexport const Err = <T, E>(err: E): Result<T, E> => ({ ok: false, data: undefined, err });\n","import ky, { isHTTPError, isKyError, isTimeoutError, type KyInstance, type KyResponse } from 'ky';\nimport pLimit, { type LimitFunction } from 'p-limit';\nimport {\n BASE_KY_CONFIG,\n type ClientConfig,\n type ConcurrencyOptions,\n DEFAULT_BACKOFF_RATE,\n DEFAULT_BACKOFF_RECOVER,\n DEFAULT_CONCURRENCY,\n DEFAULT_LIMIT,\n DEFAULT_MAX_BLIND_PAGES,\n DEFAULT_RATE_LIMIT_BACKOFF,\n HEADERS,\n LEADING_SLASHES,\n type Logger,\n MAX_CONCURRENCY,\n MAX_URL_LENGTH,\n type ResolvedConcurrencyOptions,\n} from './lib/common';\nimport {\n BaseError,\n BCApiError,\n BCClientError,\n BCCredentialsError,\n BCPaginatedItemValidationError,\n BCPaginatedOptionError,\n BCPaginatedResponseError,\n BCQueryValidationError,\n BCRequestBodyValidationError,\n BCResponseParseError,\n BCResponseValidationError,\n type BCSchemaValidationError,\n BCTimeoutError,\n} from './lib/errors';\nimport { bcRateLimitRetry, validateUrlLength } from './lib/hooks';\nimport { initLogger } from './lib/logger';\nimport type { V3Resource } from './lib/pagination';\nimport {\n type ApiVersion,\n type BatchRequestOptions,\n type BlindOptions,\n type CollectOptions,\n type DeleteOptions,\n type GetOptions,\n type PostOptions,\n type PutOptions,\n type Query,\n type QueryOptions,\n type RequestOptions,\n req,\n toUrlSearchParams,\n} from './lib/request';\nimport { type BatchResult, Err, Ok, type PageResult, type Result } from './lib/result';\nimport type { StandardSchemaV1 } from './lib/standard-schema';\nimport { AsyncChannel, chunkStrLength } from './lib/util';\n\nexport class BigCommerceClient {\n private readonly logger?: Logger;\n private readonly client: KyInstance;\n private readonly storeHash: string;\n\n /**\n * Creates a new BigCommerceClient.\n *\n * @param config - Client configuration. Ky options (e.g. `prefix`, `timeout`, `retry`,\n * `hooks`) are forwarded to the underlying ky instance.\n * @param config.storeHash - BigCommerce store hash. Must be a non-empty string.\n * @param config.accessToken - BigCommerce API access token. Must be a non-empty string.\n * @param config.logger - A {@link Logger} instance, a log level string\n * (`'debug' | 'info' | 'warn' | 'error'`), `true` to enable console logging at `'info'`\n * level, or `false` to disable logging entirely. Omitting also defaults to `'info'` level.\n * @param config.concurrency - Default max concurrent requests for batch/stream operations.\n * Must be between 1 and 1000. Pass `false` to disable concurrency (sequential execution).\n * Defaults to 10.\n * @param config.rateLimitBackoff - Concurrency cap applied when a 429 response is received.\n * Defaults to 1.\n * @param config.backoff - Divisor (or `(concurrency, status) => number` function) applied to\n * concurrency on non-429 error responses. Defaults to 2.\n * @param config.backoffRecover - Amount (or `(concurrency) => number` function) added to\n * concurrency per successful response while below the configured max. Defaults to 1.\n *\n * @throws {@link BCCredentialsError} if `storeHash` or `accessToken` are missing.\n * @throws {@link BCClientError} if `prefix` is not a valid URL or `concurrency` is out of range.\n */\n constructor(private readonly config: ClientConfig) {\n this.validateConfig();\n\n const { storeHash, accessToken, logger, concurrency: _, ...kyOptions } = config;\n\n this.logger = initLogger(logger);\n this.storeHash = storeHash;\n\n this.client = ky.create({\n ...BASE_KY_CONFIG,\n ...kyOptions,\n\n headers: {\n ...BASE_KY_CONFIG.headers,\n ...((kyOptions.headers ?? {}) as Record<string, string>),\n [HEADERS.AUTH_TOKEN]: accessToken,\n },\n\n hooks: {\n beforeRequest: [...(kyOptions.hooks?.beforeRequest ?? []), validateUrlLength],\n beforeRetry: [\n ({ error }) => {\n if (error instanceof BaseError) {\n throw error;\n }\n },\n bcRateLimitRetry(this.logger),\n ...(kyOptions.hooks?.beforeRetry ?? []),\n ],\n beforeError: [...(kyOptions.hooks?.beforeError ?? [])],\n afterResponse: [...(kyOptions.hooks?.afterResponse ?? [])],\n },\n });\n }\n\n /**\n * Sends a GET request to the given path.\n *\n * @param path - API path relative to the store's versioned base URL (e.g. `catalog/products`).\n * @param options - Ky options are forwarded to the underlying request.\n * @param options.version - API version segment inserted into the URL. Defaults to `'v3'`.\n * @param options.query - Query parameters to append to the URL.\n * @param options.querySchema - StandardSchemaV1 schema to validate `query` before the request\n * is sent. Requires `query` to be provided.\n * @param options.responseSchema - StandardSchemaV1 schema to validate the parsed response body.\n *\n * @returns Parsed and optionally validated response body.\n *\n * @throws {@link BCApiError} on HTTP error responses.\n * @throws {@link BCTimeoutError} if the request times out.\n * @throws {@link BCResponseParseError} if the response body cannot be parsed.\n * @throws {@link BCUrlTooLongError} if the constructed URL exceeds 2048 characters.\n * @throws {@link BCRateLimitNoHeadersError} if a 429 is received without rate-limit headers.\n * @throws {@link BCRateLimitDelayTooLongError} if the rate-limit reset window exceeds\n * `config.retry.maxRetryAfter`.\n * @throws {@link BCQueryValidationError} if `querySchema` validation fails.\n * @throws {@link BCResponseValidationError} if `responseSchema` validation fails.\n * @throws {@link BCClientError} on any other ky or unknown error.\n */\n async get<TRes = unknown, TQuery extends Query = Query>(\n path: string,\n options?: GetOptions<TRes, TQuery>,\n ): Promise<TRes> {\n return this.request<never, TRes, TQuery>(path, {\n ...options,\n method: 'GET',\n } as RequestOptions<never, TRes, TQuery>);\n }\n\n /**\n * Sends a POST request to the given path.\n *\n * @param path - API path relative to the store's versioned base URL.\n * @param options - Ky options are forwarded to the underlying request.\n * @param options.version - API version segment inserted into the URL. Defaults to `'v3'`.\n * @param options.body - Request body, serialized as JSON.\n * @param options.bodySchema - StandardSchemaV1 schema to validate `body` before the request\n * is sent. Requires `body` to be provided.\n * @param options.query - Query parameters to append to the URL.\n * @param optio