UNPKG

bc-api-client

Version:

A client for the BigCommerce management API and app authentication

732 lines (728 loc) 24.8 kB
// 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