@bitrix24/b24jssdk
Version:
Bitrix24 REST API JavaScript SDK
490 lines (486 loc) • 19.4 kB
JavaScript
/**
* @package @bitrix24/b24jssdk
* @version 3.0.0
* @copyright (c) 2026 Bitrix24
* @license MIT
* @see https://github.com/bitrix24/b24jssdk
* @see https://bitrix24.github.io/b24jssdk/
*/
;
const abstractAction = require('../abstract-action.cjs');
const b24 = require('../../../types/b24.cjs');
const result = require('../../result.cjs');
const sdkError = require('../../sdk-error.cjs');
const parseRow = require('../../interaction/batch/parse-row.cjs');
var __defProp = Object.defineProperty;
var __name = (target, value) => __defProp(target, "name", { value, configurable: true });
const DEFAULT_POLL_INTERVAL = 2e3;
const MIN_POLL_INTERVAL = 250;
const DEFAULT_TIMEOUT = 6e5;
const MAX_TIMER_DELAY = 2147483647;
class DeferredBatchV3 extends abstractAction.AbstractAction {
static {
__name(this, "DeferredBatchV3");
}
/**
* Runs a deferred batch end to end and resolves with its rows.
*
* Adds the job, polls it until it is `done` or `error` (calling `onStatus` on
* every change), downloads and decodes the result file, and deletes the job
* unless `deleteAfter: false`. Never throws for what the portal answered:
* check `isSuccess` on the returned `Result`. Throws only for malformed
* `calls`, before anything is sent.
*
* @template T - The type of one row (one command's result).
*
* @param {ActionDeferredBatchV3} options
* - `calls` - the commands, `[['method', params], …]` or `[{ method, params }, …]`.
* - `idempotencyKey?` - sent with `add`; use one per run, so that — per the portal's
* idempotency contract, not measured for deferred batches — a retry of an interrupted
* `make()` does not start a second job.
* - `requestId?` - sent as `bx24_request_id` on the `add` call.
* - `pollInterval?` - ms between status checks (default 2000).
* - `timeout?` - ms to wait for a final status (default 600000).
* - `signal?` - `AbortSignal` that stops waiting.
* - `onStatus?` - `(job) => void`, called on every status change.
* - `deleteAfter?` - delete the job after reading it (default `true`).
*
* @returns {Promise<Result<T[]>>} The rows in command order. On failure the
* `Result` carries the errors, each naming the job id:
* - `JSSDK_DEFERRED_BATCH_FAILED` when the job ended in `error` — the
* portal's `errorMessage` is logged and the job is deleted unless
* `deleteAfter: false`;
* - `JSSDK_DEFERRED_BATCH_TIMEOUT` / `JSSDK_DEFERRED_BATCH_ABORTED` when
* waiting stopped — the job keeps running (and cannot be deleted while
* it does), so collect it later with {@link DeferredBatchV3.waitFor};
* - a download error.
* `onStatus` also receives the job as soon as `add` returns it. An
* already-aborted `signal` adds nothing.
* @throws {SdkError} `JSSDK_DEFERRED_BATCH_EMPTY` for an empty `calls`,
* `JSSDK_INTERACTION_BATCH_ROW_FAIL` for a command that is neither a
* tuple nor a `{ method, params }` object.
*
* @example
* import type { BatchCommandsArrayUniversal } from '@bitrix24/b24jssdk'
*
* // 200 pages of 50 tasks: a list page cannot fail on a missing record the
* // way a `get` per id can, and one failing command fails the whole job.
* const calls: BatchCommandsArrayUniversal = Array.from({ length: 200 }, (_, i) =>
* ['tasks.task.list', { select: ['id', 'title'], order: { id: 'ASC' }, pagination: { page: i + 1, limit: 50 } }]
* )
*
* const response = await $b24.actions.v3.deferredBatch.make<{ items: Array<{ id: number, title: string }> }>({
* calls,
* onStatus: job => console.log(`deferred batch #${job.id}: ${job.status}`)
* })
*
* if (!response.isSuccess) {
* throw new Error(response.getErrorMessages().join('; '))
* }
* const titles = response.getData()!.flatMap(row => row.items.map(task => task.title))
* console.log(`${titles.length} tasks read`)
*/
async make(options) {
const result$1 = new result.Result();
this.#toCommands(options.calls);
if (options.signal?.aborted) {
return result$1.addError(abortedError(), "base-error");
}
const added = await this.add(options);
if (!added.isSuccess) {
return this.#carryErrors(added, result$1);
}
const job = added.getData();
const id = job.id;
this.#reportStatus(options.onStatus, job, id);
const finished = await this.#wait(id, options, job.status);
if (!finished.isSuccess) {
if (finished.getData()?.status === "error") {
this._logger.warning('deferredBatch: the job ended with status "error"', {
id,
errorMessage: finished.getData()?.errorMessage
}).catch(() => {
});
if (options.deleteAfter !== false) {
await this.#deleteQuietly(id);
}
}
return this.#carryErrors(finished, result$1);
}
const rows = await this.download(id);
if (!rows.isSuccess) {
return this.#carryErrors(rows, result$1);
}
if (options.deleteAfter !== false) {
await this.#deleteQuietly(id);
}
return result$1.setData(rows.getData());
}
/** A failed delete does not spoil the outcome already known: it is logged. */
async #deleteQuietly(id) {
const deleted = await this.delete(id);
if (!deleted.isSuccess) {
this._logger.warning("deferredBatch: the job could not be deleted", {
id,
errors: deleted.getErrorMessages()
}).catch(() => {
});
}
}
/**
* Adds a deferred batch job (`rest.deferredbatch.add`). The portal starts it
* in the background; read its progress with {@link DeferredBatchV3.get} or
* wait for it with {@link DeferredBatchV3.waitFor}.
*
* @param {ActionDeferredBatchAddV3} options - `calls`, and optionally `idempotencyKey` and `requestId`.
* @returns {Promise<Result<DeferredBatchJob>>} The new job, usually `pending`.
*
* @example
* const added = await $b24.actions.v3.deferredBatch.add({
* calls: [
* ['tasks.task.list', { select: ['id'], pagination: { page: 1, limit: 50 } }],
* ['tasks.task.list', { select: ['id'], pagination: { page: 2, limit: 50 } }]
* ],
* idempotencyKey: 'nightly-export-2026-09-26'
* })
* const jobId = added.getData()!.id // keep it, e.g. in your queue
*/
async add(options) {
const commands = this.#toCommands(options.calls);
return this.#call(
"rest.deferredbatch.add",
{ fields: { commands } },
(payload) => payload.item,
options.requestId,
options.idempotencyKey
);
}
/**
* Reads one job (`rest.deferredbatch.get`).
*
* @param {number} id - The job id from {@link DeferredBatchV3.add}.
* @returns {Promise<Result<DeferredBatchJob>>}
*
* @example
* declare const jobId: number
* const job = (await $b24.actions.v3.deferredBatch.get(jobId)).getData()!
* if (job.status === 'done') {
* // ready to download
* }
*/
async get(id) {
return this.#call("rest.deferredbatch.get", { id }, (payload) => payload.item);
}
/**
* Lists the caller's deferred batch jobs (`rest.deferredbatch.list`).
* Measured: `{ items }`, whose items carry no `commands`. Which jobs it
* includes is the portal's rule.
*
* @returns {Promise<Result<DeferredBatchJob[]>>}
*/
async list() {
return this.#call(
"rest.deferredbatch.list",
{},
(payload) => Array.isArray(payload) ? payload : Array.isArray(payload.items) ? payload.items : void 0
);
}
/**
* Polls a job until it is `done` or `error`.
*
* Resolves successfully only for `done`. For `error` the `Result` carries
* `JSSDK_DEFERRED_BATCH_FAILED` with the portal's `errorMessage`, and the job
* as data. A timeout or an aborted `signal` stops waiting — not the job.
*
* @param {number} id - The job id.
* @param {ActionDeferredBatchWaitV3} options - `pollInterval`, `timeout`, `signal`, `onStatus`.
* @returns {Promise<Result<DeferredBatchJob>>} The job in its final state.
*
* @example
* declare const jobId: number
* declare const progressBar: { setLabel(text: string): void }
* const controller = new AbortController()
* const finished = await $b24.actions.v3.deferredBatch.waitFor(jobId, {
* pollInterval: 5_000,
* signal: controller.signal,
* onStatus: job => progressBar.setLabel(job.status)
* })
*/
async waitFor(id, options = {}) {
return this.#wait(id, options, void 0);
}
async #wait(id, options, reportedStatus) {
const pollInterval = Math.min(MAX_TIMER_DELAY, Math.max(MIN_POLL_INTERVAL, finiteOr(options.pollInterval, DEFAULT_POLL_INTERVAL)));
const timeout = Math.max(0, typeof options.timeout === "number" && !Number.isNaN(options.timeout) ? options.timeout : DEFAULT_TIMEOUT);
const deadline = Date.now() + timeout;
const result$1 = new result.Result();
let lastStatus = reportedStatus;
for (; ; ) {
if (options.signal?.aborted) {
return result$1.addError(abortedError(id), "base-error");
}
const current = await this.get(id);
if (!current.isSuccess) {
return this.#carryErrors(current, result$1);
}
const job = current.getData();
if (job.status !== lastStatus) {
lastStatus = job.status;
this.#reportStatus(options.onStatus, job, id);
}
if (job.status === "done") {
return result$1.setData(job);
}
if (job.status === "error") {
result$1.setData(job);
return result$1.addError(new sdkError.SdkError({
code: "JSSDK_DEFERRED_BATCH_FAILED",
// Static text: the portal's own `errorMessage` stays on the job
// (`getData().errorMessage`), since an `SdkError` description is not
// redacted and that message is free text.
description: `deferredBatch: job #${id} ended with status "error"; see the job's errorMessage (waitFor / get).`,
status: 500
}), "base-error");
}
if (Date.now() + pollInterval > deadline) {
return result$1.addError(new sdkError.SdkError({
code: "JSSDK_DEFERRED_BATCH_TIMEOUT",
description: `deferredBatch: job #${id} reached no final status within ${timeout} ms; it keeps running on the portal.`,
status: 408
}), "base-error");
}
await this.#sleep(pollInterval, options.signal);
}
}
/**
* Gets the result file's download link (`rest.deferredbatch.downloadresult`).
*
* **The link is a credential**: on a webhook it contains the webhook secret,
* on OAuth (per the portal's code, not yet measured) an access token. Do not log it or send it anywhere you would not
* send those. Most callers want {@link DeferredBatchV3.download} instead.
*
* @param {number} id - The job id. The job must be `done`.
* @returns {Promise<Result<string>>} The absolute URL of the `.json.gz` file.
*/
async getDownloadUrl(id) {
return this.#call("rest.deferredbatch.downloadresult", { id }, (payload) => payload.downloadUrl);
}
/**
* Downloads a finished job's result file and decodes it into rows.
*
* @template T - The type of one row.
* @param {number} id - The job id. The job must be `done`.
* @returns {Promise<Result<T[]>>} The rows in command order.
*
* @example
* declare const jobId: number
* const rows = await $b24.actions.v3.deferredBatch.download<{ item: { id: number } }>(jobId)
* console.log(rows.getData()!.length)
*/
async download(id) {
const result$1 = new result.Result();
const link = await this.getDownloadUrl(id);
if (!link.isSuccess) {
return this.#carryErrors(link, result$1);
}
let bytes;
try {
const response = await this._b24.getHttpClient(b24.ApiVersion.v3).ajaxClient.get(link.getData(), {
responseType: "arraybuffer",
maxRedirects: 0
});
if (!response.status) {
return result$1.addError(new sdkError.SdkError({
code: "JSSDK_DEFERRED_BATCH_DOWNLOAD_FAILED",
description: "deferredBatch: the result file could not be downloaded (no status: a refused redirect or a dropped connection).",
status: 0
}), "base-error");
}
bytes = response.data;
} catch (error) {
const status = Number(error?.response?.status ?? 0);
return result$1.addError(new sdkError.SdkError({
code: "JSSDK_DEFERRED_BATCH_DOWNLOAD_FAILED",
description: `deferredBatch: the result file could not be downloaded (HTTP ${status || "no response"}).`,
status: status || 500
}), "base-error");
}
try {
return result$1.setData(await this.decode(bytes));
} catch (error) {
return result$1.addError(error instanceof Error ? error : new Error(String(error)), "base-error");
}
}
/**
* Decodes the bytes of a result file into rows: gunzips them (the file is
* `application/gzip`) and parses the JSON array inside. Bytes that are not
* gzip — a proxy that already inflated them — are parsed as they are.
*
* Uses the standard `DecompressionStream`, available in Node.js 18+ and every
* current browser.
*
* @param {ArrayBuffer | Uint8Array} bytes - The file contents.
* @returns {Promise<unknown[]>} The rows.
* @throws {SdkError} `JSSDK_DEFERRED_BATCH_DECODE_FAILED` when the content is
* not a JSON array, `JSSDK_DEFERRED_BATCH_GZIP_UNSUPPORTED` when the
* runtime has no `DecompressionStream`.
*
* @example
* declare const bytes: Uint8Array
* // The file was fetched some other way, e.g. by a separate worker. If you
* // fetch the link yourself, refuse redirects (`redirect: 'error'`): the link
* // is a credential.
* const rows = await $b24.actions.v3.deferredBatch.decode(bytes)
*/
async decode(bytes) {
const data = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
const isGzip = data.length >= 2 && data[0] === 31 && data[1] === 139;
const text = new TextDecoder().decode(isGzip ? await gunzip(data) : data);
let parsed;
try {
parsed = JSON.parse(text);
} catch {
parsed = void 0;
}
if (!Array.isArray(parsed)) {
throw new sdkError.SdkError({
code: "JSSDK_DEFERRED_BATCH_DECODE_FAILED",
description: "deferredBatch: the result file is not a JSON array.",
status: 500
});
}
return parsed;
}
/**
* Deletes a job and its result file (`rest.deferredbatch.delete`). The
* download link stops working. A job that is still `processing` cannot be
* deleted: the portal answers `BATCH_PROCESSING`.
*
* @param {number} id - The job id.
* @returns {Promise<Result<boolean>>}
*/
async delete(id) {
return this.#call("rest.deferredbatch.delete", { id }, () => true, void 0, void 0, false);
}
#toCommands(calls) {
if (!Array.isArray(calls) || calls.length === 0) {
throw new sdkError.SdkError({
code: "JSSDK_DEFERRED_BATCH_EMPTY",
description: "deferredBatch: `calls` must be a non-empty array of commands.",
status: 400
});
}
return calls.map((row) => {
const command = parseRow.ParseRow.getBatchCommand(row, { parallelDefaultValue: false });
return { method: command.method, query: command.query ?? {} };
});
}
/**
* One `rest.deferredbatch.*` call, as a `Result`. A portal refusal on v3 (a
* 403 for a plan without the feature) already comes back as a failed
* `AjaxResult`; anything the transport throws instead — a key it refuses
* before sending, a network error after its retries — becomes an error on
* the `Result` too, so `make()` and the steps never throw for either.
*/
async #call(method, params, pick, requestId, idempotencyKey, requireResult = true) {
const result$1 = new result.Result();
let response;
try {
response = await this._b24.getHttpClient(b24.ApiVersion.v3).call(
method,
params,
requestId,
void 0 === idempotencyKey ? void 0 : { idempotencyKey }
);
} catch (error) {
return result$1.addError(error instanceof Error ? error : new Error(String(error)), "base-error");
}
if (!response.isSuccess) {
return this.#carryErrors(response, result$1);
}
const data = response.getData()?.result;
const picked = data === void 0 || data === null ? requireResult ? void 0 : pick(data) : pick(data);
if (picked === void 0 || picked === null) {
return result$1.addError(new sdkError.SdkError({
code: "JSSDK_DEFERRED_BATCH_UNEXPECTED_RESPONSE",
description: `deferredBatch: ${method} answered without the expected data.`,
status: 500
}), "base-error");
}
return result$1.setData(picked);
}
/**
* Calls `onStatus`. A throwing callback — or an async one that rejects — must
* not stop the wait, lose the job, or surface as an unhandled rejection.
*/
#reportStatus(onStatus, job, id) {
const report = /* @__PURE__ */ __name((error) => {
this._logger.warning("deferredBatch: onStatus threw", { id, error: String(error) }).catch(() => {
});
}, "report");
try {
const returned = onStatus?.(job);
if (returned && typeof returned.then === "function") {
Promise.resolve(returned).catch(report);
}
} catch (error) {
report(error);
}
}
#carryErrors(from, to) {
for (const [key, error] of from.errors) {
to.addError(error, key);
}
return to;
}
#sleep(ms, signal) {
return new Promise((resolve) => {
if (signal?.aborted) {
resolve();
return;
}
const timer = setTimeout(done, ms);
function done() {
clearTimeout(timer);
signal?.removeEventListener("abort", done);
resolve();
}
__name(done, "done");
signal?.addEventListener("abort", done, { once: true });
});
}
}
function abortedError(id) {
return new sdkError.SdkError({
code: "JSSDK_DEFERRED_BATCH_ABORTED",
description: void 0 === id ? "deferredBatch: aborted before anything was added." : `deferredBatch: waiting for job #${id} was aborted; it keeps running on the portal.`,
status: 499
});
}
__name(abortedError, "abortedError");
function finiteOr(value, fallback) {
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
}
__name(finiteOr, "finiteOr");
async function gunzip(data) {
if (typeof DecompressionStream !== "function") {
throw new sdkError.SdkError({
code: "JSSDK_DEFERRED_BATCH_GZIP_UNSUPPORTED",
description: "deferredBatch: this runtime has no DecompressionStream, so the gzip result file cannot be decoded. Use Node.js 18+ or a current browser.",
status: 500
});
}
try {
const stream = new Blob([data]).stream().pipeThrough(new DecompressionStream("gzip"));
return new Uint8Array(await new Response(stream).arrayBuffer());
} catch {
throw new sdkError.SdkError({
code: "JSSDK_DEFERRED_BATCH_DECODE_FAILED",
description: "deferredBatch: the result file is not valid gzip.",
status: 500
});
}
}
__name(gunzip, "gunzip");
exports.DeferredBatchV3 = DeferredBatchV3;
//# sourceMappingURL=deferred-batch.cjs.map