From 3105f5f01cdb7e423fc61578454538b9a4e433c7 Mon Sep 17 00:00:00 2001 From: Vishakh Abhayan Date: Sat, 23 Nov 2024 19:47:44 +0530 Subject: [PATCH] feat:impliment redise session and manage cuncurrent users --- package-lock.json | 362 +++++++++++++++++++++- package.json | 10 +- src/app.js | 23 +- src/config/config.js | 24 ++ src/models/ResumeSession.js | 582 ++++++++++++++++++++---------------- src/routes/websocket.js | 143 ++++++--- src/utils/sessionStore.js | 40 +++ src/utils/test-redis.js | 26 ++ 8 files changed, 919 insertions(+), 291 deletions(-) create mode 100644 src/utils/sessionStore.js create mode 100644 src/utils/test-redis.js diff --git a/package-lock.json b/package-lock.json index 73ee525..1335177 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,14 +9,27 @@ "version": "1.0.0", "license": "ISC", "dependencies": { + "compression": "^1.7.5", "cors": "^2.8.5", "dotenv": "^16.4.5", "express": "^4.21.1", + "express-rate-limit": "^7.4.1", "groq-sdk": "^0.8.0", + "helmet": "^8.0.0", + "ioredis": "^5.4.1", + "node-cache": "^5.1.2", "nodemon": "^3.1.7", - "ws": "^8.18.0" + "util": "^0.12.5", + "uuid": "^11.0.3", + "ws": "^8.18.0", + "zustand": "^5.0.1" } }, + "node_modules/@ioredis/commands": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.2.0.tgz", + "integrity": "sha512-Sx1pU8EM64o2BrqNpEO1CNLtKQwyhuXuqyfH7oGKCk+1a33d2r5saW8zNwm3j6BTExtjrv2BxTgzzkMwts6vGg==" + }, "node_modules/@types/node": { "version": "18.19.64", "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.64.tgz", @@ -90,6 +103,20 @@ "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -198,6 +225,22 @@ "fsevents": "~2.3.2" } }, + "node_modules/clone": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz", + "integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/cluster-key-slot": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", + "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", @@ -209,6 +252,42 @@ "node": ">= 0.8" } }, + "node_modules/compressible": { + "version": "2.0.18", + "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", + "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==", + "dependencies": { + "mime-db": ">= 1.43.0 < 2" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/compression": { + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/compression/-/compression-1.7.5.tgz", + "integrity": "sha512-bQJ0YRck5ak3LgtnpKkiabX5pNF7tMUh1BSy2ZBOTh0Dim0BUu6aPPwByIns6/A5Prh8PufSPerMDUklpzes2Q==", + "dependencies": { + "bytes": "3.1.2", + "compressible": "~2.0.18", + "debug": "2.6.9", + "negotiator": "~0.6.4", + "on-headers": "~1.0.2", + "safe-buffer": "5.2.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/compression/node_modules/negotiator": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", + "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -290,6 +369,14 @@ "node": ">=0.4.0" } }, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "engines": { + "node": ">=0.10" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -412,6 +499,20 @@ "node": ">= 0.10.0" } }, + "node_modules/express-rate-limit": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.4.1.tgz", + "integrity": "sha512-KS3efpnpIDVIXopMc65EMbWbUht7qvTCdtCR2dD/IZmi9MIkopYESwyRqLgv8Pfu589+KqDqOdzJWW7AHoACeg==", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": "4 || 5 || ^5.0.0-beta.1" + } + }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -440,6 +541,14 @@ "node": ">= 0.8" } }, + "node_modules/for-each": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz", + "integrity": "sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==", + "dependencies": { + "is-callable": "^1.1.3" + } + }, "node_modules/form-data": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.1.tgz", @@ -602,6 +711,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/hasown": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", @@ -613,6 +736,14 @@ "node": ">= 0.4" } }, + "node_modules/helmet": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/helmet/-/helmet-8.0.0.tgz", + "integrity": "sha512-VyusHLEIIO5mjQPUI1wpOAEu+wl6Q0998jzTxqUYGE45xCIcAxy3MsbEK/yyJUJ3ADeMoB6MornPH6GMWAf+Pw==", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/http-errors": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", @@ -657,6 +788,50 @@ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" }, + "node_modules/ioredis": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.4.1.tgz", + "integrity": "sha512-2YZsvl7jopIa1gaePkeMtd9rAcSjOOjPtpcLlOeusyO+XH2SK5ZcT+UCrElPP+WVIInh2TzeI4XW9ENaSLVVHA==", + "dependencies": { + "@ioredis/commands": "^1.1.1", + "cluster-key-slot": "^1.1.0", + "debug": "^4.3.4", + "denque": "^2.1.0", + "lodash.defaults": "^4.2.0", + "lodash.isarguments": "^3.1.0", + "redis-errors": "^1.2.0", + "redis-parser": "^3.0.0", + "standard-as-callback": "^2.1.0" + }, + "engines": { + "node": ">=12.22.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ioredis" + } + }, + "node_modules/ioredis/node_modules/debug": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/ioredis/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -665,6 +840,21 @@ "node": ">= 0.10" } }, + "node_modules/is-arguments": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.1.1.tgz", + "integrity": "sha512-8Q7EARjzEnKpt/PCD7e1cgUS0a6X8u5tdSiMqXhojOdoV9TsMsiO+9VLC5vAmO8N7/GmXn7yjR8qnA6bVAEzfA==", + "dependencies": { + "call-bind": "^1.0.2", + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-binary-path": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", @@ -676,6 +866,17 @@ "node": ">=8" } }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -684,6 +885,20 @@ "node": ">=0.10.0" } }, + "node_modules/is-generator-function": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.0.10.tgz", + "integrity": "sha512-jsEjy9l3yiXEQ+PsXdmBwEPcOxaXWLspKdplFUVI9vq1iZgIekeC0L167qeu86czQaxed3q/Uzuw0swL0irL8A==", + "dependencies": { + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-glob": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", @@ -703,6 +918,30 @@ "node": ">=0.12.0" } }, + "node_modules/is-typed-array": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.13.tgz", + "integrity": "sha512-uZ25/bUAlUY5fR4OKT4rZQEBrzQWYV9ZJYGGsUmEJ6thodVJ1HX64ePQ6Z0qPWP+m+Uq6e9UugrE38jeYsDSMw==", + "dependencies": { + "which-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/lodash.defaults": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", + "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==" + }, + "node_modules/lodash.isarguments": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz", + "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==" + }, "node_modules/media-typer": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", @@ -781,6 +1020,17 @@ "node": ">= 0.6" } }, + "node_modules/node-cache": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/node-cache/-/node-cache-5.1.2.tgz", + "integrity": "sha512-t1QzWwnk4sjLWaQAS8CHgOJ+RAfmHpxFWmc36IWTiWHQfs0w5JDMBS1b1ZxQteo0vVVuWJvIUKHDkkeK7vIGCg==", + "dependencies": { + "clone": "2.x" + }, + "engines": { + "node": ">= 8.0.0" + } + }, "node_modules/node-domexception": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", @@ -904,6 +1154,14 @@ "node": ">= 0.8" } }, + "node_modules/on-headers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.0.2.tgz", + "integrity": "sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -928,6 +1186,14 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/possible-typed-array-names": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.0.0.tgz", + "integrity": "sha512-d7Uw+eZoloe0EHDIYoe+bQ5WXnGMOpmiZFTuMWCwpjzzkL2nTjcKiAk4hh8TjnGye2TwWOk3UXucZ+3rbmBa8Q==", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -992,6 +1258,25 @@ "node": ">=8.10.0" } }, + "node_modules/redis-errors": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", + "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==", + "engines": { + "node": ">=4" + } + }, + "node_modules/redis-parser": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz", + "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==", + "dependencies": { + "redis-errors": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -1126,6 +1411,11 @@ "node": ">=10" } }, + "node_modules/standard-as-callback": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz", + "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==" + }, "node_modules/statuses": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", @@ -1207,6 +1497,18 @@ "node": ">= 0.8" } }, + "node_modules/util": { + "version": "0.12.5", + "resolved": "https://registry.npmjs.org/util/-/util-0.12.5.tgz", + "integrity": "sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==", + "dependencies": { + "inherits": "^2.0.3", + "is-arguments": "^1.0.4", + "is-generator-function": "^1.0.7", + "is-typed-array": "^1.1.3", + "which-typed-array": "^1.1.2" + } + }, "node_modules/utils-merge": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", @@ -1215,6 +1517,18 @@ "node": ">= 0.4.0" } }, + "node_modules/uuid": { + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.0.3.tgz", + "integrity": "sha512-d0z310fCWv5dJwnX1Y/MncBAqGMKEzlBb1AOf7z9K8ALnd0utBX/msg/fA0+sbyN1ihbMsLhrBlnl1ak7Wa0rg==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", @@ -1245,6 +1559,24 @@ "webidl-conversions": "^3.0.0" } }, + "node_modules/which-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.15.tgz", + "integrity": "sha512-oV0jmFtUky6CXfkqehVvBP/LSWJ2sy4vWMioiENyJLePrBO/yKyV9OyJySfAKosh+RYkIl5zJCNZ8/4JncrpdA==", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.7", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/ws": { "version": "8.18.0", "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", @@ -1264,6 +1596,34 @@ "optional": true } } + }, + "node_modules/zustand": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.1.tgz", + "integrity": "sha512-pRET7Lao2z+n5R/HduXMio35TncTlSW68WsYBq2Lg1ASspsNGjpwLAsij3RpouyV6+kHMwwwzP0bZPD70/Jx/w==", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } } } } diff --git a/package.json b/package.json index faf2abb..fb4e77a 100644 --- a/package.json +++ b/package.json @@ -12,11 +12,19 @@ "author": "", "license": "ISC", "dependencies": { + "compression": "^1.7.5", "cors": "^2.8.5", "dotenv": "^16.4.5", "express": "^4.21.1", + "express-rate-limit": "^7.4.1", "groq-sdk": "^0.8.0", + "helmet": "^8.0.0", + "ioredis": "^5.4.1", + "node-cache": "^5.1.2", "nodemon": "^3.1.7", - "ws": "^8.18.0" + "util": "^0.12.5", + "uuid": "^11.0.3", + "ws": "^8.18.0", + "zustand": "^5.0.1" } } diff --git a/src/app.js b/src/app.js index 0dedd61..39685ac 100644 --- a/src/app.js +++ b/src/app.js @@ -4,13 +4,34 @@ const WebSocket = require("ws"); const cors = require("cors"); const config = require("./config/config"); const setupWebSocket = require("./routes/websocket"); +const helmet = require("helmet"); +const rateLimit = require("express-rate-limit"); +const compression = require("compression"); +const NodeCache = require("node-cache"); const app = express(); const server = http.createServer(app); -const wss = new WebSocket.Server({ server }); +const wss = new WebSocket.Server({ + server, + clientTracking: true, + maxPayload: 50 * 1024, +}); +const myCache = new NodeCache({ stdTTL: 100 }); app.use(cors()); app.use(express.json()); +app.use(helmet()); +app.use(compression()); +app.use( + rateLimit({ + windowMs: 15 * 60 * 1000, + max: 100, + }) +); + +process.on("uncaughtException", (error) => { + console.error("Uncaught Exception:", error); +}); app.get("/v1/status", (req, res) => { res.json({ status: "ok" }); diff --git a/src/config/config.js b/src/config/config.js index efb114d..1a3fabe 100644 --- a/src/config/config.js +++ b/src/config/config.js @@ -3,4 +3,28 @@ require("dotenv").config(); module.exports = { port: process.env.PORT || 8000, groqApiKey: process.env.GROQ_API_KEY, + redis: { + host: process.env.REDIS_HOST || "127.0.0.1", // Use IP instead of localhost + port: process.env.REDIS_PORT || 6379, + password: process.env.REDIS_PASSWORD, + retryStrategy: function (times) { + const delay = Math.min(times * 50, 2000); + return delay; + }, + maxRetriesPerRequest: 3, + enableReadyCheck: true, + reconnectOnError: function (err) { + const targetError = "READONLY"; + if (err.message.includes(targetError)) { + return true; + } + return false; + }, + }, + limits: { + maxConnections: 100, + maxConnectionsPerIP: 5, + sessionTTL: 3600, // 1 hour + messageRateLimit: 60, // messages per minute + }, }; diff --git a/src/models/ResumeSession.js b/src/models/ResumeSession.js index a38284a..0add4ad 100644 --- a/src/models/ResumeSession.js +++ b/src/models/ResumeSession.js @@ -1,6 +1,9 @@ const Groq = require("groq-sdk"); const config = require("../config/config"); +const sessionStore = require("../utils/sessionStore"); const { resumeTemplate, questionSequence } = require("../utils/templates"); +const { v4: uuidv4 } = require("uuid"); +const WebSocket = require("ws"); const groq = new Groq({ apiKey: config.groqApiKey, @@ -9,26 +12,86 @@ const groq = new Groq({ class ResumeSession { constructor(ws) { this.ws = ws; + this.sessionId = null; this.currentQuestionIndex = 0; this.resume = { ...resumeTemplate }; + this.hasStarted = false; } - async start() { - this.sendQuestion(questionSequence[0]); + async initialize(existingSessionId = null) { + if (existingSessionId) { + const existingSession = await sessionStore.getSession(existingSessionId); + + if (existingSession) { + console.log("Resuming existing session:", existingSessionId); + this.sessionId = existingSessionId; + this.currentQuestionIndex = existingSession.currentQuestionIndex || 0; + this.resume = existingSession.resume; + return true; // Return true if session was restored + } + } + + // Initialize a new session if no existing session is found + console.log("Initializing new session..."); + this.sessionId = uuidv4(); + this.currentQuestionIndex = 0; + await this.saveSession(); + return false; // Return false if new session was created + } + + async saveSession() { + await sessionStore.updateSession(this.sessionId, { + currentQuestionIndex: this.currentQuestionIndex, + resume: this.resume, + lastActive: Date.now(), + }); + } + + async start(sessionId = null) { + if (this.hasStarted) { + return; // Prevent multiple starts + } + + const sessionRestored = await this.initialize(sessionId); + + // Only send the first question if: + // 1. This is a new session (currentQuestionIndex === 0) OR + // 2. This is a restored session but we haven't completed all questions + if ( + !sessionRestored || + (this.currentQuestionIndex > 0 && + this.currentQuestionIndex < questionSequence.length) + ) { + const nextQuestion = questionSequence[this.currentQuestionIndex]; + console.log( + `Sending question ${this.currentQuestionIndex}:`, + nextQuestion + ); + this.sendQuestion(nextQuestion); + } + + this.hasStarted = true; } sendQuestion(questionData) { const message = { type: "question", - data: questionData, + data: { + ...questionData, + sessionId: this.sessionId, + }, currentResume: this.resume, }; + + // Add a small delay to prevent race conditions setTimeout(() => { - this.ws.send(JSON.stringify(message)); - }, 1000); + if (this.ws && this.ws.readyState === WebSocket.OPEN) { + this.ws.send(JSON.stringify(message)); + } + }, 100); } - updateResumeField(field, value) { + async updateResumeField(field, value) { const fieldPath = field.split("."); let current = this.resume; for (let i = 0; i < fieldPath.length - 1; i++) { @@ -38,304 +101,319 @@ class ResumeSession { current = current[fieldPath[i]]; } current[fieldPath[fieldPath.length - 1]] = value; + await this.saveSession(); } + async processExperience(answer) { const prompt = ` - Extract work experience information from this text and format it as a JSON array of experiences. - Each experience should have: title, company, period, and achievements (as an array). - Text: "${answer}" - - Respond only with the JSON array. Example format: - [ - { - "title": "Senior Developer", - "company": "Tech Corp", - "period": "2020-2023", - "achievements": ["Led team of 5 developers", "Increased performance by 40%"] - } - ]`; - - const completion = await groq.chat.completions.create({ - messages: [{ role: "assistant", content: prompt }], - model: "gemma2-9b-it", - temperature: 0.3, - max_tokens: 1024, - }); + Extract experience details from text and format as JSON array. Text must contain job title, company, period, and achievements. + Input: "${answer}" + Required format: + [ + { + "title": "Job Title", + "company": "Company Name", + "period": "YYYY-YYYY", + "achievements": ["Achievement 1", "Achievement 2"] + } + ] + + Use exact text from input when possible. Split achievements into separate array items.`; - return JSON.parse(completion.choices[0].message.content); + try { + const completion = await groq.chat.completions.create({ + messages: [ + { + role: "system", + content: + "You are a parser that converts job experience text into structured JSON. Only output valid JSON.", + }, + { + role: "user", + content: prompt, + }, + ], + model: "gemma2-9b-it", + temperature: 0.1, + max_tokens: 1024, + }); + + const result = completion.choices[0].message.content; + // Validate JSON structure + const parsed = JSON.parse(result); + if ( + !Array.isArray(parsed) || + !parsed[0]?.title || + !parsed[0]?.company || + !parsed[0]?.period || + !Array.isArray(parsed[0]?.achievements) + ) { + throw new Error("Invalid experience format"); + } + return parsed; + } catch (error) { + throw new Error( + "Please provide your role, company name, work period, and key achievements." + ); + } } async processEducation(answer) { const prompt = ` - Extract education information from this text and format it as a JSON array. - Each education entry should have: degree, school, and period. - Text: "${answer}" - - Respond only with the JSON array. Example format: - [ - { - "degree": "Bachelor of Science in Computer Science", - "school": "University of Technology", - "period": "2016-2020" - } - ]`; + Convert education text to JSON array, fixing spelling and formatting: + Input: "${answer}" + Required output format: + [ + { + "degree": "Name of Degree in Computer Science/IT", + "school": "Full School Name", + "period": "YYYY-YYYY" + } + ] + Rules: + - Fix common typos (e.g. "ploytchnic" -> "Polytechnic") + - Format school name properly + - Use standard degree names (e.g. "Computer Science" not "coputerscince") + - Extract or infer period if given`; - const completion = await groq.chat.completions.create({ - messages: [{ role: "assistant", content: prompt }], - model: "gemma2-9b-it", - temperature: 0.3, - max_tokens: 1024, - }); + try { + const completion = await groq.chat.completions.create({ + messages: [ + { + role: "system", + content: + "You are a parser that converts education details into structured JSON, fixing any typos or formatting issues.", + }, + { + role: "user", + content: prompt, + }, + ], + model: "gemma2-9b-it", + temperature: 0.1, + max_tokens: 1024, + }); + + const result = completion.choices[0].message.content; + const parsed = JSON.parse(result); + + // Validate structure + if ( + !Array.isArray(parsed) || + !parsed[0]?.degree || + !parsed[0]?.school || + !parsed[0]?.period + ) { + throw new Error("Invalid education format"); + } - return JSON.parse(completion.choices[0].message.content); + return parsed; + } catch (error) { + throw new Error( + "Please provide your school, degree and study period in a clear format." + ); + } } async processSkills(answer) { const prompt = ` - Extract professional skills from this text and return them as a JSON array of strings. - Text: "${answer}" - - Respond only with the JSON array. Example: - ["JavaScript", "React", "Node.js", "Project Management"]`; - - const completion = await groq.chat.completions.create({ - messages: [{ role: "assistant", content: prompt }], - model: "gemma2-9b-it", - temperature: 0.3, - max_tokens: 1024, - }); + Convert text to JSON array of professional skills: + Input: "${answer}" + Rules: + - Extract only technical/professional skills + - Return as JSON array of strings + - Normalize skill names + - Limit to most relevant skills + Format: ["Skill1", "Skill2", ...]`; - return JSON.parse(completion.choices[0].message.content); + try { + const completion = await groq.chat.completions.create({ + messages: [{ role: "assistant", content: prompt }], + model: "gemma2-9b-it", + temperature: 0.3, + max_tokens: 1024, + }); + + return JSON.parse(completion.choices[0].message.content); + } catch (error) { + throw new Error("Please list your key professional skills."); + } } async processCertifications(answer) { const prompt = ` - Extract certifications from this text and return them as a JSON array of strings. - If no certifications are mentioned, return an empty array. - Text: "${answer}" - - Respond only with the JSON array. Example: - ["AWS Certified Solutions Architect", "PMP Certification"]`; - - const completion = await groq.chat.completions.create({ - messages: [{ role: "assistant", content: prompt }], - model: "gemma2-9b-it", - temperature: 0.3, - max_tokens: 1024, - }); + Convert certification information to JSON array: + Input: "${answer}" + Rules: + - Extract only formal certifications + - Return as JSON array of strings + - Include certification provider if mentioned + - Return empty array if no certifications found + Format: ["Certification1", "Certification2", ...]`; - return JSON.parse(completion.choices[0].message.content); - } - async processPersonalInfo(field, answer) { - let prompt; - switch (field) { - case "personalInfo.name": - prompt = `Extract only the person's name from this text. If no name is found, respond with "NO_NAME_FOUND". - only extract the name, not the rest of the text - respond with the name only in camel case - no double quotes and no json - Example: "hi my name is John Doe" -> "John Doe" - Text: "${answer}"`; - break; - // case "personalInfo.email": - // prompt = `Extract only the email address from this text. If no email is found, respond with "NO_EMAIL_FOUND". - // Example: "my email is john@example.com" -> "john@example.com" - // if text is directly an email address, respond with that address - // Text: "${answer}"`; - // break; - // case "personalInfo.phone": - // prompt = `Extract only the phone number from this text. If no phone number is found, respond with "NO_PHONE_FOUND". - // Example: "my number is 1234567890" -> "1234567890" - // if text is directly a phone number, respond with that number - // Text: "${answer}"`; - // break; - case "personalInfo.title": - prompt = `Extract only the job title from this text. Fix any typos. If no title is found, respond with "NO_TITLE_FOUND". - Example: "I work as a Senior Software Engineer" -> "Senior Software Engineer" - only extract the title, not the rest of the text - if text is directly a job title, respond with that only title in camel case - no double quotes and no json - Text: "${answer}"`; - - break; + try { + const completion = await groq.chat.completions.create({ + messages: [{ role: "assistant", content: prompt }], + model: "gemma2-9b-it", + temperature: 0.3, + max_tokens: 1024, + }); + + return JSON.parse(completion.choices[0].message.content); + } catch (error) { + return []; // Return empty array if no certifications } + } - const completion = await groq.chat.completions.create({ - messages: [{ role: "assistant", content: prompt }], - model: "gemma2-9b-it", - max_tokens: 100, - }); - - const result = completion.choices[0].message.content.trim(); + async processPersonalInfo(field, answer) { + const prompts = { + "personalInfo.name": ` + Extract full name from: "${answer}" + Rules: + - Return only the name without any prefixes + - Return as plain text, not JSON + - If no name found, return "Unknown"`, + + "personalInfo.title": ` + Extract job title from: "${answer}" + Rules: + - Return single professional title + - Return as plain text without any prefixes + - If no title found, return "Professional"`, + }; - // Handle cases where no valid data was found - if (result.includes("NO_") && result.includes("_FOUND")) { + try { + const completion = await groq.chat.completions.create({ + messages: [{ role: "assistant", content: prompts[field] }], + model: "gemma2-9b-it", + temperature: 0.1, + max_tokens: 100, + }); + + const result = completion.choices[0].message.content + .trim() + .replace(/^(Answer:|Solution:)\s*/i, ""); // Remove "Answer:" prefix + + if (result === "Unknown" || result === "Professional") { + throw new Error(`Please provide your ${field.split(".")[1]}.`); + } + return result; + } catch (error) { throw new Error( - `Could not find valid ${ - field.split(".")[1] - } in your response. Please try again.` + `Could not process ${field.split(".")[1]}. Please try again.` ); } - - return result; } async processSummary(answer) { - if (answer.toLowerCase() === "hello" || answer.length < 10) { - throw new Error( - "Please provide more details about your professional background so I can create a meaningful summary." - ); - } - const prompt = ` - Generate a concise, professional summary emphasizing the candidate's experience, skills, and career goals. - Start the response directly with the summary content without any introductory phrases or additional explanations. - Limit the summary to 3 sentences. - Text: "${answer}" - `; - const completion = await groq.chat.completions.create({ - messages: [{ role: "assistant", content: prompt }], - model: "gemma2-9b-it", - max_tokens: 100, - temperature: 0.3, - }); + Create professional summary from: + "${answer}" + Rules: + - 2-3 sentences maximum + - Include years of experience if mentioned + - Focus on key skills and achievements + - Make it achievement-oriented + - Return as plain text, not JSON`; - const result = completion.choices[0].message.content.trim(); - - if (result === "NO_SUMMARY_FOUND") { - throw new Error( - "Please provide more details about your professional background so I can create a meaningful summary." - ); + try { + const completion = await groq.chat.completions.create({ + messages: [{ role: "assistant", content: prompt }], + model: "gemma2-9b-it", + temperature: 0.3, + max_tokens: 200, + }); + + const result = completion.choices[0].message.content.trim(); + if (result.length < 50) { + throw new Error( + "Please provide more details about your professional background." + ); + } + return result; + } catch (error) { + throw new Error("Please provide a more detailed professional summary."); } + } - return result; + async processField(field, answer) { + switch (field) { + case "experience": + return await this.processExperience(answer); + case "education": + return await this.processEducation(answer); + case "skills": + return await this.processSkills(answer); + case "certifications": + return await this.processCertifications(answer); + case "personalInfo.name": + case "personalInfo.title": + return await this.processPersonalInfo(field, answer); + case "summary": + return await this.processSummary(answer); + default: + return answer; + } } - async processAnswer(answer) { - const currentQuestion = questionSequence[this.currentQuestionIndex]; - console.log("Processing answer for question:", currentQuestion.id); - console.log("Answer received:", answer); + async processAnswer(answer) { try { - let processedValue; - - // Add input validation - if (!answer || answer.trim().length === 0) { - throw new Error("Please provide a response."); - } - - // Process the answer based on the field type - try { - switch (currentQuestion.field) { - case "experience": - processedValue = await this.processExperience(answer); - break; - case "education": - processedValue = await this.processEducation(answer); - break; - case "skills": - processedValue = await this.processSkills(answer); - break; - case "certifications": - processedValue = await this.processCertifications(answer); - break; - case "summary": - processedValue = await this.processSummary(answer); - break; - default: - if (currentQuestion.field.startsWith("personalInfo.")) { - processedValue = await this.processPersonalInfo( - currentQuestion.field, - answer - ); - } - } + const currentQuestion = questionSequence[this.currentQuestionIndex]; + let processedValue = await this.processField( + currentQuestion.field, + answer + ); + await this.updateResumeField(currentQuestion.field, processedValue); - // Validate processed value - if (!processedValue) { - throw new Error( - "Could not process your response. Please try again with more details." - ); - } + this.ws.send( + JSON.stringify({ + type: "update", + data: { + field: currentQuestion.field, + value: processedValue, + currentResume: this.resume, + sessionId: this.sessionId, + }, + }) + ); - // Update resume with processed value - this.updateResumeField(currentQuestion.field, processedValue); - - // Send update to client - setTimeout(() => { - this.ws.send( - JSON.stringify({ - type: "update", - data: { - field: currentQuestion.field, - value: processedValue, - currentResume: this.resume, - }, - }) - ); - }, 2000); - - // If it's the first question and we got a NO_NAME_FOUND, ask again - if ( - currentQuestion.id === "name" && - processedValue === "NO_NAME_FOUND" - ) { - this.ws.send( - JSON.stringify({ - type: "question", - data: { - ...currentQuestion, - question: - "I didn't catch your name. Could you please tell me your full name?", - }, - }) - ); - return; - } + this.currentQuestionIndex++; + await this.saveSession(); - // Move to next question - this.currentQuestionIndex++; - if (this.currentQuestionIndex < questionSequence.length) { - this.sendQuestion(questionSequence[this.currentQuestionIndex]); - } else { - // Resume is complete - console.log("Resume complete:", this.resume); - this.ws.send( - JSON.stringify({ - type: "complete", - data: { - message: - "Great! Your resume is complete. Feel free to review and make any adjustments needed.", - finalResume: this.resume, - }, - }) - ); - } - } catch (processingError) { - // Send error message and ask the same question again + if (this.currentQuestionIndex < questionSequence.length) { + this.sendQuestion(questionSequence[this.currentQuestionIndex]); + } else { this.ws.send( JSON.stringify({ - type: "error", + type: "complete", data: { - message: processingError.message, + message: "Your resume is complete", + finalResume: this.resume, + sessionId: this.sessionId, }, }) ); - // Ask the same question again - this.sendQuestion(currentQuestion); } } catch (error) { - console.error("Error in processAnswer:", error); + this.handleError(error); + } + } + + handleError(error) { + console.error("Error during session:", error); + if (this.ws && this.ws.readyState === WebSocket.OPEN) { this.ws.send( JSON.stringify({ type: "error", - data: { - message: - error.message || "Sorry, something went wrong. Please try again.", - }, + data: { message: error.message || "An error occurred" }, }) ); - // Ask the same question again - this.sendQuestion(currentQuestion); + } + } + + async cleanup() { + if (this.sessionId) { + await sessionStore.deleteSession(this.sessionId); } } } diff --git a/src/routes/websocket.js b/src/routes/websocket.js index 46f8fb9..59b38d9 100644 --- a/src/routes/websocket.js +++ b/src/routes/websocket.js @@ -1,46 +1,117 @@ const ResumeSession = require("../models/ResumeSession"); +const sessionStore = require("../utils/sessionStore"); + +// Track active connections +const activeConnections = new Map(); function setupWebSocket(wss) { - wss.on("connection", (ws) => { - console.log("New client connected"); - const session = new ResumeSession(ws); - session.start(); - - ws.on("message", async (message) => { - try { - const data = JSON.parse(message.toString()); - - switch (data.type) { - case "answer": - await session.processAnswer(data.content); - break; - case "restart": - session.currentQuestionIndex = 0; - session.resume = { ...resumeTemplate }; - session.start(); - break; - default: - ws.send( - JSON.stringify({ - type: "error", - data: { message: "Unknown message type" }, - }) - ); + // Track active connections + const activeConnections = new Map(); + const connectionsPerIP = new Map(); + const maxConnections = 100; + + // Cleanup function + const cleanup = async (ws, ip) => { + const session = activeConnections.get(ws); + if (session) { + await session.cleanup(); + } + activeConnections.delete(ws); + const currentCount = connectionsPerIP.get(ip) || 0; + if (currentCount > 0) { + connectionsPerIP.set(ip, currentCount - 1); + } + }; + + wss.on("connection", async (ws, req) => { + const ip = req.headers["x-forwarded-for"] || req.socket.remoteAddress; + + // Check connection limits + if (wss.clients.size >= maxConnections) { + ws.close(1013, "Server is at capacity"); + return; + } + + const ipConnections = connectionsPerIP.get(ip) || 0; + if (ipConnections >= 5) { + ws.close(1013, "Too many connections from this IP"); + return; + } + + // Update connection tracking + connectionsPerIP.set(ip, ipConnections + 1); + + try { + const url = new URL(req.url, `http://${req.headers.host}`); + const sessionId = url.searchParams.get("sessionId"); + + const session = new ResumeSession(ws); + activeConnections.set(ws, session); + + // Set up WebSocket event handlers + ws.isAlive = true; + ws.on("pong", () => { + ws.isAlive = true; + }); + + ws.on("message", async (message) => { + try { + const data = JSON.parse(message.toString()); + + switch (data.type) { + case "answer": + await session.processAnswer(data.content); + break; + case "restart": + await session.cleanup(); + await session.start(); + break; + default: + ws.send( + JSON.stringify({ + type: "error", + data: { message: "Unknown message type" }, + }) + ); + } + } catch (error) { + console.error("Message processing error:", error); + ws.send( + JSON.stringify({ + type: "error", + data: { message: "Failed to process message" }, + }) + ); } - } catch (error) { - console.error("Error processing message:", error); - ws.send( - JSON.stringify({ - type: "error", - data: { message: "Sorry, something went wrong. Please try again." }, - }) - ); + }); + + ws.on("close", () => cleanup(ws, ip)); + ws.on("error", () => cleanup(ws, ip)); + + // Start the session + await session.start(sessionId); + } catch (error) { + console.error("Connection setup error:", error); + cleanup(ws, ip); + ws.close(1011, "Failed to setup session"); + } + }); + + // Heartbeat interval + const interval = setInterval(() => { + wss.clients.forEach((ws) => { + if (!ws.isAlive) { + cleanup(ws, ws._socket?.remoteAddress); + return ws.terminate(); } + ws.isAlive = false; + ws.ping(); }); + }, 30000); - ws.on("close", () => { - console.log("Client disconnected"); - }); + // Cleanup interval on server shutdown + wss.on("close", () => { + clearInterval(interval); }); } diff --git a/src/utils/sessionStore.js b/src/utils/sessionStore.js new file mode 100644 index 0000000..766b79a --- /dev/null +++ b/src/utils/sessionStore.js @@ -0,0 +1,40 @@ +const Redis = require("ioredis"); +const config = require("../config/config"); + +class SessionStore { + constructor() { + this.redis = new Redis({ + host: config.redis.host, + port: config.redis.port, + password: config.redis.password, + maxRetriesPerRequest: 3, + }); + } + + async createSession(sessionId, data) { + await this.redis.setex( + `resume_session:${sessionId}`, + 3600, // 1 hour expiry + JSON.stringify(data) + ); + } + + async getSession(sessionId) { + const data = await this.redis.get(`resume_session:${sessionId}`); + return data ? JSON.parse(data) : null; + } + + async updateSession(sessionId, data) { + await this.redis.setex( + `resume_session:${sessionId}`, + 3600, + JSON.stringify(data) + ); + } + + async deleteSession(sessionId) { + await this.redis.del(`resume_session:${sessionId}`); + } +} + +module.exports = new SessionStore(); diff --git a/src/utils/test-redis.js b/src/utils/test-redis.js new file mode 100644 index 0000000..4a2dcca --- /dev/null +++ b/src/utils/test-redis.js @@ -0,0 +1,26 @@ +const sessionStore = require("./sessionStore"); + +async function testRedisConnection() { + try { + // Test creating a session + await sessionStore.createSession("test-session", { + data: "test data", + }); + console.log("Successfully created test session"); + + // Test retrieving the session + const session = await sessionStore.getSession("test-session"); + console.log("Retrieved session:", session); + + // Test deleting the session + await sessionStore.deleteSession("test-session"); + console.log("Successfully deleted test session"); + + process.exit(0); + } catch (error) { + console.error("Redis test failed:", error); + process.exit(1); + } +} + +testRedisConnection();