@crowdin/app-project-module
Version:
Module that generates for you all common endpoints for serving standalone Crowdin App
535 lines (534 loc) • 25.7 kB
JavaScript
;
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
__setModuleDefault(result, mod);
return result;
};
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
return new (P || (P = Promise))(function (resolve, reject) {
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
step((generator = generator.apply(thisArg, _arguments || [])).next());
});
};
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.listenQueueMessage = exports.updateCrowdinFromWebhookRequest = exports.prepareWebhookData = exports.unregisterAllCrowdinWebhooks = exports.unregisterWebhooks = exports.registerWebhooks = exports.HookEvents = void 0;
const logsFormatter = __importStar(require("@crowdin/logs-formatter"));
const crowdinAppFunctions = __importStar(require("@crowdin/crowdin-apps-functions"));
const amqplib_1 = __importDefault(require("amqplib"));
const types_1 = require("../types");
const storage_1 = require("../../../storage");
const connection_1 = require("../../../util/connection");
const defaults_1 = require("./defaults");
const index_1 = require("../../../util/index");
const logger_1 = require("../../../util/logger");
const files_1 = require("./files");
const prefetchCount = 10;
const forceProcessDelay = 10000;
const maxReconnectAttempts = 5;
const baseReconnectDelay = 1000;
const connectionHeartbeat = 60;
exports.HookEvents = {
fileAdded: 'file.added',
fileDeleted: 'file.deleted',
fileApproved: 'file.approved',
fileTranslated: 'file.translated',
translationUpdated: 'translation.updated',
};
const HookConditionEvents = {
ALL: [exports.HookEvents.translationUpdated, exports.HookEvents.fileDeleted],
TRANSLATED: [exports.HookEvents.fileTranslated, exports.HookEvents.fileDeleted],
APPROVED: [exports.HookEvents.fileApproved, exports.HookEvents.fileDeleted],
};
function encodedUrlParam({ config, crowdinContext, integration, }) {
var _a;
const params = {
projectId: +crowdinContext.jwtPayload.context.project_id,
clientId: crowdinContext.clientId,
crowdinId: crowdinContext.crowdinId,
};
const encryptedParams = (0, index_1.encryptData)(config, JSON.stringify(params));
return `${(_a = integration.webhooks) === null || _a === void 0 ? void 0 : _a.urlParam}=${encodeURIComponent(encryptedParams)}`;
}
function decodedUrlParam(config, data) {
const params = (0, index_1.decryptData)(config, data);
return JSON.parse(params);
}
function makeCrowdinWebhookUrl({ config, crowdinContext, integration, }) {
var _a;
const urlParam = encodedUrlParam({ config, integration, crowdinContext });
return (`${config.baseUrl}${((_a = integration.webhooks) === null || _a === void 0 ? void 0 : _a.crowdinWebhookUrl) ? integration.webhooks.crowdinWebhookUrl : '/api/crowdin/webhook'}` + `?${urlParam}`);
}
function registerWebhooks({ config, apiCredentials, appSettings, client, crowdinContext, integration, }) {
var _a, _b;
return __awaiter(this, void 0, void 0, function* () {
const isWebhookSync = Number(appSettings.schedule) !== types_1.SyncSchedule.DISABLED;
const projectId = crowdinContext.jwtPayload.context.project_id;
const urlParam = encodedUrlParam({ config, integration, crowdinContext });
if ((_a = integration.webhooks) === null || _a === void 0 ? void 0 : _a.crowdinWebhooks) {
yield integration.webhooks.crowdinWebhooks(client, projectId, isWebhookSync, appSettings);
}
else {
const webhookName = `${config.name} application hook ${crowdinContext.jwtPayload.sub}`;
const webhookUrl = makeCrowdinWebhookUrl({
config,
integration,
crowdinContext,
});
const syncCondition = types_1.SyncCondition[Number(appSettings.condition)];
const events = [...HookConditionEvents[syncCondition]];
const webhook = yield getCrowdinProjectWebhook({
client,
projectId,
name: webhookName,
});
if (appSettings['new-crowdin-files']) {
events.push(exports.HookEvents.fileAdded);
}
if (isWebhookSync && webhook) {
yield updateCrowdinWebhooks({
client,
projectId,
webhook,
events,
});
}
else if (isWebhookSync && !webhook) {
yield registerCrowdinWebhook({
config,
url: webhookUrl,
client,
crowdinContext,
events,
});
}
else if (!isWebhookSync && webhook) {
yield unregisterCrowdinWebhooks({ client, projectId, webhook });
}
}
if ((_b = integration.webhooks) === null || _b === void 0 ? void 0 : _b.integrationWebhooks) {
yield integration.webhooks.integrationWebhooks(apiCredentials, urlParam, isWebhookSync, appSettings);
}
});
}
exports.registerWebhooks = registerWebhooks;
function unregisterWebhooks({ apiCredentials, appSettings, client, config, crowdinContext, integration, }) {
var _a, _b;
return __awaiter(this, void 0, void 0, function* () {
if ((_a = integration.webhooks) === null || _a === void 0 ? void 0 : _a.crowdinWebhooks) {
yield integration.webhooks.crowdinWebhooks(client, crowdinContext.jwtPayload.context.project_id, false);
}
else {
const webhookName = `${config.name} application hook ${crowdinContext.jwtPayload.sub}`;
const webhook = yield getCrowdinProjectWebhook({
client,
projectId: crowdinContext.jwtPayload.context.project_id,
name: webhookName,
});
if (webhook) {
yield unregisterCrowdinWebhooks({
client,
projectId: crowdinContext.jwtPayload.context.project_id,
webhook,
});
}
}
if ((_b = integration.webhooks) === null || _b === void 0 ? void 0 : _b.integrationWebhooks) {
const urlParam = encodedUrlParam({ config, integration, crowdinContext });
yield integration.webhooks.integrationWebhooks(apiCredentials, urlParam, false, appSettings);
}
});
}
exports.unregisterWebhooks = unregisterWebhooks;
function getCrowdinProjectWebhook({ client, name, projectId, }) {
return __awaiter(this, void 0, void 0, function* () {
const hooks = (yield client.webhooksApi.withFetchAll().listWebhooks(projectId)).data.map((e) => e.data);
return hooks.find((h) => h.name === name);
});
}
function getAllCrowdinProjectWebhooks({ client, config, projectId, }) {
return __awaiter(this, void 0, void 0, function* () {
const hooks = (yield client.webhooksApi.withFetchAll().listWebhooks(projectId)).data.map((e) => e.data);
return hooks.filter((h) => h.name.startsWith(`${config.name} application hook `));
});
}
function createPayload(events) {
const payload = {};
for (const event of events) {
payload[event] = {
event: '{{event}}',
projectId: '{{projectId}}',
language: '{{targetLanguageId}}',
fileId: '{{fileId}}',
};
}
return payload;
}
function registerCrowdinWebhook({ client, config, crowdinContext, events, url, }) {
return __awaiter(this, void 0, void 0, function* () {
const name = `${config.name} application hook ${crowdinContext.jwtPayload.sub}`;
const payload = createPayload(events);
yield client.webhooksApi.addWebhook(crowdinContext.jwtPayload.context.project_id, {
name,
url,
events,
requestType: 'POST',
batchingEnabled: true,
payload,
});
});
}
function updateCrowdinWebhooks({ client, events, projectId, webhook, }) {
return __awaiter(this, void 0, void 0, function* () {
const payload = createPayload(events);
yield client.webhooksApi.editWebhook(projectId, webhook.id, [
{
value: events,
op: 'replace',
path: '/events',
},
{
value: payload,
op: 'replace',
path: '/payload',
},
]);
});
}
function unregisterCrowdinWebhooks({ client, projectId, webhook, }) {
return __awaiter(this, void 0, void 0, function* () {
yield client.webhooksApi.deleteWebhook(projectId, webhook.id);
});
}
function unregisterAllCrowdinWebhooks({ config, crowdinId, integration, }) {
return __awaiter(this, void 0, void 0, function* () {
if (integration.webhooks) {
const crowdinCredentials = yield (0, storage_1.getStorage)().getCrowdinCredentials(crowdinId);
if (crowdinCredentials) {
const credentials = yield (0, storage_1.getStorage)().getAllIntegrationCredentials(crowdinId);
const projectIds = credentials.map((c) => crowdinAppFunctions.getProjectId(c.id));
const crowdinClient = yield (0, connection_1.prepareCrowdinClient)({ config, credentials: crowdinCredentials });
yield Promise.all(projectIds.map((projectId) => __awaiter(this, void 0, void 0, function* () {
const webhooks = yield getAllCrowdinProjectWebhooks({
config,
client: crowdinClient.client,
projectId,
});
yield Promise.all(webhooks.map((hook) => __awaiter(this, void 0, void 0, function* () {
return yield unregisterCrowdinWebhooks({
client: crowdinClient.client,
projectId,
webhook: hook,
});
})));
})));
}
}
});
}
exports.unregisterAllCrowdinWebhooks = unregisterAllCrowdinWebhooks;
function prepareWebhookData({ config, integration, provider, webhookUrlParam, }) {
return __awaiter(this, void 0, void 0, function* () {
let rootFolder = undefined;
let syncSettings = null;
let crowdinClient = null;
let preparedIntegrationCredentials = null;
let appSettings = {};
const { projectId, crowdinId, clientId } = decodedUrlParam(config, webhookUrlParam);
const crowdinCredentials = yield (0, storage_1.getStorage)().getCrowdinCredentials(crowdinId);
const integrationCredentials = yield (0, storage_1.getStorage)().getIntegrationCredentials(clientId);
const integrationConfig = yield (0, storage_1.getStorage)().getIntegrationConfig(clientId);
if (!crowdinCredentials) {
return { projectId, crowdinClient, rootFolder, appSettings, syncSettings, preparedIntegrationCredentials };
}
const context = Object.assign({ jwtPayload: {
context: {
project_id: projectId,
organization_id: crowdinCredentials === null || crowdinCredentials === void 0 ? void 0 : crowdinCredentials.organizationId,
user_id: crowdinCredentials === null || crowdinCredentials === void 0 ? void 0 : crowdinCredentials.userId,
},
}, crowdinId: crowdinCredentials.id }, ((integrationCredentials === null || integrationCredentials === void 0 ? void 0 : integrationCredentials.id) && { clientId: integrationCredentials.id }));
logsFormatter.resetContext();
logsFormatter.setContext(context);
crowdinClient = yield (0, connection_1.prepareCrowdinClient)({
config,
credentials: crowdinCredentials,
context,
});
if (!integrationCredentials) {
return { projectId, crowdinClient, rootFolder, appSettings, syncSettings, preparedIntegrationCredentials };
}
preparedIntegrationCredentials = yield (0, connection_1.prepareIntegrationCredentials)(config, integration, integrationCredentials);
if (integrationConfig === null || integrationConfig === void 0 ? void 0 : integrationConfig.config) {
appSettings = JSON.parse(integrationConfig.config);
const isWebhookSync = Number(appSettings.schedule) !== types_1.SyncSchedule.DISABLED;
if (isWebhookSync) {
syncSettings = (yield (0, storage_1.getStorage)().getSyncSettings(clientId, crowdinId, 'schedule', provider));
// We can get an error in getRootFolder when the project has been deleted but the webhooks have been created
try {
rootFolder = yield (0, defaults_1.getRootFolder)(config, integration, crowdinClient.client, projectId);
}
catch (e) {
(0, logger_1.logError)(e);
}
}
}
return {
projectId,
crowdinClient,
preparedIntegrationCredentials,
rootFolder,
appSettings,
syncSettings,
};
});
}
exports.prepareWebhookData = prepareWebhookData;
function updateCrowdinFromWebhookRequest(args) {
var _a, _b;
return __awaiter(this, void 0, void 0, function* () {
const { integration, webhookData, req } = args;
let filesToSync = [];
const { projectId, crowdinClient, preparedIntegrationCredentials, rootFolder, appSettings, syncSettings } = webhookData;
const syncFiles = (syncSettings === null || syncSettings === void 0 ? void 0 : syncSettings.files) ? (0, files_1.prepareSyncFiles)(JSON.parse(syncSettings.files)) : [];
if ((_a = integration.webhooks) === null || _a === void 0 ? void 0 : _a.integrationWebhookInterceptor) {
filesToSync = yield ((_b = integration.webhooks) === null || _b === void 0 ? void 0 : _b.integrationWebhookInterceptor(projectId, crowdinClient.client, preparedIntegrationCredentials, rootFolder, appSettings, syncSettings, req));
}
for (const file of filesToSync) {
const fileNodeType = file.nodeType || file.node_type || '1';
if (fileNodeType !== '1') {
continue;
}
const initFile = Object.assign({ id: file.id, name: file.name, sync: false, schedule: true, parent_id: file.parent_id || file.parentId, node_type: file.nodeType || file.node_type || '1' }, (file.type ? { type: file.type } : {}));
if (syncSettings) {
if (!syncFiles.find((obj) => obj.id === initFile.id)) {
yield (0, storage_1.getStorage)().updateSyncSettings(JSON.stringify([...syncFiles, initFile]), syncSettings.integrationId, syncSettings.crowdinId, 'schedule', syncSettings.provider);
}
const webhook = yield (0, storage_1.getStorage)().getWebhooks(initFile.id, syncSettings.integrationId, syncSettings.crowdinId, syncSettings.provider);
if (!webhook) {
yield (0, storage_1.getStorage)().saveWebhooks(initFile.id, syncSettings.integrationId, syncSettings.crowdinId, syncSettings.provider);
}
}
}
});
}
exports.updateCrowdinFromWebhookRequest = updateCrowdinFromWebhookRequest;
function listenQueueMessage({ config, integration, queueName, queueUrl, }) {
return __awaiter(this, void 0, void 0, function* () {
let reconnectAttempts = 0;
const connect = () => __awaiter(this, void 0, void 0, function* () {
try {
const connection = yield amqplib_1.default.connect(queueUrl, {
heartbeat: connectionHeartbeat, // 60 seconds heartbeat
});
// Reset reconnect attempts on successful connection
reconnectAttempts = 0;
connection.on('error', (err) => {
(0, logger_1.logError)(`AMQP connection error: ${err.message}`);
});
connection.on('close', () => __awaiter(this, void 0, void 0, function* () {
(0, logger_1.logError)('AMQP connection closed, attempting to reconnect...');
yield scheduleReconnect();
}));
const channel = yield connection.createChannel();
if (channel) {
yield channel.assertQueue(queueName, { durable: true });
yield channel.prefetch(prefetchCount);
channel.on('error', (err) => {
(0, logger_1.logError)(`AMQP channel error: ${err.message}`);
});
channel.on('close', () => {
(0, logger_1.logError)('AMQP channel closed');
});
const onMessage = consumer({ channel, config, integration });
yield channel.consume(queueName, onMessage, { noAck: false });
}
}
catch (e) {
(0, logger_1.logError)(`Failed to connect to AMQP: ${e}`);
yield scheduleReconnect();
}
});
const scheduleReconnect = () => __awaiter(this, void 0, void 0, function* () {
if (reconnectAttempts >= maxReconnectAttempts) {
(0, logger_1.logError)(`Max reconnection attempts (${maxReconnectAttempts}) reached. Stopping reconnection.`);
return;
}
reconnectAttempts++;
const delay = Math.min(baseReconnectDelay * Math.pow(2, reconnectAttempts - 1), 30000); // Exponential backoff up to 30s
setTimeout(() => __awaiter(this, void 0, void 0, function* () {
yield connect();
}), delay);
});
yield connect();
});
}
exports.listenQueueMessage = listenQueueMessage;
function consumer({ channel, config, integration, }) {
let messagesCounter = 0;
let webhooksInfo = {};
let webhooksData = [];
let timeoutId;
let messagesToAck = []; // Track messages for individual acknowledgment
const resetStateVariables = () => {
messagesCounter = 0;
webhooksInfo = {};
webhooksData = [];
messagesToAck = [];
};
return function (msg) {
var _a;
return __awaiter(this, void 0, void 0, function* () {
if (!msg) {
return;
}
messagesCounter++;
messagesToAck.push(msg); // Add message to acknowledgment queue
clearTimeout(timeoutId);
try {
const data = JSON.parse(msg.content.toString());
const urlParam = (_a = integration.webhooks) === null || _a === void 0 ? void 0 : _a.urlParam;
const webhookUrlParam = data.query[urlParam];
const { clientId } = decodedUrlParam(config, webhookUrlParam);
if (!webhooksInfo[clientId]) {
webhooksInfo[clientId] = {};
webhooksInfo[clientId].data = [data];
webhooksInfo[clientId].integration = integration;
webhooksData.push(prepareWebhookData({
config,
integration,
webhookUrlParam,
provider: types_1.Provider.INTEGRATION,
}).then((res) => {
if (res) {
webhooksInfo[clientId].webhookData = res;
}
}));
}
else {
webhooksInfo[clientId].data.push(data);
}
if (messagesCounter < prefetchCount) {
// if all messages are not received, wait 10 seconds to force process messages
timeoutId = setTimeout(() => __awaiter(this, void 0, void 0, function* () {
yield processMessages({
webhooksData,
webhooksInfo,
channel,
messagesToAck,
});
resetStateVariables();
}), forceProcessDelay);
return;
}
clearTimeout(timeoutId);
yield processMessages({
webhooksData,
webhooksInfo,
channel,
messagesToAck,
});
resetStateVariables();
}
catch (e) {
(0, logger_1.logError)(`Error processing message: ${e}`);
// Acknowledge the current message even if there's an error to prevent reprocessing
try {
channel.ack(msg);
}
catch (ackError) {
(0, logger_1.logError)(`Error acknowledging message: ${ackError}`);
}
}
});
};
}
function isRetryableError(error) {
var _a;
if ((_a = error === null || error === void 0 ? void 0 : error.response) === null || _a === void 0 ? void 0 : _a.status) {
const status = error.response.status;
if (status >= 500) {
return true;
}
}
// Network errors, timeouts, and other non-HTTP errors are retryable
if ((error === null || error === void 0 ? void 0 : error.code) === 'ECONNRESET' || (error === null || error === void 0 ? void 0 : error.code) === 'ETIMEDOUT' || (error === null || error === void 0 ? void 0 : error.code) === 'ENOTFOUND') {
return true;
}
return false;
}
function processMessages({ channel, messagesToAck, webhooksData, webhooksInfo, }) {
return __awaiter(this, void 0, void 0, function* () {
try {
// Prepare all webhook data first
yield Promise.all(webhooksData);
// Process each client's webhook data individually
for (const { data, integration, webhookData } of Object.values(webhooksInfo)) {
if (webhookData && webhookData.crowdinClient) {
try {
yield updateCrowdinFromWebhookRequest({
integration: integration,
webhookData: webhookData,
req: data,
});
}
catch (processingError) {
(0, logger_1.logError)(`Error processing webhook request: ${processingError}`);
// Continue processing other clients even if one fails
}
}
}
// Acknowledge all messages individually after successful processing
for (const msg of messagesToAck) {
try {
channel.ack(msg);
}
catch (ackError) {
(0, logger_1.logError)(`Error acknowledging individual message: ${ackError}`);
}
}
}
catch (e) {
(0, logger_1.log)(`Error in processMessages: ${e}`);
for (const msg of messagesToAck) {
try {
if (isRetryableError(e)) {
// For retryable errors (5xx, network issues), requeue the message
channel.nack(msg, false, true);
}
else {
// For non-retryable errors (4xx client errors), acknowledge to remove from queue
(0, logger_1.log)(`Non-retryable error encountered, discarding message: ${e}`);
channel.ack(msg);
}
}
catch (handleError) {
(0, logger_1.logError)(`Error handling message acknowledgment: ${handleError}`);
}
}
}
});
}