@bitrix24/b24jssdk
Version:
Bitrix24 REST API JavaScript SDK
642 lines (636 loc) • 22.3 kB
JavaScript
/**
* @package @bitrix24/b24jssdk
* @version 2.0.0
* @copyright (c) 2026 Bitrix24
* @license MIT
* @see https://github.com/bitrix24/b24jssdk
* @see https://bitrix24.github.io/b24jssdk/
*/
'use strict';
const axios = require('axios');
const requestIdGenerator = require('../request-id-generator.cjs');
const paramsFactory = require('./limiters/params-factory.cjs');
const manager = require('./limiters/manager.cjs');
const ajaxError = require('./ajax-error.cjs');
const ajaxResult = require('./ajax-result.cjs');
const redact = require('./redact.cjs');
const type = require('../../tools/type.cjs');
const environment = require('../../tools/environment.cjs');
const b24 = require('../../types/b24.cjs');
const loggerFactory = require('../../logger/logger-factory.cjs');
function _interopDefault (e) { return e && e.__esModule ? e.default : e; }
const axios__default = /*#__PURE__*/_interopDefault(axios);
var __defProp = Object.defineProperty;
var __name = (target, value) => __defProp(target, "name", { value, configurable: true });
const LOG_MAX_LENGTH = 300;
const LOG_SLICE_LENGTH = 100;
function truncateForLog(value) {
return value.length > LOG_MAX_LENGTH ? value.slice(0, LOG_SLICE_LENGTH) + "..." : value;
}
__name(truncateForLog, "truncateForLog");
class AbstractHttp {
static {
__name(this, "AbstractHttp");
}
_clientAxios;
_authActions;
_requestIdGenerator;
_restrictionManager;
/**
* In-flight token refresh, shared so concurrent 401s coalesce into a single
* `refreshAuth()` round-trip — avoids OAuth refresh-token reuse errors when a
* burst of requests expires together. (#182)
*/
_pendingRefresh = null;
_logger;
_isClientSideWarning = false;
_clientSideWarningMessage = "";
_version;
_metrics = {
totalRequests: 0,
successfulRequests: 0,
failedRequests: 0,
totalDuration: 0,
byMethod: /* @__PURE__ */ new Map(),
lastErrors: []
};
constructor(authActions, options, restrictionParams) {
this._version = b24.ApiVersion.v2;
this._logger = loggerFactory.LoggerFactory.createNullLogger();
const defaultHeaders = {};
if (this.isServerSide()) {
defaultHeaders["User-Agent"] = "b24-js-sdk/2.0.0";
}
this._authActions = authActions;
this._requestIdGenerator = new requestIdGenerator.RequestIdGenerator();
this._clientAxios = axios__default.create({
timeout: 3e4,
timeoutErrorMessage: "Request timeout exceeded",
...options ?? {},
// headers last so the merged default + caller headers aren't wiped by an
// `options.headers` (or the previous `headers: undefined`) spread (#144).
headers: {
...defaultHeaders,
...options?.headers ?? {}
}
});
const params = {
...paramsFactory.ParamsFactory.getDefault(),
...restrictionParams
};
this._restrictionManager = new manager.RestrictionManager(params);
}
get apiVersion() {
return this._version;
}
get ajaxClient() {
return this._clientAxios;
}
// region Logger ////
setLogger(logger) {
this._logger = logger;
this._restrictionManager.setLogger(this._logger);
}
getLogger() {
return this._logger;
}
// endregion ////
// region RestrictionManager ////
async setRestrictionManagerParams(params) {
await this._restrictionManager.setConfig(params);
}
getRestrictionManagerParams() {
return this._restrictionManager.getParams();
}
/**
* @inheritDoc
*/
getStats() {
return {
...this._restrictionManager.getStats(),
totalRequests: this._metrics.totalRequests,
successfulRequests: this._metrics.successfulRequests,
failedRequests: this._metrics.failedRequests,
totalDuration: this._metrics.totalDuration,
byMethod: this._metrics.byMethod,
lastErrors: this._metrics.lastErrors
};
}
/**
* @inheritDoc
*/
async reset() {
this._metrics.totalRequests = 0;
this._metrics.successfulRequests = 0;
this._metrics.failedRequests = 0;
this._metrics.totalDuration = 0;
this._metrics.byMethod.clear();
this._metrics.lastErrors = [];
return this._restrictionManager.reset();
}
// endregion ////
// region Metrics ////
_updateMetrics(method, isSuccess, duration, error) {
this._metrics.totalRequests++;
if (isSuccess) {
this._metrics.successfulRequests++;
} else {
this._metrics.failedRequests++;
if (error instanceof ajaxError.AjaxError) {
this._metrics.lastErrors.push({
method,
error: error.message,
timestamp: Date.now()
});
if (this._metrics.lastErrors.length > 100) {
this._metrics.lastErrors = this._metrics.lastErrors.slice(-100);
}
}
}
if (!this._metrics.byMethod.has(method)) {
this._metrics.byMethod.set(method, { count: 0, totalDuration: 0 });
}
const methodMetrics = this._metrics.byMethod.get(method);
methodMetrics.count++;
methodMetrics.totalDuration += duration;
}
// endregion ////
_validateParams(requestId, method, params) {
try {
JSON.stringify(params);
} catch (error) {
throw new ajaxError.AjaxError({
code: "JSSDK_INVALID_PARAMS",
description: "Parameters contain circular references",
status: 400,
requestInfo: { method, params, requestId },
originalError: error
});
}
}
/**
* Calling the RestApi function
* @param method - REST API method name
* @param params - Parameters for the method.
* @param requestId - Request id
* @returns Promise with AjaxResult
*/
async call(method, params, requestId) {
requestId = requestId ?? this._requestIdGenerator.getRequestId();
const maxRetries = this._restrictionManager.getParams().maxRetries;
this._validateParams(requestId, method, params);
this._logRequest(requestId, method, params);
let lastError = null;
const startTime = Date.now();
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
this._logAttempt(requestId, method, attempt + 1, maxRetries);
await this._restrictionManager.applyOperatingLimits(requestId, method, params);
const result = await this._executeSingleCall(requestId, method, params);
const duration = Date.now() - startTime;
this._restrictionManager.resetErrors(method);
this._updateMetrics(method, true, duration);
this._logSuccessfulRequest(requestId, method, duration);
return result;
} catch (error) {
lastError = this._convertToAjaxError(requestId, error, method, params);
const duration = Date.now() - startTime;
this._restrictionManager.incrementError(method);
this._updateMetrics(method, false, duration, lastError);
this._logFailedRequest(requestId, method, attempt + 1, maxRetries, lastError);
if (attempt + 1 < maxRetries) {
const waitTime = await this._restrictionManager.handleError(requestId, method, params, lastError, attempt);
if (waitTime > 0) {
this._restrictionManager.incrementStats("limitHits");
this._logAttemptRetryWaiteDelay(requestId, method, waitTime, attempt + 1, maxRetries);
await this._restrictionManager.waiteDelay(waitTime);
this._restrictionManager.incrementStats("retries");
continue;
}
}
if (attempt + 1 === maxRetries) {
this._logAllAttemptsExhausted(requestId, method, attempt + 1, maxRetries);
}
if (this._restrictionManager.exceptionCodeForSoft.includes(lastError.code)) {
return this._createAjaxResultWithErrorFromResponse(lastError, requestId, method, params);
}
throw lastError;
}
}
throw new ajaxError.AjaxError({
code: "JSSDK_CALL_ALL_ATTEMPTS_EXHAUSTED",
description: "All attempts exhausted",
status: lastError?.status || 500,
requestInfo: { method, params, requestId },
originalError: lastError?.originalError || null
});
}
_convertToAjaxError(requestId, error, method, params) {
if (error instanceof ajaxError.AjaxError) {
return error;
}
if (error instanceof axios.AxiosError) {
return this._convertAxiosErrorToAjaxError(requestId, error, method, params);
}
return this._convertUnknownErrorToAjaxError(requestId, error, method, params);
}
_convertAxiosErrorToAjaxError(requestId, axiosError, method, params) {
let errorCode = `${axiosError.code || "JSSDK_AXIOS_ERROR"}`;
let errorDescription = axiosError.message;
const status = axiosError.response?.status || 0;
if (errorCode === "ERR_NETWORK") {
return new ajaxError.AjaxError({
code: "NETWORK_ERROR",
description: "Network connection failed",
status: 0,
requestInfo: { method, params, requestId },
originalError: axiosError
});
}
if (errorCode === "ECONNABORTED" || axiosError.message.includes("timeout")) {
return new ajaxError.AjaxError({
code: "REQUEST_TIMEOUT",
description: "Request timeout exceeded",
status: 408,
requestInfo: { method, params, requestId },
originalError: axiosError
});
}
if (axiosError.response?.data && typeof axiosError.response.data === "object") {
const responseData = axiosError.response.data;
if (responseData.error && typeof responseData.error === "object" && "code" in responseData.error) {
errorCode = responseData.error.code;
errorDescription = responseData.error.message.trimEnd();
if (responseData.error.validation) {
if (errorDescription.length > 0) {
if (!errorDescription.endsWith(".")) {
errorDescription += `.`;
}
errorDescription += ` `;
}
responseData.error.validation.forEach((row) => {
errorDescription += `${row?.message || JSON.stringify(row)}`;
});
}
} else if (responseData.error && typeof responseData.error === "string") {
errorCode = responseData.error !== "0" ? responseData.error : errorCode;
errorDescription = responseData?.error_description ?? errorDescription;
}
}
return new ajaxError.AjaxError({
code: errorCode,
description: errorDescription,
status,
requestInfo: { method, params, requestId },
originalError: axiosError
});
}
_convertUnknownErrorToAjaxError(requestId, error, method, params) {
return new ajaxError.AjaxError({
code: "JSSDK_UNKNOWN_ERROR",
description: error instanceof Error ? error.message : String(error),
status: 0,
requestInfo: { method, params, requestId },
originalError: error
});
}
// region Execute Single Call ////
/**
* Performs a single call with
* - 401 error handling
* - rate limit check
* - updating operating statistics
*/
async _executeSingleCall(requestId, method, params) {
this._checkClientSideWarning(requestId);
const authData = await this._ensureAuth(requestId);
const response = await this._makeRequestWithAuthRetry(requestId, method, params, authData);
return this._createAjaxResultFromResponse(response, requestId, method, params);
}
// Get/update authorization
async _ensureAuth(requestId) {
let authData = this._authActions.getAuthData();
if (authData === false) {
this._logRefreshingAuthToken(requestId);
authData = await this._refreshAuth();
}
return authData;
}
/**
* Refresh the auth token, coalescing concurrent callers onto a single
* in-flight `refreshAuth()` so a burst of 401s triggers exactly one refresh
* round-trip. The slot clears once the refresh settles. (#182)
*/
_refreshAuth() {
if (this._pendingRefresh) {
return this._pendingRefresh;
}
const refresh = this._authActions.refreshAuth();
this._pendingRefresh = refresh;
refresh.finally(() => {
this._pendingRefresh = null;
}).catch(() => {
});
return refresh;
}
// Execute the request with 401 error handling
async _makeRequestWithAuthRetry(requestId, method, params, authData) {
try {
await this._restrictionManager.checkRateLimit(requestId, method);
return await this._makeAxiosRequest(requestId, method, params, authData);
} catch (error) {
if (error instanceof axios.AxiosError) {
this.getLogger().info(
`post/catchError`,
{
requestId,
status: error.status,
// Redact in case a future portal response embeds credentials in
// the error body (today it doesn't, but the channel is open) (#39),
// and cap the length so a large error body can't flood the sink (#236).
responseData: truncateForLog(JSON.stringify(redact.redactSensitiveParams(error?.response?.data), null, 0))
}
);
}
const ajaxError = this._convertToAjaxError(requestId, error, method, params);
if (this._isAuthError(ajaxError)) {
this._logAuthErrorDetected(requestId);
this._logRefreshingAuthToken(requestId);
const refreshedAuthData = await this._refreshAuth();
await this._restrictionManager.checkRateLimit(requestId, method);
return await this._makeAxiosRequest(requestId, method, params, refreshedAuthData);
}
throw ajaxError;
}
}
async _makeAxiosRequest(requestId, method, params, authData) {
const methodFormatted = this._prepareMethod(requestId, method, this.getBaseUrl());
const paramsFormatted = this._prepareParams(authData, params);
const paramsFormattedForLog = JSON.stringify(redact.redactSensitiveParams(paramsFormatted), null, 0);
this.getLogger().info(
`post/send`,
{
requestId,
method,
params: truncateForLog(paramsFormattedForLog)
}
);
const response = await this._clientAxios.post(methodFormatted, paramsFormatted);
const resultFormattedForLog = JSON.stringify(response.data.result, null, 0);
this.getLogger().info(
`post/response`,
{
requestId,
// responseFull: JSON.stringify(response.data, null, 2),
result: truncateForLog(resultFormattedForLog),
time: JSON.stringify(response.data.time, null, 0)
}
);
return {
status: response.status,
payload: response.data
};
}
_isAuthError(error) {
if (!(error instanceof ajaxError.AjaxError)) {
return false;
}
return error.status === 401 && ["expired_token", "invalid_token"].includes(error.code);
}
async _createAjaxResultFromResponse(response, requestId, method, params) {
const result = new ajaxResult.AjaxResult({
answer: response.payload,
query: { method, params, requestId },
status: response.status
});
if (result.isSuccess) {
const time = result.getData()?.time;
await this._restrictionManager.updateStats(requestId, method, time);
}
return result;
}
/**
* This works in conjunction with the AbstractHttp._convertAxiosErrorToAjaxError function
*/
_createAjaxResultWithErrorFromResponse(ajaxError, requestId, method, params) {
return new ajaxResult.AjaxResult({
answer: {
error: {
code: ajaxError.code,
message: ajaxError.message
}
},
query: { method, params, requestId },
status: ajaxError.status
});
}
// endregion ////
// endregion ////
// region Prepare ////
/**
* Builds the request URL: the method path plus the SDK telemetry query params
* (`bx24_request_id` / `bx24_sdk_ver` / `bx24_sdk_type` — request tracing and
* SDK identification, not auth material).
*
* Carve-out for the legacy positional `task.*` methods (`task.commentitem.*`,
* `task.checklistitem.*`, `task.elapseditem.*`, …): these read the request
* **query string positionally**, so appending the telemetry params shifts
* `Param #0` and the server rejects the call —
* `WRONG_ARGUMENTS: Param #0 (taskId) ... expected integer, but given
* something else`. Verified live against a portal: the same
* `task.commentitem.getlist` / `task.checklistitem.getlist` call succeeds
* without the telemetry params and fails with them; modern `tasks.task.*`
* (named params) is unaffected. So telemetry is omitted for any method whose
* name contains `task.`.
*
* Shared by v2 and v3 (rather than per-transport): once the v3 method
* allowlist was dropped (#259) a positional `task.*` method can be routed via
* `actions.v3.*` too, so v3 needs the same suppression — keeping the rule in
* one place stops the two transports drifting apart again (#207).
*
* `includes('task.')` is a deliberate over-approximation: it also drops the
* (optional, telemetry-safe) params for modern `tasks.task.*` / `bizproc.task.*`.
* Narrowing to an anchored `^task\.` is possible but unverified across the
* whole task surface, so the broad, safe match is kept. Bitrix24 method names
* are lowercase by convention, so the case-sensitive match is sufficient.
*
* @see https://apidocs.bitrix24.com/settings/how-to-call-rest-api/data-encoding.html#order-of-parameters
*/
_prepareMethod(requestId, method, baseUrl) {
const methodUrl = `/${encodeURIComponent(method)}`;
if (method.includes("task.")) {
return `${baseUrl}${methodUrl}`;
}
const queryParams = new URLSearchParams({
[this._requestIdGenerator.getQueryStringParameterName()]: requestId,
[this._requestIdGenerator.getQueryStringSdkParameterName()]: "2.0.0",
[this._requestIdGenerator.getQueryStringSdkTypeParameterName()]: "b24-js-sdk"
});
return `${baseUrl}${methodUrl}?${queryParams.toString()}`;
}
/**
* Processes function parameters and adds authorization
*/
_prepareParams(authData, params) {
const result = { ...params };
if (authData.refresh_token !== "hook") {
result.auth = authData.access_token;
}
if (result?.data && "start" in result.data) {
const { start, ...dataWithoutStart } = result.data;
result.data = dataWithoutStart;
}
return result;
}
/**
* @inheritDoc
*/
setClientSideWarning(value, message) {
this._isClientSideWarning = value;
this._clientSideWarningMessage = message;
}
// endregion ////
// region Tools ////
/**
* Tests whether the code is executed on the client side
* @return {boolean}
* @protected
*/
isServerSide() {
return environment.getEnvironment() !== environment.Environment.BROWSE;
}
/**
* Get the BX24 account address with the path based on the API version
*/
getBaseUrl() {
return this._authActions.getTargetOriginWithPath().get(this._version);
}
// endregion ////
// region Log ////
_sanitizeParams(params) {
return redact.redactSensitiveParams(params);
}
_logRequest(requestId, method, params) {
this.getLogger().debug(`http request starting`, {
requestId,
method,
params: this._sanitizeParams(params),
api: this.apiVersion,
timestamp: Date.now()
});
}
_logAttempt(requestId, method, attempt, maxRetries) {
this.getLogger().info(`http request attempt`, {
requestId,
method,
api: this.apiVersion,
attempt: {
current: attempt,
max: maxRetries
}
});
}
_logRefreshingAuthToken(requestId) {
this.getLogger().info(`http refreshing auth token`, {
requestId,
api: this.apiVersion
});
}
_logAuthErrorDetected(requestId) {
this.getLogger().info(`http auth error detected`, {
requestId,
api: this.apiVersion
});
}
_logSuccessfulRequest(requestId, method, duration) {
this.getLogger().debug(`http request successful`, {
requestId,
method,
api: this.apiVersion,
duration: {
ms: duration,
sec: Number.parseFloat((duration / 1e3).toFixed(2))
}
});
}
_logFailedRequest(requestId, method, attempt, maxRetries, error) {
this.getLogger().debug(`http request failed`, {
requestId,
method,
api: this.apiVersion,
attempt: {
current: attempt,
max: maxRetries
},
error: {
code: error.code,
message: error.message,
status: error.status
}
});
}
_logAttemptRetryWaiteDelay(requestId, method, wait, attempt, maxRetries) {
this.getLogger().debug(
`http wait ${(wait / 1e3).toFixed(2)} sec.`,
{
requestId,
method,
api: this.apiVersion,
wait,
attempt: {
current: attempt,
max: maxRetries
}
}
);
}
_logAllAttemptsExhausted(requestId, method, attempt, maxRetries) {
this.getLogger().warning(`http all retry attempts exhausted`, {
requestId,
method,
api: this.apiVersion,
attempt: {
current: attempt,
max: maxRetries
}
});
}
_logBatchStart(requestId, calls, options) {
const callCount = Array.isArray(calls) ? calls.length : Object.keys(calls).length;
this.getLogger().debug(`http batch request starting `, {
requestId,
callCount,
api: this.apiVersion,
isHaltOnError: options.isHaltOnError,
timestamp: Date.now()
});
}
_logBatchCompletion(requestId, total, errors) {
this.getLogger().debug(`http batch request completed`, {
requestId,
api: this.apiVersion,
totalCalls: total,
successful: total - errors,
failed: errors,
successRate: total > 0 ? ((total - errors) / total * 100).toFixed(1) + "%" : "??"
});
}
// Check client-side warnings
_checkClientSideWarning(requestId) {
if (this._isClientSideWarning && !this.isServerSide() && type.Type.isStringFilled(this._clientSideWarningMessage)) {
loggerFactory.LoggerFactory.forcedLog(
this.getLogger(),
"warning",
this._clientSideWarningMessage,
{
requestId,
code: "JSSDK_CLIENT_SIDE_WARNING"
}
);
}
}
// endregion ////
}
exports.AbstractHttp = AbstractHttp;
exports.truncateForLog = truncateForLog;
//# sourceMappingURL=abstract-http.cjs.map