bc-api-client
Version:
A client for the BigCommerce management API and app authentication
732 lines (728 loc) • 24.8 kB
JavaScript
// src/net.ts
import ky, { HTTPError } from "ky";
var Methods = {
GET: "GET",
POST: "POST",
PUT: "PUT",
DELETE: "DELETE"
};
var BASE_URL = "https://api.bigcommerce.com/stores/";
var CONFIG = {
/** Base URL for BigCommerce API */
BASE_URL,
/** Default API version to use */
DEFAULT_VERSION: "v3",
/** Maximum delay in milliseconds for rate limit retries */
DEFAULT_MAX_DELAY: 6e4,
/** Maximum allowed URL length */
MAX_URL_LENGTH: 2048,
/** Default maximum number of retries for rate-limited requests */
DEFAULT_MAX_RETRIES: 5,
/** Rate limit header names */
HEADERS: {
/** Time window for rate limiting in milliseconds */
WINDOW: "x-rate-limit-time-window-ms",
/** Time to wait before retrying after rate limit in milliseconds */
RETRY_AFTER: "x-rate-limit-time-reset-ms",
/** Total request quota for the time window */
REQUEST_QUOTA: "x-rate-limit-requests-quota",
/** Number of requests remaining in the current window */
REQUESTS_LEFT: "x-rate-limit-requests-left"
}
};
var RequestError = class extends Error {
constructor(status, message, data, cause) {
super(message, { cause });
this.status = status;
this.message = message;
this.data = data;
this.cause = cause;
}
};
var request = async (options) => {
const { maxDelay = CONFIG.DEFAULT_MAX_DELAY, maxRetries = CONFIG.DEFAULT_MAX_RETRIES, logger } = options;
let retries = 0;
let lastError = null;
while (retries < maxRetries) {
try {
return await safeRequest(options);
} catch (error) {
const err = error;
lastError = err;
if (err.status === 429 && typeof err.data === "object" && err.data !== null && "headers" in err.data) {
const headers = err.data.headers;
const retryAfter = Number.parseInt(headers[CONFIG.HEADERS.RETRY_AFTER]);
logger?.debug(
{
retryAfter,
retries,
remaining: headers[CONFIG.HEADERS.REQUESTS_LEFT]
},
"Rate limit hit, retrying"
);
if (Number.isNaN(retryAfter)) {
throw new RequestError(
err.status,
`Failed to parse retry after: ${headers[CONFIG.HEADERS.RETRY_AFTER]}, ${err.message}`,
err.data,
err.cause
);
}
if (retryAfter > maxDelay) {
logger?.warn(
{
retryAfter,
maxDelay
},
"Rate limit delay exceeds maximum allowed delay"
);
throw new RequestError(
err.status,
`Rate limit exceeded: ${retryAfter}ms, ${err.message}`,
err.data,
err.cause
);
}
await new Promise((resolve) => setTimeout(resolve, retryAfter));
retries++;
continue;
}
throw err;
}
}
logger?.error(
{
retries,
error: lastError
},
"Request failed after maximum retries"
);
throw lastError ?? new RequestError(500, "Failed to make request", "Too many retries after rate limit");
};
var safeRequest = async (options) => {
const { logger } = options;
let res;
try {
res = await call(options);
} catch (error) {
if (error instanceof RequestError) {
throw error;
}
if (!(error instanceof HTTPError)) {
logger?.error(
{
error: error instanceof Error ? {
name: error.name,
message: error.message
} : error
},
"Unexpected error during request"
);
throw error;
}
let data;
let errorMessage = error.message;
try {
data = await error.response.text();
try {
data = JSON.parse(data);
if (typeof data === "object" && data !== null && "message" in data) {
errorMessage = data.message;
}
} catch {
}
} catch {
data = "Failed to read error response";
}
logger?.error(
{
status: error?.response?.status,
errorMessage,
data,
endpoint: options.endpoint,
query: options.query,
body: options.body,
headers: Object.fromEntries(error?.response?.headers?.entries() ?? [])
},
"HTTP error during request"
);
throw new RequestError(
error?.response?.status ?? 500,
errorMessage,
{
data,
endpoint: options.endpoint,
query: options.query,
body: options.body,
headers: Object.fromEntries(error?.response?.headers?.entries() ?? [])
},
error
);
}
const text = await res.text();
if (res.status === 204) {
return void 0;
}
try {
return JSON.parse(text);
} catch (error) {
logger?.error(
{
status: res.status,
error: error instanceof Error ? {
name: error.name,
message: error.message
} : error
},
"Failed to parse response"
);
throw new RequestError(res.status, `Failed to parse response: ${text}`, text, error);
}
};
var call = async (options) => {
const {
storeHash,
accessToken,
endpoint,
method = "GET",
body,
version = CONFIG.DEFAULT_VERSION,
query,
logger
} = options;
const url = `${CONFIG.BASE_URL}${storeHash}/${version}/${endpoint.replace(/^\//, "")}`;
const searchParams = query ? new URLSearchParams(query).toString() : "";
const fullUrl = searchParams ? `${url}?${searchParams}` : url;
if (fullUrl.length > CONFIG.MAX_URL_LENGTH) {
logger?.error(
{
urlLength: fullUrl.length,
maxLength: CONFIG.MAX_URL_LENGTH
},
"URL length exceeds maximum allowed length"
);
throw new RequestError(
400,
"URL too long",
`URL length ${fullUrl.length} exceeds maximum allowed length of ${CONFIG.MAX_URL_LENGTH}`
);
}
const request2 = {
method,
headers: {
"Content-Type": "application/json",
Accept: "application/json",
"X-Auth-Token": accessToken
},
json: body
};
const response = await ky(fullUrl, request2);
return response;
};
// src/util.ts
var 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 separatorLength = currentChunk.length > 0 ? separatorSize : 0;
const totalItemLength = itemLength + separatorLength;
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 += totalItemLength;
}
if (currentChunk.length > 0) {
chunks.push(currentChunk);
}
return chunks;
};
// src/client.ts
var MAX_PAGE_SIZE = 250;
var DEFAULT_CONCURRENCY = 10;
function chunkArray(array, size) {
return Array.from({ length: Math.ceil(array.length / size) }, (_, i) => array.slice(i * size, i * size + size));
}
function rangeArray(start, end) {
return Array.from({ length: end - start + 1 }, (_, i) => start + i);
}
var BigCommerceClient = class {
/**
* Creates a new BigCommerce client instance
* @param config - Configuration options for the client
* @param config.storeHash - The store hash to use for the client
* @param config.accessToken - The API access token to use for the client
* @param config.maxRetries - The maximum number of retries for rate limit errors (default: 5)
* @param config.maxDelay - Maximum time to wait to retry in case of rate limit errors in milliseconds (default: 60000 - 1 minute). If `X-Rate-Limit-Time-Reset-Ms` header is higher than `maxDelay`, the request will fail immediately.
* @param config.concurrency - The default concurrency for concurrent methods (default: 10)
* @param config.skipErrors - Whether to skip errors during concurrent requests (default: false)
* @param config.logger - Optional logger instance for debugging and error tracking
*/
constructor(config) {
this.config = config;
}
/**
* Makes a GET request to the BigCommerce API
* @param endpoint - The API endpoint to request
* @param options.query - Query parameters to include in the request
* @param options.version - API version to use (v2 or v3) (default: v3)
* @returns Promise resolving to the response data of type `R`
*/
async get(endpoint, options) {
return request({
endpoint,
method: "GET",
...options,
...this.config
});
}
/**
* Makes a POST request to the BigCommerce API
* @param endpoint - The API endpoint to request
* @param options.query - Query parameters to include in the request
* @param options.version - API version to use (v2 or v3) (default: v3)
* @param options.body - Request body data of type `T`
* @returns Promise resolving to the response data of type `R`
*/
async post(endpoint, options) {
return request({
endpoint,
method: "POST",
...options,
...this.config
});
}
/**
* Makes a PUT request to the BigCommerce API
* @param endpoint - The API endpoint to request
* @param options.query - Query parameters to include in the request
* @param options.version - API version to use (v2 or v3) (default: v3)
* @param options.body - Request body data of type `T`
* @returns Promise resolving to the response data of type `R`
*/
async put(endpoint, options) {
return request({
endpoint,
method: "PUT",
...options,
...this.config
});
}
/**
* Makes a DELETE request to the BigCommerce API
* @param endpoint - The API endpoint to delete
* @param options.version - API version to use (v2 or v3) (default: v3)
* @returns Promise resolving to void
*/
async delete(endpoint, options) {
await request({
endpoint,
method: "DELETE",
...options,
...this.config
});
}
/**
* Executes multiple requests concurrently with controlled concurrency
* @param requests - Array of request options to execute
* @param options.concurrency - Maximum number of concurrent requests, overrides the client's concurrency setting (default: 10)
* @param options.skipErrors - Whether to skip errors and continue processing (the errors will be logged if logger is provided), overrides the client's skipErrors setting (default: false)
* @returns Promise resolving to array of response data
*/
async concurrent(requests, options) {
const skipErrors = options?.skipErrors ?? this.config.skipErrors ?? false;
const results = await this.concurrentSettled(requests, options);
const successfulResults = [];
for (const result of results) {
if (result.status === "fulfilled") {
successfulResults.push(result.value);
} else {
if (!skipErrors) {
throw result.reason;
} else {
this.config.logger?.warn(
{
error: result.reason
},
"Error in concurrent request"
);
}
}
}
return successfulResults;
}
/**
* Lowest level concurrent request method.
* This method executes requests in chunks and returns bare PromiseSettledResult objects.
* Use this method if you need to handle errors in a custom way.
* @param requests - Array of request options to execute
* @param options.concurrency - Maximum number of concurrent requests, overrides the client's concurrency setting (default: 10)
* @returns Promise resolving to array of PromiseSettledResult containing both successful and failed requests
*/
async concurrentSettled(requests, options) {
const chunkSize = options?.concurrency ?? this.config.concurrency ?? DEFAULT_CONCURRENCY;
const chunks = chunkArray(requests, chunkSize);
this.config.logger?.debug(
{
totalRequests: requests.length,
chunkSize,
chunks: chunks.length
},
"Starting concurrent requests with detailed results"
);
const allResults = [];
for (const [index, chunk] of chunks.entries()) {
const responses = await Promise.allSettled(
chunk.map(
(opt) => request({
...opt,
...this.config
})
)
);
this.config.logger?.debug(
{
chunkIndex: index,
chunkSize: chunk.length,
totalRequests: requests.length,
totalChunks: chunks.length,
responses: responses.map((response) => response.status)
},
"Completed chunk"
);
allResults.push(...responses);
}
return allResults;
}
/**
* Collects all pages of data from a paginated v3 API endpoint.
* This method pulls the first page and uses pagination meta to collect the remaining pages concurrently.
* @param endpoint - The API endpoint to request
* @param options.query - Query parameters to include in the request
* @param options.concurrency - Maximum number of concurrent requests, overrides the client's concurrency setting (default: 10)
* @param options.skipErrors - Whether to skip errors and continue processing (the errors will be logged if logger is provided), overrides the client's skipErrors setting (default: false)
* @returns Promise resolving to array of all items across all pages
*/
async collect(endpoint, options) {
options = options ?? {};
if (options.query) {
if (!options.query.limit) {
options.query.limit = MAX_PAGE_SIZE.toString();
}
} else {
options.query = { limit: MAX_PAGE_SIZE.toString() };
}
const first = await this.get(endpoint, options);
if (!Array.isArray(first.data) || !first?.meta?.pagination?.total_pages) {
return first.data;
}
const results = [...first.data];
const pages = first.meta.pagination.total_pages;
if (pages > 1) {
this.config.logger?.debug(
{
totalPages: pages,
itemsPerPage: first.data.length
},
"Collecting remaining pages"
);
const pageRequests = rangeArray(2, pages).map((page) => ({
endpoint,
method: "GET",
query: {
...options.query,
page: page.toString()
}
}));
const remainingPages = await this.concurrent(pageRequests, options);
remainingPages.forEach((page) => {
if (Array.isArray(page.data)) {
results.push(...page.data);
}
});
}
return results;
}
/**
* Collects all pages of data from a paginated v2 API endpoint.
* This method simply pulls all pages concurrently until a 204 is returned in a batch.
* @param endpoint - The API endpoint to request
* @param options.query - Query parameters to include in the request
* @param options.concurrency - Maximum number of concurrent requests, overrides the client's concurrency setting (default: 10)
* @param options.skipErrors - Whether to skip errors and continue processing (the errors will be logged if logger is provided), overrides the client's skipErrors setting (default: false)
* @returns Promise resolving to array of all items across all pages
*/
async collectV2(endpoint, options) {
options = options ?? {};
if (options.query) {
if (!options.query.limit) {
options.query.limit = MAX_PAGE_SIZE.toString();
}
} else {
options.query = { limit: MAX_PAGE_SIZE.toString() };
}
let done = false;
const results = [];
let page = 1;
const concurrency = options.concurrency ?? this.config.concurrency ?? DEFAULT_CONCURRENCY;
while (!done) {
const pages = rangeArray(page, page + concurrency);
page += concurrency;
const requests = pages.map((page2) => ({
...options,
endpoint,
version: "v2",
query: { ...options.query, page: page2.toString() }
}));
const responses = await Promise.allSettled(requests.map((request2) => this.get(endpoint, request2)));
responses.forEach((response) => {
if (response.status === "fulfilled") {
if (response.value) {
results.push(...response.value);
} else {
done = true;
}
} else {
if (response.reason instanceof RequestError && response.reason.status === 404) {
done = true;
} else {
if (!(options.skipErrors ?? this.config.skipErrors ?? false)) {
throw response.reason;
} else {
this.config.logger?.warn(
{
error: response.reason instanceof Error ? {
name: response.reason.name,
message: response.reason.message
} : response.reason
},
"Error in collectV2"
);
}
}
}
});
}
return results;
}
/**
* Queries multiple values against a single field using the v3 API.
* If the url + query params are too long, the query will be chunked. Otherwise, this method acts like `collect`.
* This method does not check for uniqueness of the `values` array.
*
* @param endpoint - The API endpoint to request
* @param options.key - The field name to query against e.g. `sku:in`
* @param options.values - Array of values to query for e.g. `['123', '456', ...]`
* @param options.query - Additional query parameters
* @param options.concurrency - Maximum number of concurrent requests, overrides the client's concurrency setting (default: 10)
* @param options.skipErrors - Whether to skip errors and continue processing (the errors will be logged if logger is provided), overrides the client's skipErrors setting (default: false)
* @returns Promise resolving to array of matching items
*/
async query(endpoint, options) {
if (options.query) {
if (!options.query.limit) {
options.query.limit = MAX_PAGE_SIZE.toString();
}
} else {
options.query = { limit: MAX_PAGE_SIZE.toString() };
}
const keySize = encodeURIComponent(options.key).length;
const fullUrl = `${BASE_URL}${this.config.storeHash}/v3/${endpoint}?${new URLSearchParams(options.query).toString()}`;
const offset = fullUrl.length + keySize + 1;
const chunkLength = Number.parseInt(options.query?.limit) || MAX_PAGE_SIZE;
const separatorSize = encodeURIComponent(",").length;
const queryStr = options.values.map((value) => `${value}`);
const chunks = chunkStrLength(queryStr, {
separatorSize,
offset,
chunkLength
});
this.config.logger?.debug(
{
offset,
totalValues: options.values.length,
chunks: chunks.length,
valuesPerChunk: chunks[0]?.length,
separatorSize
},
"Querying with chunked values"
);
const requests = chunks.map((chunk) => ({
...options,
endpoint,
query: { ...options.query, [options.key]: chunk.join(",") }
}));
const responses = await this.concurrent(requests, options);
return responses.flatMap((response) => response.data);
}
};
// src/auth.ts
import ky2, { HTTPError as HTTPError2 } from "ky";
import * as jose from "jose";
var GRANT_TYPE = "authorization_code";
var TOKEN_ENDPOINT = "https://login.bigcommerce.com/oauth2/token";
var ISSUER = "bc";
var BigCommerceAuth = class {
/**
* 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 {Error} If the redirect URI is invalid
*/
constructor(config) {
this.config = config;
try {
new URL(this.config.redirectUri);
} catch (error) {
throw new Error("Invalid redirect URI", { cause: error });
}
}
/**
* Requests an access token from BigCommerce
* @param data - Either a query string, URLSearchParams, or AuthQuery object containing auth callback data
* @returns Promise resolving to the token response
*/
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.config.logger?.debug(
{
clientId: this.config.clientId,
context: query.context,
scopes: query.scope
},
"Requesting OAuth token"
);
let res;
try {
res = await ky2(TOKEN_ENDPOINT, {
method: "POST",
json: tokenRequest
});
} catch (error) {
if (error instanceof HTTPError2) {
const text = await error.response.text();
this.config.logger?.error({
err: {
name: error.name,
message: error.message,
text
}
});
throw new Error(`Failed to request token. BC returned: ${text}`, { cause: error });
}
this.config.logger?.error({
err: error instanceof Error ? {
name: error.name,
message: error.message
} : error
});
throw new Error(`Failed to request token`, { cause: 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 {Error} 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.config.logger?.debug(
{
userId: payload.user?.id,
storeHash: payload.sub.split("/")[1]
},
"JWT verified successfully"
);
return payload;
} catch (error) {
this.config.logger?.error({
error: error instanceof Error ? {
name: error.name,
message: error.message
} : error
});
throw new Error("Invalid JWT payload", { cause: error });
}
}
/**
* Parses and validates a query string from BigCommerce auth callback
* @param queryString - The query string to parse
* @returns The parsed auth query parameters
* @throws {Error} If required parameters are missing or scopes are invalid
*/
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 Error("No code found in query string");
}
if (!scope) {
throw new Error("No scope found in query string");
} else if (this.config.scopes?.length) {
this.validateScopes(scope);
}
if (!context) {
throw new Error("No context found in query string");
}
return {
code,
scope,
context
};
}
/**
* Validates that the granted scopes match the expected scopes
* @param scopes - Space-separated list of granted scopes
* @throws {Error} If the scopes don't match the expected scopes
*/
validateScopes(scopes) {
if (!this.config.scopes) {
return;
}
const grantedScopes = scopes.split(" ");
const requiredScopes = this.config.scopes;
const missingScopes = requiredScopes.filter((scope) => !grantedScopes.includes(scope));
if (missingScopes.length) {
throw new Error(`Scope mismatch: ${scopes}; expected: ${this.config.scopes.join(" ")}`);
}
}
};
export {
BigCommerceAuth,
BigCommerceClient,
Methods
};
//# sourceMappingURL=index.js.map