@crowdin/app-project-module 0.94.2 → 0.95.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -10,8 +10,21 @@ const util_1 = require("../../util");
10
10
  function register({ config, app }) {
11
11
  var _a, _b;
12
12
  const allowUnauthorized = !(0, util_1.isAuthorizedConfig)(config);
13
- if (((_a = config.contextMenu) === null || _a === void 0 ? void 0 : _a.uiPath) || ((_b = config.contextMenu) === null || _b === void 0 ? void 0 : _b.formSchema)) {
14
- app.use('/context', (0, ui_module_1.default)({ config, allowUnauthorized, moduleType: config.contextMenu.key }), (0, render_ui_module_1.default)(config.contextMenu));
13
+ if (!config.contextMenu) {
14
+ return;
15
+ }
16
+ if (Array.isArray(config.contextMenu)) {
17
+ config.contextMenu.forEach((contextMenu) => {
18
+ if ((contextMenu === null || contextMenu === void 0 ? void 0 : contextMenu.uiPath) || (contextMenu === null || contextMenu === void 0 ? void 0 : contextMenu.formSchema)) {
19
+ app.use(`/context-${contextMenu.key}`, (0, ui_module_1.default)({ config, allowUnauthorized, moduleType: contextMenu.key }), (0, render_ui_module_1.default)(contextMenu));
20
+ }
21
+ });
22
+ }
23
+ else {
24
+ // backward compatibility will be removed after migration
25
+ if (((_a = config.contextMenu) === null || _a === void 0 ? void 0 : _a.uiPath) || ((_b = config.contextMenu) === null || _b === void 0 ? void 0 : _b.formSchema)) {
26
+ app.use('/context', (0, ui_module_1.default)({ config, allowUnauthorized, moduleType: config.contextMenu.key }), (0, render_ui_module_1.default)(config.contextMenu));
27
+ }
15
28
  }
16
29
  }
17
30
  exports.register = register;
@@ -1,8 +1,9 @@
1
1
  import { SignaturePatterns } from '../../types';
2
- export interface ContextModule {
2
+ export interface ContextContent {
3
3
  location: ContextOptionsLocations;
4
4
  type: ContextOptionsTypes;
5
5
  module: string;
6
+ moduleKey?: string;
6
7
  /**
7
8
  * Context menu name
8
9
  */
@@ -47,6 +47,9 @@ const index_1 = require("../../../util/index");
47
47
  const logger_1 = require("../../../util/logger");
48
48
  const prefetchCount = 10;
49
49
  const forceProcessDelay = 10000;
50
+ const maxReconnectAttempts = 5;
51
+ const baseReconnectDelay = 1000;
52
+ const connectionHeartbeat = 60;
50
53
  exports.HookEvents = {
51
54
  fileAdded: 'file.added',
52
55
  fileDeleted: 'file.deleted',
@@ -341,27 +344,52 @@ function updateCrowdinFromWebhookRequest(args) {
341
344
  exports.updateCrowdinFromWebhookRequest = updateCrowdinFromWebhookRequest;
342
345
  function listenQueueMessage({ config, integration, queueName, queueUrl, }) {
343
346
  return __awaiter(this, void 0, void 0, function* () {
344
- try {
345
- const connection = yield amqplib_1.default.connect(queueUrl);
346
- connection.once('close', function () {
347
- setTimeout(() => {
348
- listenQueueMessage({ config, integration, queueUrl, queueName });
349
- }, 3000);
347
+ let reconnectAttempts = 0;
348
+ const connect = () => __awaiter(this, void 0, void 0, function* () {
349
+ try {
350
+ const connection = yield amqplib_1.default.connect(queueUrl, {
351
+ heartbeat: connectionHeartbeat, // 60 seconds heartbeat
352
+ });
353
+ // Reset reconnect attempts on successful connection
354
+ reconnectAttempts = 0;
355
+ connection.on('error', (err) => {
356
+ (0, logger_1.logError)(`AMQP connection error: ${err.message}`);
357
+ });
358
+ connection.on('close', () => __awaiter(this, void 0, void 0, function* () {
359
+ (0, logger_1.logError)('AMQP connection closed, attempting to reconnect...');
360
+ yield scheduleReconnect();
361
+ }));
362
+ const channel = yield connection.createChannel();
363
+ if (channel) {
364
+ yield channel.assertQueue(queueName, { durable: true });
365
+ yield channel.prefetch(prefetchCount);
366
+ channel.on('error', (err) => {
367
+ (0, logger_1.logError)(`AMQP channel error: ${err.message}`);
368
+ });
369
+ channel.on('close', () => {
370
+ (0, logger_1.logError)('AMQP channel closed');
371
+ });
372
+ const onMessage = consumer({ channel, config, integration });
373
+ yield channel.consume(queueName, onMessage, { noAck: false });
374
+ }
375
+ }
376
+ catch (e) {
377
+ (0, logger_1.logError)(`Failed to connect to AMQP: ${e}`);
378
+ yield scheduleReconnect();
379
+ }
380
+ });
381
+ const scheduleReconnect = () => __awaiter(this, void 0, void 0, function* () {
382
+ if (reconnectAttempts >= maxReconnectAttempts) {
383
+ (0, logger_1.logError)(`Max reconnection attempts (${maxReconnectAttempts}) reached. Stopping reconnection.`);
350
384
  return;
351
- });
352
- const channel = yield connection.createChannel();
353
- if (channel) {
354
- yield channel.assertQueue(queueName, { durable: true });
355
- yield channel.prefetch(prefetchCount);
356
- const onMessage = consumer({ channel, config, integration });
357
- yield channel.consume(queueName, onMessage, { noAck: false });
358
385
  }
359
- }
360
- catch (e) {
361
- setTimeout(() => {
362
- listenQueueMessage({ config, integration, queueUrl, queueName });
363
- }, 3000);
364
- }
386
+ reconnectAttempts++;
387
+ const delay = Math.min(baseReconnectDelay * Math.pow(2, reconnectAttempts - 1), 30000); // Exponential backoff up to 30s
388
+ setTimeout(() => __awaiter(this, void 0, void 0, function* () {
389
+ yield connect();
390
+ }), delay);
391
+ });
392
+ yield connect();
365
393
  });
366
394
  }
367
395
  exports.listenQueueMessage = listenQueueMessage;
@@ -370,18 +398,21 @@ function consumer({ channel, config, integration, }) {
370
398
  let webhooksInfo = {};
371
399
  let webhooksData = [];
372
400
  let timeoutId;
401
+ let messagesToAck = []; // Track messages for individual acknowledgment
373
402
  const resetStateVariables = () => {
374
403
  messagesCounter = 0;
375
404
  webhooksInfo = {};
376
405
  webhooksData = [];
406
+ messagesToAck = [];
377
407
  };
378
408
  return function (msg) {
379
409
  var _a;
380
410
  return __awaiter(this, void 0, void 0, function* () {
381
- messagesCounter++;
382
411
  if (!msg) {
383
412
  return;
384
413
  }
414
+ messagesCounter++;
415
+ messagesToAck.push(msg); // Add message to acknowledgment queue
385
416
  clearTimeout(timeoutId);
386
417
  try {
387
418
  const data = JSON.parse(msg.content.toString());
@@ -413,7 +444,7 @@ function consumer({ channel, config, integration, }) {
413
444
  webhooksData,
414
445
  webhooksInfo,
415
446
  channel,
416
- msg,
447
+ messagesToAck,
417
448
  });
418
449
  resetStateVariables();
419
450
  }), forceProcessDelay);
@@ -424,34 +455,65 @@ function consumer({ channel, config, integration, }) {
424
455
  webhooksData,
425
456
  webhooksInfo,
426
457
  channel,
427
- msg,
458
+ messagesToAck,
428
459
  });
429
460
  resetStateVariables();
430
461
  }
431
462
  catch (e) {
432
- (0, logger_1.logError)(e);
463
+ (0, logger_1.logError)(`Error processing message: ${e}`);
464
+ // Acknowledge the current message even if there's an error to prevent reprocessing
465
+ try {
466
+ channel.ack(msg);
467
+ }
468
+ catch (ackError) {
469
+ (0, logger_1.logError)(`Error acknowledging message: ${ackError}`);
470
+ }
433
471
  }
434
472
  });
435
473
  };
436
474
  }
437
- function processMessages({ channel, msg, webhooksData, webhooksInfo, }) {
475
+ function processMessages({ channel, messagesToAck, webhooksData, webhooksInfo, }) {
438
476
  return __awaiter(this, void 0, void 0, function* () {
439
477
  try {
478
+ // Prepare all webhook data first
440
479
  yield Promise.all(webhooksData);
480
+ // Process each client's webhook data individually
441
481
  for (const { data, integration, webhookData } of Object.values(webhooksInfo)) {
442
482
  if (webhookData && webhookData.crowdinClient) {
443
- yield updateCrowdinFromWebhookRequest({
444
- integration: integration,
445
- webhookData: webhookData,
446
- req: data,
447
- });
483
+ try {
484
+ yield updateCrowdinFromWebhookRequest({
485
+ integration: integration,
486
+ webhookData: webhookData,
487
+ req: data,
488
+ });
489
+ }
490
+ catch (processingError) {
491
+ (0, logger_1.logError)(`Error processing webhook request: ${processingError}`);
492
+ // Continue processing other clients even if one fails
493
+ }
494
+ }
495
+ }
496
+ // Acknowledge all messages individually after successful processing
497
+ for (const msg of messagesToAck) {
498
+ try {
499
+ channel.ack(msg);
500
+ }
501
+ catch (ackError) {
502
+ (0, logger_1.logError)(`Error acknowledging individual message: ${ackError}`);
448
503
  }
449
504
  }
450
- channel.ack(msg, true);
451
505
  }
452
506
  catch (e) {
453
- (0, logger_1.logError)(e);
454
- channel.nack(msg, false, false);
507
+ (0, logger_1.logError)(`Error in processMessages: ${e}`);
508
+ // On critical error, nack all messages to requeue them
509
+ for (const msg of messagesToAck) {
510
+ try {
511
+ channel.nack(msg, false, true); // requeue the message
512
+ }
513
+ catch (nackError) {
514
+ (0, logger_1.logError)(`Error nacking message: ${nackError}`);
515
+ }
516
+ }
455
517
  }
456
518
  });
457
519
  }
@@ -210,19 +210,35 @@ function handle(config) {
210
210
  modules['modal'] = modals;
211
211
  }
212
212
  if (config.contextMenu) {
213
- // prevent possible overrides of the other modules
214
- config.contextMenu = Object.assign(Object.assign({}, config.contextMenu), { key: config.identifier + '-context-menu' });
215
- modules['context-menu'] = [
216
- Object.assign({ key: config.contextMenu.key, name: config.contextMenu.name || config.name, description: config.description, options: Object.assign(Object.assign({ location: config.contextMenu.location, type: config.contextMenu.type }, (config.contextMenu.module
217
- ? {
218
- module: {
219
- [config.contextMenu.module]: modules[config.contextMenu.module][0].key,
220
- },
221
- }
222
- : {})), { url: '/context/' + (config.contextMenu.fileName || 'index.html') }), signaturePatterns: config.contextMenu.signaturePatterns }, (!!config.contextMenu.environments && {
223
- environments: normalizeEnvironments(config.contextMenu.environments),
224
- })),
225
- ];
213
+ let contextMenus = [];
214
+ if (Array.isArray(config.contextMenu)) {
215
+ contextMenus = config.contextMenu.map((contextMenu, i) => {
216
+ const moduleKey = contextMenu.key || `${config.identifier}-context-menu-${i}`;
217
+ return Object.assign({ key: moduleKey, name: contextMenu.name || config.name, description: config.description, options: Object.assign(Object.assign({ location: contextMenu.location, type: contextMenu.type }, (contextMenu.module && contextMenu.moduleKey
218
+ ? {
219
+ module: {
220
+ [contextMenu.module]: contextMenu.moduleKey,
221
+ },
222
+ }
223
+ : {})), { url: `/context-${moduleKey}/` + (contextMenu.fileName || 'index.html') }), signaturePatterns: contextMenu.signaturePatterns }, (!!contextMenu.environments && {
224
+ environments: normalizeEnvironments(contextMenu.environments),
225
+ }));
226
+ });
227
+ }
228
+ else {
229
+ contextMenus = [
230
+ Object.assign({ key: config.identifier + '-context-menu', name: config.contextMenu.name || config.name, description: config.description, options: Object.assign(Object.assign({ location: config.contextMenu.location, type: config.contextMenu.type }, (config.contextMenu.module
231
+ ? {
232
+ module: {
233
+ [config.contextMenu.module]: modules[config.contextMenu.module][0].key,
234
+ },
235
+ }
236
+ : {})), { url: '/context/' + (config.contextMenu.fileName || 'index.html') }), signaturePatterns: config.contextMenu.signaturePatterns }, (!!config.contextMenu.environments && {
237
+ environments: normalizeEnvironments(config.contextMenu.environments),
238
+ })),
239
+ ];
240
+ }
241
+ modules['context-menu'] = contextMenus;
226
242
  }
227
243
  if (config.api) {
228
244
  modules['api'] = (0, api_1.getApiManifest)(config, config.api);
@@ -21,6 +21,7 @@ function register({ config, app }) {
21
21
  });
22
22
  }
23
23
  else {
24
+ // backward compatibility will be removed after migration
24
25
  if (((_a = config.modal) === null || _a === void 0 ? void 0 : _a.uiPath) || ((_b = config.modal) === null || _b === void 0 ? void 0 : _b.formSchema)) {
25
26
  app.use('/modal', (0, ui_module_1.default)({ config, allowUnauthorized, moduleType: config.modal.key }), (0, render_ui_module_1.default)(config.modal));
26
27
  }
package/out/types.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import Crowdin from '@crowdin/crowdin-api-client';
2
2
  import { JwtPayload, VerifyOptions } from '@crowdin/crowdin-apps-functions';
3
3
  import { Request } from 'express';
4
- import { ContextModule } from './modules/context-menu/types';
4
+ import { ContextContent } from './modules/context-menu/types';
5
5
  import { CustomMTLogic } from './modules/custom-mt/types';
6
6
  import { CustomSpellcheckerModule } from './modules/custom-spell-check/types';
7
7
  import { EditorPanels } from './modules/editor-right-panel/types';
@@ -155,7 +155,7 @@ export interface ClientConfig extends ImagePath {
155
155
  /**
156
156
  * context menu module
157
157
  */
158
- contextMenu?: ContextModule & UiModule & Environments;
158
+ contextMenu?: ContextModule | ContextModule[];
159
159
  /**
160
160
  * modal module
161
161
  */
@@ -499,4 +499,6 @@ export declare enum storageFiles {
499
499
  }
500
500
  export interface ModalModule extends ModuleContent, UiModule, Environments {
501
501
  }
502
+ export interface ContextModule extends ContextContent, UiModule, Environments {
503
+ }
502
504
  export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crowdin/app-project-module",
3
- "version": "0.94.2",
3
+ "version": "0.95.0",
4
4
  "description": "Module that generates for you all common endpoints for serving standalone Crowdin App",
5
5
  "main": "out/index.js",
6
6
  "types": "out/index.d.ts",