UNPKG

@bitrix24/b24jssdk

Version:

Bitrix24 REST API JavaScript SDK

488 lines (485 loc) • 19.1 kB
/** * @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/ */ import { AbstractAction } from '../abstract-action.mjs'; import { ApiVersion } from '../../../types/b24.mjs'; import { Result } from '../../result.mjs'; import { SdkError } from '../../sdk-error.mjs'; import { ParseRow } from '../../interaction/batch/parse-row.mjs'; 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 { 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 = new Result(); this.#toCommands(options.calls); if (options.signal?.aborted) { return result.addError(abortedError(), "base-error"); } const added = await this.add(options); if (!added.isSuccess) { return this.#carryErrors(added, result); } 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); } const rows = await this.download(id); if (!rows.isSuccess) { return this.#carryErrors(rows, result); } if (options.deleteAfter !== false) { await this.#deleteQuietly(id); } return result.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 = new Result(); let lastStatus = reportedStatus; for (; ; ) { if (options.signal?.aborted) { return result.addError(abortedError(id), "base-error"); } const current = await this.get(id); if (!current.isSuccess) { return this.#carryErrors(current, result); } const job = current.getData(); if (job.status !== lastStatus) { lastStatus = job.status; this.#reportStatus(options.onStatus, job, id); } if (job.status === "done") { return result.setData(job); } if (job.status === "error") { result.setData(job); return result.addError(new 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.addError(new 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 = new Result(); const link = await this.getDownloadUrl(id); if (!link.isSuccess) { return this.#carryErrors(link, result); } let bytes; try { const response = await this._b24.getHttpClient(ApiVersion.v3).ajaxClient.get(link.getData(), { responseType: "arraybuffer", maxRedirects: 0 }); if (!response.status) { return result.addError(new 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.addError(new 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.setData(await this.decode(bytes)); } catch (error) { return result.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({ 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({ 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.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 = new Result(); let response; try { response = await this._b24.getHttpClient(ApiVersion.v3).call( method, params, requestId, void 0 === idempotencyKey ? void 0 : { idempotencyKey } ); } catch (error) { return result.addError(error instanceof Error ? error : new Error(String(error)), "base-error"); } if (!response.isSuccess) { return this.#carryErrors(response, result); } 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.addError(new SdkError({ code: "JSSDK_DEFERRED_BATCH_UNEXPECTED_RESPONSE", description: `deferredBatch: ${method} answered without the expected data.`, status: 500 }), "base-error"); } return result.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({ 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({ 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({ code: "JSSDK_DEFERRED_BATCH_DECODE_FAILED", description: "deferredBatch: the result file is not valid gzip.", status: 500 }); } } __name(gunzip, "gunzip"); export { DeferredBatchV3 }; //# sourceMappingURL=deferred-batch.mjs.map