langaro-api 1.0.5 → 1.0.6

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.
Files changed (2) hide show
  1. package/lib/cli/init.js +349 -19
  2. package/package.json +1 -1
package/lib/cli/init.js CHANGED
@@ -1,9 +1,24 @@
1
1
  const fs = require('fs');
2
2
  const path = require('path');
3
3
  const readline = require('readline');
4
+ const crypto = require('crypto');
4
5
 
5
6
  const OWN_VERSION = require('../../package.json').version;
6
7
 
8
+ function generateUUID() {
9
+ return crypto.randomUUID();
10
+ }
11
+
12
+ function generateRandomString(length = 24) {
13
+ const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
14
+ let result = '';
15
+ const bytes = crypto.randomBytes(length);
16
+ for (let i = 0; i < length; i++) {
17
+ result += chars[bytes[i] % chars.length];
18
+ }
19
+ return result;
20
+ }
21
+
7
22
  function ask(rl, question, defaultVal) {
8
23
  const suffix = defaultVal ? ` (${defaultVal})` : '';
9
24
  return new Promise((resolve) => {
@@ -199,11 +214,11 @@ newrelic_agent.log
199
214
  @types/generated/
200
215
  `;
201
216
 
202
- function envContent(port, dbName, redisPort) {
217
+ function envContent(port, dbName, redisPort, { jwtSecret = '', masterPass = '' } = {}) {
203
218
  return `NODE_ENV="development"
204
219
  TZ="UTC"
205
220
  PORT="${port}"
206
- APP_URL="http://localhost:${port}"
221
+ APP_URL="http://localhost:3000"
207
222
  API_URL="http://localhost:${port}"
208
223
 
209
224
  # Database
@@ -216,13 +231,13 @@ DB_PORT="3306"
216
231
  # Redis
217
232
  REDIS_HOST="127.0.0.1"
218
233
  REDIS_PORT="${redisPort}"
219
- REDIS_PASSWORD=""
234
+ # REDIS_PASSWORD=""
220
235
  # REDIS_USERNAME=""
221
236
  # REDIS_TLS=""
222
237
 
223
238
  # Security
224
- JWT_SECRET=""
225
- MASTER_PASS=""
239
+ JWT_SECRET="${jwtSecret}"
240
+ MASTER_PASS="${masterPass}"
226
241
 
227
242
  # Queue Management
228
243
  BULLBOARD_PATH="/admin/queues"
@@ -620,19 +635,16 @@ module.exports = function (services) {
620
635
  };
621
636
  `;
622
637
 
623
- const UTILS_INDEX = `const { ApiError } = require('trigger-api-error');
624
- const { StatusCodes } = require('http-status-codes');
625
- const { validateRequiredFields } = require('validate-required-fields');
626
- const { validateFieldsFormat } = require('validate-fields-format');
627
- const { fieldShouldNotBeSent } = require('forbid-fields-sent');
638
+ const UTILS_INDEX = `export * from 'validate-required-fields';
639
+ export * from 'trigger-api-error';
640
+ export * from 'http-status-codes';
641
+ export * from 'forbid-fields-sent';
642
+ export * from 'validate-fields-format';
643
+ export * from './sleep';
644
+ `;
628
645
 
629
- module.exports = {
630
- ApiError,
631
- StatusCodes,
632
- validateRequiredFields,
633
- validateFieldsFormat,
634
- fieldShouldNotBeSent,
635
- };
646
+ const UTILS_SLEEP = `// eslint-disable-next-line no-promise-executor-return
647
+ exports.sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
636
648
  `;
637
649
 
638
650
  const GLOBAL_SETUP_TESTS = `require('dotenv').config({ path: '.env.test' });
@@ -648,6 +660,319 @@ const matchers = require('jest-extended');
648
660
  expect.extend(matchers);
649
661
  `;
650
662
 
663
+ const QUEUES_JS = `require('dotenv').config({
664
+ path: process.env.NODE_ENV === 'test' ? '.env.test' : '.env',
665
+ });
666
+
667
+ const Sentry = require('@/config/instrument');
668
+ const redisConfig = require('@/database/redis-config');
669
+ const IORedis = require('ioredis');
670
+ const { Queue, Worker } = require('bullmq');
671
+ const { BullMQAdapter } = require('@bull-board/api/bullMQAdapter');
672
+ const { sleep } = require('@/utils');
673
+
674
+ const isTestEnv = process.env.NODE_ENV === 'test';
675
+ const inactivityCheckIntervalMs = 1 * 60 * 1000;
676
+ const inactivityTimeLimitMs = 10 * 60 * 1000;
677
+
678
+ let isShuttingDown = false;
679
+ let connection;
680
+ const jobConfigs = new Map();
681
+ const activeQueues = new Map();
682
+ let ioRef;
683
+ let bullBoardUpdateQueuesRef;
684
+ let cleanupIntervalId = null;
685
+
686
+ function createRedisConnection() {
687
+ const connectionInstance = new IORedis(redisConfig.connection);
688
+ connectionInstance.on('error', (error) => {
689
+ console.error('Redis Connection Error:', error);
690
+ Sentry.captureException(error, { tags: { service: 'redis' } });
691
+ });
692
+ return connectionInstance;
693
+ }
694
+
695
+ function getDefaultJobOptions() {
696
+ return {
697
+ attempts: 100,
698
+ backoff: { type: 'fixed', delay: 1000 * 60 },
699
+ removeOnComplete: { age: 60 * 60 * 24 * 30, count: 1000 },
700
+ removeOnFail: { age: 60 * 60 * 24 * 90, count: 5000 },
701
+ };
702
+ }
703
+
704
+ function getDefaultWorkerOptions() {
705
+ return {
706
+ autorun: false,
707
+ limiter: { max: 2, duration: 1000 },
708
+ maxStalledCount: 5,
709
+ };
710
+ }
711
+
712
+ function updateBullBoard() {
713
+ if (bullBoardUpdateQueuesRef && typeof bullBoardUpdateQueuesRef === 'function') {
714
+ bullBoardUpdateQueuesRef(Array.from(activeQueues.values()).map((qd) => new BullMQAdapter(qd.queue)));
715
+ }
716
+ }
717
+
718
+ async function getOrCreateQueueAndWorker(queueName, groupId, baseConfig) {
719
+ const queueNameWithGroupId = \`\${queueName}\${groupId ? \`-\${groupId}\` : ''}\`;
720
+
721
+ if (activeQueues.has(queueNameWithGroupId)) {
722
+ const existing = activeQueues.get(queueNameWithGroupId);
723
+ existing.lastActivity = Date.now();
724
+ return existing;
725
+ }
726
+
727
+ console.log(\`Creating queue and worker for: \${queueNameWithGroupId}\`);
728
+
729
+ const finalJobOptions = { ...getDefaultJobOptions(), ...(baseConfig.jobOptions || {}) };
730
+ const queue = new Queue(queueNameWithGroupId, { connection, defaultJobOptions: finalJobOptions });
731
+
732
+ const finalWorkerOptions = { connection, ...getDefaultWorkerOptions(), ...(baseConfig.workerOptions || {}) };
733
+ const worker = new Worker(
734
+ queueNameWithGroupId,
735
+ async (jobObj) => {
736
+ const queueData = activeQueues.get(queueNameWithGroupId);
737
+ if (queueData) { queueData.lastActivity = Date.now(); }
738
+ // eslint-disable-next-line no-use-before-define
739
+ await baseConfig.handle(jobObj.data, jobObj, { add }, ioRef);
740
+ },
741
+ finalWorkerOptions,
742
+ );
743
+
744
+ worker.on('failed', (job, error) => {
745
+ Sentry.captureException(error, {
746
+ tags: { queue: queueNameWithGroupId },
747
+ contexts: { data: { 'Job data': job?.data, queue: queueNameWithGroupId } },
748
+ });
749
+ });
750
+
751
+ worker.on('error', (error) => {
752
+ Sentry.captureException(error, { tags: { queue: queueNameWithGroupId, event: 'worker-error' } });
753
+ });
754
+
755
+ const queueData = {
756
+ queue, worker, lastActivity: Date.now(), name: queueNameWithGroupId, queueName, groupId,
757
+ };
758
+
759
+ if (!isShuttingDown) {
760
+ activeQueues.set(queueNameWithGroupId, queueData);
761
+ worker.run();
762
+ console.log(\`Worker running for: \${queueNameWithGroupId}\`);
763
+ }
764
+
765
+ updateBullBoard();
766
+ return queueData;
767
+ }
768
+
769
+ // eslint-disable-next-line no-unused-vars
770
+ async function add(queueName, data, options = {}) {
771
+ const baseConfig = jobConfigs.get(queueName);
772
+ if (!baseConfig) {
773
+ throw new Error(\`Queue configuration not found for: \${queueName}\`);
774
+ }
775
+
776
+ try {
777
+ if (baseConfig.group && !options.groupId) {
778
+ throw new Error(\`Missing groupId for grouped queue: \${queueName}\`);
779
+ }
780
+
781
+ const queueData = await getOrCreateQueueAndWorker(queueName, options.groupId, baseConfig);
782
+ await queueData.queue.add(queueData.name, data, options);
783
+ } catch (error) {
784
+ console.error(\`Failed to add job to queue \${queueName}:\`, error);
785
+ Sentry.captureException(error, { tags: { queue: queueName, operation: 'addJob' } });
786
+ throw error;
787
+ }
788
+ }
789
+
790
+ async function closeInactiveQueues() {
791
+ const now = Date.now();
792
+ const queuesToRemove = [];
793
+
794
+ const checks = Array.from(activeQueues.entries()).map(async ([queueName, queueData]) => {
795
+ if (now - queueData.lastActivity <= inactivityTimeLimitMs) return;
796
+ try {
797
+ const [activeCount, waitingCount, delayedCount] = await Promise.all([
798
+ queueData.queue.getActiveCount(),
799
+ queueData.queue.getWaitingCount(),
800
+ queueData.queue.getDelayedCount(),
801
+ ]);
802
+ if (activeCount === 0 && waitingCount === 0 && delayedCount === 0) {
803
+ queuesToRemove.push(queueName);
804
+ }
805
+ } catch (error) {
806
+ Sentry.captureException(error, { tags: { queue: queueName, operation: 'checkBeforeClose' } });
807
+ }
808
+ });
809
+
810
+ await Promise.allSettled(checks);
811
+ if (queuesToRemove.length === 0) return;
812
+
813
+ await Promise.allSettled(
814
+ queuesToRemove.map(async (queueName) => {
815
+ const queueData = activeQueues.get(queueName);
816
+ if (!queueData) return;
817
+ try {
818
+ await queueData.worker.close();
819
+ await queueData.queue.close();
820
+ activeQueues.delete(queueName);
821
+ } catch (error) {
822
+ Sentry.captureException(error, { tags: { queue: queueName, operation: 'closeInactive' } });
823
+ }
824
+ }),
825
+ );
826
+
827
+ updateBullBoard();
828
+ }
829
+
830
+ function startCleanupInterval() {
831
+ if (cleanupIntervalId) clearInterval(cleanupIntervalId);
832
+ if (!isTestEnv) {
833
+ cleanupIntervalId = setInterval(closeInactiveQueues, inactivityCheckIntervalMs);
834
+ }
835
+ }
836
+
837
+ async function startWorkersForExistingQueues() {
838
+ if (!connection) return;
839
+
840
+ const stream = connection.scanStream({ match: 'bull:*:meta', count: 1000 });
841
+ const potentialQueueNames = new Set();
842
+
843
+ stream.on('data', (keys) => {
844
+ (keys || []).forEach((key) => {
845
+ const match = key.match(/^bull:(.+):meta$/);
846
+ if (match && match[1]) potentialQueueNames.add(match[1]);
847
+ });
848
+ });
849
+
850
+ try {
851
+ await new Promise((resolve, reject) => { stream.on('end', resolve); stream.on('error', reject); });
852
+ } catch (error) {
853
+ Sentry.captureException(error, { tags: { operation: 'startWorkersForExistingQueues' } });
854
+ return;
855
+ }
856
+
857
+ if (potentialQueueNames.size === 0) return;
858
+
859
+ const sortedConfigKeys = Array.from(jobConfigs.keys()).sort((a, b) => b.length - a.length);
860
+
861
+ await Promise.all(
862
+ Array.from(potentialQueueNames).map(async (fullQueueName) => {
863
+ let baseName = null;
864
+ let groupId = null;
865
+ let config = null;
866
+
867
+ sortedConfigKeys.some((potentialBase) => {
868
+ const baseConfig = jobConfigs.get(potentialBase);
869
+ if (fullQueueName === potentialBase && !baseConfig.group) {
870
+ baseName = potentialBase;
871
+ config = baseConfig;
872
+ return true;
873
+ }
874
+ if (baseConfig.group && fullQueueName.startsWith(\`\${potentialBase}-\`)) {
875
+ baseName = potentialBase;
876
+ groupId = fullQueueName.slice(potentialBase.length + 1);
877
+ config = baseConfig;
878
+ return true;
879
+ }
880
+ return false;
881
+ });
882
+
883
+ if (!config || activeQueues.has(fullQueueName)) return;
884
+
885
+ try {
886
+ const activeCount = await connection.llen(\`bull:\${fullQueueName}:active\`);
887
+ const waitCount = await connection.llen(\`bull:\${fullQueueName}:wait\`);
888
+ const delayedCount = await connection.zcard(\`bull:\${fullQueueName}:delayed\`);
889
+
890
+ if (waitCount > 0 || delayedCount > 0 || activeCount > 0) {
891
+ await getOrCreateQueueAndWorker(baseName, groupId, config);
892
+ }
893
+ } catch (error) {
894
+ Sentry.captureException(error, { tags: { queue: fullQueueName, operation: 'startupScanInit' } });
895
+ }
896
+ }),
897
+ );
898
+ }
899
+
900
+ async function initializeQueues(services, socketIo, bullBoardReplaceQueues) {
901
+ if (isTestEnv) { return { add: jest.fn() }; }
902
+
903
+ console.log('Queue system initializing...');
904
+
905
+ connection = createRedisConnection();
906
+ ioRef = socketIo;
907
+ bullBoardUpdateQueuesRef = bullBoardReplaceQueues;
908
+
909
+ const { loadJobs } = require('langaro-api');
910
+ const jobs = loadJobs(services);
911
+ Object.entries(jobs).forEach(([name, job]) => {
912
+ jobConfigs.set(name, {
913
+ handle: job.handle,
914
+ jobOptions: job.jobOptions || {},
915
+ workerOptions: job.workerOptions || {},
916
+ group: job.group,
917
+ });
918
+ });
919
+
920
+ await startWorkersForExistingQueues();
921
+ startCleanupInterval();
922
+
923
+ console.log('Queue system initialized.');
924
+ return { add };
925
+ }
926
+
927
+ async function shutdownQueues() {
928
+ if (isShuttingDown) return;
929
+ isShuttingDown = true;
930
+
931
+ console.log('Gracefully shutting down queues...');
932
+
933
+ if (cleanupIntervalId) {
934
+ clearInterval(cleanupIntervalId);
935
+ cleanupIntervalId = null;
936
+ }
937
+
938
+ const activeQueuesValues = Array.from(activeQueues.values());
939
+
940
+ await Promise.allSettled(activeQueuesValues.map(async (qd) => {
941
+ try { await qd.worker.pause(); } catch (e) { console.error(\`Error pausing \${qd.name}:\`, e); }
942
+ }));
943
+
944
+ let retries = 0;
945
+ const maxRetries = 400;
946
+ while (retries++ < maxRetries) {
947
+ const stillActive = await Promise.all(activeQueuesValues.map((qd) => qd.queue.getActiveCount()));
948
+ if (stillActive.reduce((a, b) => a + b, 0) === 0) break;
949
+ await sleep(3000);
950
+ }
951
+
952
+ await Promise.allSettled(activeQueuesValues.map(async (qd) => {
953
+ try {
954
+ await qd.worker.close();
955
+ await qd.queue.close();
956
+ } catch (e) {
957
+ Sentry.captureException(e, { tags: { queue: qd.name, operation: 'shutdown' } });
958
+ }
959
+ }));
960
+
961
+ activeQueues.clear();
962
+ jobConfigs.clear();
963
+
964
+ if (connection) {
965
+ try { await connection.quit(); } catch (e) {
966
+ Sentry.captureException(e, { tags: { service: 'redis', operation: 'quit' } });
967
+ }
968
+ }
969
+
970
+ console.log('Queue shutdown complete.');
971
+ }
972
+
973
+ module.exports = { initializeQueues, shutdownQueues };
974
+ `;
975
+
651
976
  const MODELS_INDEX = `const { loadModels } = require('langaro-api');
652
977
 
653
978
  module.exports = (knexInstance) => loadModels(knexInstance, __dirname);
@@ -726,12 +1051,16 @@ async function run() {
726
1051
  writeFile(root, 'jest.config.js', JEST_CONFIG);
727
1052
  writeFile(root, '.gitignore', GITIGNORE);
728
1053
  writeFile(root, '.env.example', envContent(port, dbName, redisPort));
729
- writeFile(root, '.env', envContent(port, dbName, redisPort));
1054
+ writeFile(root, '.env', envContent(port, dbName, redisPort, {
1055
+ jwtSecret: generateUUID(),
1056
+ masterPass: generateRandomString(24),
1057
+ }));
730
1058
 
731
1059
  // src/config/
732
1060
  writeFile(root, 'src/config/server.js', serverJs(port));
733
1061
  writeFile(root, 'src/config/app.js', APP_JS);
734
1062
  writeFile(root, 'src/config/instrument.js', INSTRUMENT_JS);
1063
+ writeFile(root, 'src/config/queues.js', QUEUES_JS);
735
1064
  writeFile(root, 'src/config/tests/global-setup-tests.js', GLOBAL_SETUP_TESTS);
736
1065
  writeFile(root, 'src/config/tests/setup-tests.js', SETUP_TESTS);
737
1066
 
@@ -753,6 +1082,7 @@ async function run() {
753
1082
 
754
1083
  // src/utils
755
1084
  writeFile(root, 'src/utils/index.js', UTILS_INDEX);
1085
+ writeFile(root, 'src/utils/sleep.js', UTILS_SLEEP);
756
1086
 
757
1087
  // Empty directories
758
1088
  ['src/integrations', 'src/static', 'src/temp', 'src/database/tests'].forEach((dir) => {
@@ -762,7 +1092,7 @@ async function run() {
762
1092
  console.log(`\n\x1b[32mProject scaffolded!\x1b[0m\n`);
763
1093
  console.log('Next steps:');
764
1094
  console.log(` 1. \x1b[36mnpm install\x1b[0m`);
765
- console.log(` 2. Fill in your secrets in \x1b[36m.env\x1b[0m (JWT_SECRET, DB_PASSWORD, etc.)`);
1095
+ console.log(` 2. Fill in your \x1b[36m.env\x1b[0m (DB_PASSWORD, etc.)`);
766
1096
  console.log(` 3. Create the \x1b[36m${dbName}\x1b[0m database in MySQL`);
767
1097
  console.log(` 4. \x1b[36mnpm run dev\x1b[0m`);
768
1098
  console.log(` 5. \x1b[36mnpx langaro-api new\x1b[0m to create your first resource\n`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "langaro-api",
3
- "version": "1.0.5",
3
+ "version": "1.0.6",
4
4
  "description": "Auto-generate TypeScript types, JSDoc annotations, and boilerplate loaders for knex-extended-crud projects",
5
5
  "main": "lib/index.js",
6
6
  "bin": {