@bitrix24/b24jssdk
Version:
Bitrix24 REST API JavaScript SDK
277 lines (273 loc) • 10.1 kB
JavaScript
/**
* @package @bitrix24/b24jssdk
* @version 2.2.0
* @copyright (c) 2026 Bitrix24
* @license MIT
* @see https://github.com/bitrix24/b24jssdk
* @see https://bitrix24.github.io/b24jssdk/
*/
'use strict';
const type = require('../../tools/type.cjs');
const text = require('../../tools/text.cjs');
const index = require('../../tools/index.cjs');
const sdkError = require('../../core/sdk-error.cjs');
const loggerFactory = require('../../logger/logger-factory.cjs');
var __defProp = Object.defineProperty;
var __name = (target, value) => __defProp(target, "name", { value, configurable: true });
const MAX_REJECTED_ORIGINS = 50;
const MAX_CALLBACK_ID_IN_ERROR = 64;
class MessageManager {
static {
__name(this, "MessageManager");
}
#appFrame;
#callbackPromises;
#callbackSingletone;
// origins already warned about (#244) — dedup so a peer spamming postMessage
// from a foreign origin can't flood a wired logger sink. Capped, because the
// dedup key is attacker-chosen: a peer can post from an unbounded supply of
// distinct origins (`a.evil.test`, `b.evil.test`, …) and every unseen one adds
// an entry. Past the cap the set stops growing and later origins are simply
// not warned about — losing a log line is the right trade against unbounded
// memory in a long-lived frame (#146).
#rejectedOrigins = /* @__PURE__ */ new Set();
_logger;
runCallbackHandler;
constructor(appFrame) {
this._logger = loggerFactory.LoggerFactory.createNullLogger();
this.#appFrame = appFrame;
this.#callbackPromises = /* @__PURE__ */ new Map();
this.#callbackSingletone = /* @__PURE__ */ new Map();
this.runCallbackHandler = this._runCallback.bind(this);
}
setLogger(logger) {
this._logger = logger;
}
getLogger() {
return this._logger;
}
// region Events ////
/**
* Subscribe to the onMessage event of the parent window
*/
subscribe() {
window.addEventListener("message", this.runCallbackHandler);
}
/**
* Unsubscribe from the onMessage event of the parent window, and tear the
* manager down.
*
* Removing the listener is not enough. Every in-flight `send()` is waiting on
* a promise that only the listener could settle, so dropping the listener
* alone strands each of them **forever** — with its `isSafely` timer still
* armed and its entry still in the callback map. `B24Frame.destroy()` calls
* this, so an SPA that mounts and unmounts the frame accumulated one such set
* per cycle (#146; the same class of leak as #222 in `PullClient`).
*
* Pending sends are therefore **rejected**, not left hanging, with
* `JSSDK_FRAME_DISPOSED` — mirroring how a disposed `PullClient` rejects
* `start()` with `PULL_DISPOSED`. A caller awaiting a command that can no
* longer be answered should learn that; silence is the one outcome it cannot
* act on.
*
* Note the consequence: a `send()` whose result was discarded without a
* `.catch()` turns into an unhandled rejection at teardown. Most SDK commands
* pass `isSafely`, which settles them on their own timer, so they are normally
* already gone by the time this runs — but **not all of them do**.
* `ParentManager.closeApplication()` and `SliderManager.closeSliderAppPage()`
* deliberately pass `isSafely: false` ("everything will be closed, and timeout
* will not be able to do anything"), and those are exactly the calls made as
* an app tears itself down — the likeliest race with this method. The awaited
* commands (`getInitData`, `refreshAuth`, the dialog selectors) send without
* `isSafely` too, but their callers await them, so the rejection surfaces
* where it can be handled.
*
* A caller that fires `closeApplication()` without awaiting it should attach
* `.catch(() => {})` if it also tears the frame down in the same breath.
*/
unsubscribe() {
window.removeEventListener("message", this.runCallbackHandler);
for (const [key, promise] of this.#callbackPromises) {
if (promise.timeoutId) {
clearTimeout(promise.timeoutId);
}
this.#callbackPromises.delete(key);
promise.reject(new sdkError.SdkError({
code: "JSSDK_FRAME_DISPOSED",
description: "The B24Frame was destroyed before the parent window answered this command.",
status: 0
}));
}
this.#callbackSingletone.clear();
this.#rejectedOrigins.clear();
}
// endregion ////
/**
* Send message to parent window
* The answer (if) we will get in _runCallback
*
* @param command
* @param params
*/
async send(command, params = null) {
return new Promise((resolve, reject) => {
let cmd;
const promiseHandler = {
resolve,
reject,
timeoutId: null
};
const keyPromise = this.#setCallbackPromise(promiseHandler);
let paramsSend = null;
const optionsSend = index.omit(params || {}, ["singleOption", "callBack", "isSafely", "safelyTime", "requestId"]);
const { callBack, singleOption, requestId } = params || {};
if (callBack) {
this.#callbackSingletone.set(keyPromise, callBack);
}
if (singleOption) {
paramsSend = singleOption;
} else if (Object.keys(optionsSend).length > 0) {
paramsSend = { ...optionsSend };
}
if (command.toString().includes(":")) {
cmd = {
method: command.toString(),
params: paramsSend || "",
callback: keyPromise,
appSid: this.#appFrame.getAppSid(),
requestId
};
} else {
cmd = command.toString();
if (params?.isRawValue !== true && paramsSend) {
paramsSend = JSON.stringify(paramsSend);
} else if (params?.isRawValue === true && paramsSend && type.Type.isPlainObject(paramsSend) && paramsSend["value"]) {
paramsSend = paramsSend["value"];
}
const listParams = [
paramsSend || "",
keyPromise,
this.#appFrame.getAppSid()
];
cmd += ":" + listParams.filter(Boolean).join(":");
}
this.getLogger().debug(`send to ${this.#appFrame.getTargetOrigin()}`, {
command: command.toString(),
callbackKey: keyPromise,
origin: this.#appFrame.getTargetOrigin()
}).catch(() => {
});
parent.postMessage(cmd, this.#appFrame.getTargetOrigin());
if (params?.isSafely) {
const safelyTime = Number.parseInt(String(params?.safelyTime || 900));
this.#callbackPromises.get(keyPromise).timeoutId = window.setTimeout(
() => {
if (this.#callbackPromises.has(keyPromise)) {
this.getLogger().warning(`action ${command.toString()} stop by timeout`, {
command: command.toString(),
safelyTime
}).catch(() => {
});
this.#callbackPromises.delete(keyPromise);
resolve({ isSafely: true });
}
},
safelyTime
);
}
});
}
/**
* Fulfilling a promise based on messages from the parent window
*
* @param event
* @private
*/
_runCallback(event) {
if (event.origin !== this.#appFrame.getTargetOrigin()) {
if (!this.#rejectedOrigins.has(event.origin) && this.#rejectedOrigins.size < MAX_REJECTED_ORIGINS) {
this.#rejectedOrigins.add(event.origin);
this.getLogger().warning("message rejected: unexpected origin", {
origin: event.origin,
expected: this.#appFrame.getTargetOrigin()
}).catch(() => {
});
}
return;
}
if (typeof event.data === "string" && event.data.length > 0) {
const [id = "", ...rest] = event.data.split(":");
const cmd = {
id,
args: rest.join(":")
};
this.getLogger().debug(`get from ${event.origin}`, {
id: cmd.id,
origin: event.origin
}).catch(() => {
});
if (cmd.args) {
try {
cmd.args = JSON.parse(cmd.args);
} catch {
this.getLogger().warning("message dropped: payload is not valid JSON", {
id: cmd.id,
origin: event.origin
}).catch(() => {
});
this.#rejectPromise(cmd.id, new sdkError.SdkError({
code: "JSSDK_FRAME_BAD_PAYLOAD",
description: `The parent window answered command "${cmd.id.slice(0, MAX_CALLBACK_ID_IN_ERROR)}" with a payload that is not valid JSON.`,
status: 0
}));
return;
}
}
if (this.#callbackPromises.has(cmd.id)) {
const promise = this.#callbackPromises.get(cmd.id);
if (promise.timeoutId) {
clearTimeout(promise.timeoutId);
}
this.#callbackPromises.delete(cmd.id);
promise.resolve(cmd.args);
} else if (this.#callbackSingletone.has(cmd.id)) {
const callBack = this.#callbackSingletone.get(cmd.id);
if (callBack) {
callBack.apply(globalThis, [cmd.args]);
}
}
}
}
/**
* Settle a waiting promise with a rejection, if one is still waiting.
*
* Silent when the key is unknown: a payload can arrive for a command that
* already timed out under `isSafely`, and there is nothing left to reject.
*/
#rejectPromise(key, error) {
const promise = this.#callbackPromises.get(key);
if (!promise) {
return;
}
if (promise.timeoutId) {
clearTimeout(promise.timeoutId);
}
this.#callbackPromises.delete(key);
promise.reject(error);
}
/**
* Storing a promise for a message from the parent window
*
* @param promiseHandler
* @private
*
* @memo We don't use Symbol here, because we need to pass it to the parent and then find and restore it.
*/
#setCallbackPromise(promiseHandler) {
const key = text.Text.getUniqId();
this.#callbackPromises.set(key, promiseHandler);
return key;
}
}
exports.MessageManager = MessageManager;
//# sourceMappingURL=controller.cjs.map