├── .babelrc ├── .env ├── .gitignore ├── LICENSE ├── README.md ├── dist ├── bundle.js ├── bundle.js.LICENSE.txt └── index.html ├── index.js └── package.json /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "@babel/preset-env", 4 | "@babel/preset-react" 5 | ] 6 | } -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | NODE_ENV = 'development' 2 | PORT = 4000 -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | package-lock.json 3 | .npm 4 | .DS-Store 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 OSLabs Beta 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # QLutch 2 | 3 | ![alt text](https://github.com/lrod8/Qlutch/blob/main/assets/white-base.svg?raw=true) 4 | 5 | 6 | 7 | A lightweight caching solution for graphQL APIs that interfaces with Redis for high-speed data retrieval, combined with performance visualization. 8 | 9 | ![JavaScript](https://img.shields.io/badge/javascript-%23323330.svg?style=for-the-badge&logo=javascript&logoColor=%23F7DF1E) 10 | ![NodeJS](https://img.shields.io/badge/node.js-6DA55F?style=for-the-badge&logo=node.js&logoColor=white) 11 | ![Express.js](https://img.shields.io/badge/express.js-%23404d59.svg?style=for-the-badge&logo=express&logoColor=%2361DAFB) 12 | ![TypeScript](https://img.shields.io/badge/TypeScript-007ACC?style=for-the-badge&logo=typescript&logoColor=white) 13 | ![Jest](https://img.shields.io/badge/-jest-%23C21325?style=for-the-badge&logo=jest&logoColor=white) 14 | ![Redis](https://img.shields.io/badge/redis-%23DD0031.svg?&style=for-the-badge&logo=redis&logoColor=white) 15 | ![GraphQL](https://img.shields.io/badge/-GraphQL-E10098?style=for-the-badge&logo=graphql&logoColor=white) 16 | ![React](https://img.shields.io/badge/React-20232A?style=for-the-badge&logo=react&logoColor=61DAFB) 17 | ![MongoDB](https://img.shields.io/badge/MongoDB-%234ea94b.svg?style=for-the-badge&logo=mongodb&logoColor=white) 18 | ____ 19 | # Features 20 | - Redis cache integration for graphQL queries and *Create* mutations. 21 | - Performance monitor. 22 | 23 | ![alt text](https://github.com/lrod8/Qlutch/blob/main/assets/data_flow_readme.png?raw=true) 24 | 25 | ## Dashboard Visualizer 26 | ![alt text](https://github.com/lrod8/Qlutch/blob/main/assets/demo.png?raw=true) 27 | 28 | # Usage Notes 29 | - Caching support for Update and Delete mutations is not yet implemented. 30 | 31 | # Installation 32 | - User creates application and installs qlutch dependency via [npm](https://www.npmjs.com/package/qlutch) (npm install qlutch) 33 | - Set up Redis database in application 34 | - Require qlutch and Redis in server file 35 | - For the dashboard visualizer, add express static path to node modules: 36 | ![alt text](https://github.com/lrod8/Qlutch/blob/main/assets/expressStatic.png?raw=true) 37 | - For the dashboard visualizer, add a dashboard endpoint with a path to the qlutch dist index file: 38 | ![alt text](https://github.com/lrod8/Qlutch/blob/main/assets/dashboard.png?raw=true) 39 | - Need two endpoints – one for qlutch and one for graphql. Install qlutch as middleware in qlutch endpoint – pass in “graphql” endpoint and redis instance as arguments. User would need to return res.locals.response: 40 | ![alt text](https://github.com/lrod8/Qlutch/blob/main/assets/endPoints.png?raw=true) 41 | - Fetch requests on frontend will need to be made to /qlutch endpoint 42 | 43 | # Authors 44 | - [@Michael-Weckop](https://github.com/Michael-Weckop) 45 | - [@lrod8](https://github.com/lrod8) 46 | - [@alroro](https://github.com/alroro) 47 | - [@Reneeto](https://github.com/Reneeto) 48 | # Acknowledgements 49 | - [Charlie Charboneau](https://github.com/CharlieCharboneau) 50 | - [Annie Blazejack](https://github.com/annieblazejack) 51 | - [Matt Severyn](https://github.com/mtseveryn) 52 | - [Erika Collins Reynolds](https://github.com/erikacollinsreynolds) 53 | - [Sam Arnold](https://github.com/sam-a723) 54 | -------------------------------------------------------------------------------- /dist/bundle.js.LICENSE.txt: -------------------------------------------------------------------------------- 1 | /*! 2 | * ApexCharts v3.42.0 3 | * (c) 2018-2023 ApexCharts 4 | * Released under the MIT License. 5 | */ 6 | 7 | /*! 8 | * bytes 9 | * Copyright(c) 2012-2014 TJ Holowaychuk 10 | * Copyright(c) 2015 Jed Watson 11 | * MIT Licensed 12 | */ 13 | 14 | /*! svg.draggable.js - v2.2.2 - 2019-01-08 15 | * https://github.com/svgdotjs/svg.draggable.js 16 | * Copyright (c) 2019 Wout Fierens; Licensed MIT */ 17 | 18 | /*! svg.filter.js - v2.0.2 - 2016-02-24 19 | * https://github.com/wout/svg.filter.js 20 | * Copyright (c) 2016 Wout Fierens; Licensed MIT */ 21 | 22 | /** 23 | * @license React 24 | * react-dom.production.min.js 25 | * 26 | * Copyright (c) Facebook, Inc. and its affiliates. 27 | * 28 | * This source code is licensed under the MIT license found in the 29 | * LICENSE file in the root directory of this source tree. 30 | */ 31 | 32 | /** 33 | * @license React 34 | * react.production.min.js 35 | * 36 | * Copyright (c) Facebook, Inc. and its affiliates. 37 | * 38 | * This source code is licensed under the MIT license found in the 39 | * LICENSE file in the root directory of this source tree. 40 | */ 41 | 42 | /** 43 | * @license React 44 | * scheduler.production.min.js 45 | * 46 | * Copyright (c) Facebook, Inc. and its affiliates. 47 | * 48 | * This source code is licensed under the MIT license found in the 49 | * LICENSE file in the root directory of this source tree. 50 | */ 51 | -------------------------------------------------------------------------------- /dist/index.html: -------------------------------------------------------------------------------- 1 | Qlutch
-------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | const { request, gql } = require("graphql-request"); 2 | const { visit } = require("graphql"); 3 | const { parse } = require("graphql/language"); 4 | 5 | module.exports = function (graphQlPath, redis) { 6 | return async function (req, res, next) { 7 | try { 8 | //parse query from frontend 9 | const parsedQuery = parse(req.body.query); 10 | 11 | /*USE INTROSPECTION TO IDENTIFY SCHEMA TYPES*/ 12 | 13 | // array to store all query types 14 | const typesArr = []; 15 | 16 | // excluded typenames that are automatically returned by introspection 17 | const excludedTypeNames = [ 18 | "Query", 19 | "String", 20 | "Int", 21 | "Boolean", 22 | "__Schema", 23 | "__Type", 24 | "__TypeKind", 25 | "__Field", 26 | "__EnumValue", 27 | "__Directive", 28 | "__DirectiveLocation", 29 | ]; 30 | 31 | //using introspection query to find all types in schema 32 | let schemaTypes = await request( 33 | `${graphQlPath}`, 34 | `{ 35 | __schema{ 36 | types{ 37 | name 38 | fields { 39 | name 40 | type{ 41 | name 42 | ofType { 43 | name 44 | } 45 | } 46 | } 47 | } 48 | } 49 | }` 50 | ); 51 | 52 | // parsing through types with args and storing them in typesArr 53 | schemaTypes.__schema.types.forEach((type) => { 54 | if (type.name === "Query" || type.name === "Mutation") { 55 | type.fields.forEach((type) => { 56 | typesArr.push(type.name); 57 | }); 58 | } 59 | }); 60 | 61 | // parsing through schema types to idenitfy parent types of each field 62 | schemaTypes.__schema.types.forEach((type) => { 63 | // check if current type name is inside excluded typeArr 64 | if (!excludedTypeNames.includes(type.name)) { 65 | // if not in typeArr iterate through current field 66 | if (type.fields) { 67 | type.fields.forEach((field) => { 68 | // if ofType field is truthy && it's a string && it's included in typeArr 69 | if ( 70 | field.type.ofType && 71 | typeof field.type.ofType.name === "string" && 72 | typesArr.includes(field.type.ofType.name.toLowerCase()) 73 | ) { 74 | typesArr.push(field.name); 75 | } 76 | }); 77 | } 78 | } 79 | }); 80 | 81 | /*FIND TYPES IN CURRENT QUERY/MUTATION*/ 82 | 83 | //checks parsedQuery for types used in query 84 | const findAllTypes = (query, types) => { 85 | const valuesObj = {}; 86 | 87 | //helper function to traverse deeply nested query 88 | const traverseParsedQuery = (currentObj, visited) => { 89 | visited.add(currentObj); 90 | 91 | for (let key in currentObj) { 92 | const value = currentObj[key]; 93 | //this is where the actual query types are getting stored in valuesObj 94 | if (types.includes(value)) { 95 | valuesObj[value] = value; 96 | } 97 | //if current value is an array, iterates through array to find more objects to traverse, or recursively calls traverseParsedQuery if an object is found 98 | if (Array.isArray(value)) { 99 | value.forEach((el) => { 100 | if (typeof el === "object" && !visited.has(el)) { 101 | traverseParsedQuery(el, visited); 102 | } 103 | }); 104 | } else if (typeof value === "object" && !visited.has(value)) { 105 | traverseParsedQuery(value, visited); 106 | } 107 | } 108 | }; 109 | 110 | traverseParsedQuery(query, new Set()); 111 | return valuesObj; 112 | }; 113 | 114 | //returns object with all types found in query 115 | const valuesObj = findAllTypes(parsedQuery, typesArr); 116 | 117 | //the actual query types being used in the query 118 | const valuesArr = Object.values(valuesObj); 119 | 120 | // var to store the parent type for mutations 121 | let mutationQueryType; 122 | 123 | //finds query type associated with mutation 124 | const findMutationQueryType = (introspection, parsedQuery) => { 125 | const types = introspection.__schema.types; 126 | 127 | types.forEach((type) => { 128 | if (type.name === "Mutation") { 129 | const mutations = type.fields; 130 | mutations.forEach((mutation) => { 131 | if (valuesArr.includes(mutation.name)) { 132 | mutationQueryType = mutation.type.name.toLowerCase(); 133 | } 134 | }); 135 | } 136 | }); 137 | return mutationQueryType; 138 | }; 139 | 140 | /* VISITOR FUNCTION */ 141 | 142 | // var to store current operation 143 | let operation = ""; 144 | 145 | // var to store root field including args 146 | let rootField; 147 | 148 | // create a var to store an object of arrays 149 | const keysToCache = []; 150 | 151 | // var to store id of current query/mutation 152 | let id; 153 | 154 | // visitor object for arguments is called from field method 155 | const argVisitor = { 156 | Argument: (node) => { 157 | if (node.value.kind === "StringValue") { 158 | return `(${node.name.value}:"${node.value.value}")`; 159 | } else return `(${node.name.value}:${node.value.value})`; 160 | }, 161 | }; 162 | 163 | //var to store parent field in current mutation 164 | let mutationRootField; 165 | 166 | //similar to keysToCache, but instead of the query type, it includes the mutation type 167 | const mutationForGQLResponse = []; 168 | 169 | // main visitor object that builds out rootfield and mutationRootField array 170 | const visitor = { 171 | // return current operation 172 | OperationDefinition: (node) => { 173 | operation = node.operation; 174 | }, 175 | // returns each query field 176 | Field: (node) => { 177 | // create a var to store an current field name 178 | const currentField = node.name.value; 179 | 180 | // check if field is in typesArr 181 | if (valuesArr.includes(currentField)) { 182 | if (operation === "mutation" && !rootField) { 183 | rootField = findMutationQueryType(schemaTypes, parsedQuery); 184 | mutationRootField = currentField; 185 | 186 | //sends arguments node to visitor function 187 | const args = visit(node, argVisitor); 188 | const arguments = args.arguments.map((arg) => { 189 | if (arg.includes("id:")) id = arg; 190 | return arg; 191 | }); 192 | 193 | //concats id to rootField and mutationRootField - this helps normalize cache data 194 | rootField = rootField.concat(id); 195 | mutationRootField = mutationRootField.concat(id); 196 | } else if (currentField === valuesArr[0]) { 197 | // reassign root var with root field of first element in typesArr with arguments from visiotr function if any 198 | rootField = currentField; 199 | // check if there are args on current node and if so call argument visitor method 200 | const args = visit(node, argVisitor); 201 | // add to main root 202 | const arguments = args.arguments.map((arg) => { 203 | if (arg.includes("id:")) id = arg; 204 | return arg; 205 | }); 206 | rootField = rootField.concat(id); 207 | } else { 208 | // else re-assign currentType to current type 209 | let currentType = node.name.value; 210 | if (operation === "mutation") { 211 | mutationRootField = mutationRootField.concat(`{${currentType}`); 212 | } 213 | rootField = rootField.concat(`{${currentType}`); 214 | } 215 | } else { 216 | // else add each field to root value and build out object 217 | if (operation === "mutation") { 218 | mutationForGQLResponse.push( 219 | mutationRootField.concat(`{${node.name.value}}`) 220 | ); 221 | } 222 | keysToCache.push(rootField.concat(`{${node.name.value}}`)); 223 | } 224 | }, 225 | }; 226 | 227 | //invokes initial visit function 228 | visit(parsedQuery, visitor); 229 | 230 | /* GETS RESPONSE DATA FROM CACHE OR GQL AND BUILDS OUT RESPONSE TO SEND TO FRONT END */ 231 | 232 | //combines data found in cache and data requested from database - called in GQLResponse 233 | function combineCacheAndResponseData(...objects) { 234 | return objects.reduce((merged, obj) => { 235 | for (const key in obj) { 236 | if (obj.hasOwnProperty(key)) { 237 | if (typeof obj[key] === "object" && !Array.isArray(obj[key])) { 238 | // if the property is an object, recursively merge it 239 | merged[key] = combineCacheAndResponseData( 240 | merged[key] || {}, 241 | obj[key] 242 | ); 243 | } else if (Array.isArray(obj[key])) { 244 | // if the property is an array, handle each element 245 | if (!merged[key]) { 246 | merged[key] = []; 247 | } 248 | obj[key].forEach((el, index) => { 249 | //check if the element is an object and merge it if needed 250 | if (typeof el === "object" && !Array.isArray(el)) { 251 | merged[key][index] = combineCacheAndResponseData( 252 | merged[key][index] || {}, 253 | el 254 | ); 255 | } else { 256 | merged[key][index] = el; 257 | } 258 | }); 259 | } else { 260 | // otherwise, assign the value 261 | merged[key] = obj[key]; 262 | } 263 | } 264 | } 265 | return merged; 266 | }, {}); 267 | } 268 | 269 | //check redis if key is stored and return value - called in createResponse 270 | async function checkCache(key) { 271 | try { 272 | const cachedData = JSON.parse(await redis.get(key)); 273 | return cachedData; 274 | } catch (err) { 275 | const errObj = { 276 | log: "error in checking cache", 277 | status: 400, 278 | message: "Invalid request", 279 | }; 280 | return next(err, errObj); 281 | } 282 | } 283 | 284 | //checks if data is in cache or if it needs to be requested in gql 285 | async function createResponse() { 286 | try { 287 | //array to store non-cached keys that need to be sent to gql to request response - used in createResponse and GQLResponse 288 | const keysToRequestArr = []; 289 | 290 | // array to store cached keys - used in createResponse and GQLResponse 291 | const responseToMergeArr = []; 292 | 293 | //checks cache to see if data exists already in cache 294 | const checkDataIsCachedArr = keysToCache.map((key) => 295 | checkCache(key) 296 | ); 297 | 298 | //array with whatever data is found in the cache 299 | const response = await Promise.all(checkDataIsCachedArr); 300 | 301 | // iterates through response array and checks for values to be push to responseToMerge or keyToRequest 302 | for (let i = 0; i < response.length; i++) { 303 | if (response[i] === null) { 304 | keysToRequestArr.push(keysToCache[i]); 305 | } else { 306 | responseToMergeArr.push(response[i]); 307 | } 308 | } 309 | 310 | // getResponseAndCache writes a query and requests each field from gql, then caches responses - called in GQLResponse 311 | async function getResponseAndCache(key) { 312 | try { 313 | // create graphql query 314 | let parsedGraphQLQuery; 315 | 316 | if (operation === "mutation") { 317 | parsedGraphQLQuery = `mutation {`; 318 | } else { 319 | parsedGraphQLQuery = `query {`; 320 | } 321 | 322 | // creates a new gql object 323 | let curlyBracesCount = 0; 324 | 325 | key.split("").forEach((char) => { 326 | if (char === "{") curlyBracesCount++; 327 | 328 | parsedGraphQLQuery = parsedGraphQLQuery.concat(char); 329 | }); 330 | 331 | parsedGraphQLQuery = parsedGraphQLQuery.concat( 332 | "}".repeat(curlyBracesCount) 333 | ); 334 | 335 | // request new query to graphQL 336 | let document; 337 | 338 | if (operation === "mutation") { 339 | document = gql` 340 | ${req.body.query} 341 | `; 342 | } else { 343 | document = gql` 344 | ${parsedGraphQLQuery} 345 | `; 346 | } 347 | 348 | //requests, caches and returns response 349 | let gqlResponse = await request(`${graphQlPath}`, document); 350 | redis.set(key, JSON.stringify(gqlResponse)); 351 | return gqlResponse; 352 | } catch (err) { 353 | const errObj = { 354 | log: "error in getResponse", 355 | status: 400, 356 | message: "error in getResponse", 357 | }; 358 | return next(err, errObj); 359 | } 360 | } 361 | 362 | //ensures we cache nested mutation data 363 | let arrayInMutation = null; 364 | 365 | //iterates through mutation data from gql and builds out objects to add to cache - called in GQLResponse 366 | async function cacheMutations(keysToCache, response) { 367 | try { 368 | for (const key in response) { 369 | if ( 370 | typeof response[key] === "object" && 371 | !Array.isArray(response[key]) 372 | ) { 373 | cacheMutations(keysToCache, response[key]); 374 | } else if (Array.isArray(response[key])) { 375 | arrayInMutation = key; 376 | response[key].forEach((el) => { 377 | if (typeof el === "object" && !Array.isArray(el)) { 378 | cacheMutations(keysToCache, response[key]); 379 | } 380 | }); 381 | } else { 382 | for (let i = 0; i < keysToCache.length; i++) { 383 | if (keysToCache[i].includes(key)) { 384 | let mutationResponse; 385 | //builds out properly formatted response object to cache 386 | if (arrayInMutation) { 387 | mutationResponse = { 388 | [mutationQueryType]: { 389 | [arrayInMutation]: [{ [key]: response[key] }], 390 | }, 391 | }; 392 | } else { 393 | mutationResponse = { 394 | [mutationQueryType]: { 395 | [key]: response[key], 396 | }, 397 | }; 398 | } 399 | //sets mutation data in redis 400 | redis.set( 401 | keysToCache[i], 402 | JSON.stringify(mutationResponse) 403 | ); 404 | } 405 | } 406 | } 407 | } 408 | } catch (err) { 409 | const errObj = { 410 | log: "error in cacheMutations", 411 | status: 400, 412 | message: "error in cacheMutations", 413 | }; 414 | return next(err, errObj); 415 | } 416 | } 417 | 418 | // requests response from gql and calls combineCacheAndResponseData to return merged object to sendResponse - called in createResponse 419 | async function GQLResponse() { 420 | if (operation === "mutation") { 421 | document = gql` 422 | ${req.body.query} 423 | `; 424 | let gqlResponse = await request(`${graphQlPath}`, document); 425 | 426 | cacheMutations(keysToCache, gqlResponse); 427 | 428 | sendResponse(gqlResponse); 429 | } else { 430 | const mergeArr = keysToRequestArr.map( 431 | async (key) => await getResponseAndCache(key) 432 | ); 433 | 434 | const toBeMerged = await Promise.all(mergeArr); 435 | sendResponse( 436 | combineCacheAndResponseData( 437 | ...toBeMerged, 438 | ...responseToMergeArr 439 | ) 440 | ); 441 | } 442 | } 443 | 444 | //sends response to front end 445 | async function sendResponse(resObj) { 446 | const dataToReturn = { 447 | data: {}, 448 | }; 449 | dataToReturn.data = resObj; 450 | res.locals.response = dataToReturn; 451 | return next(); 452 | } 453 | 454 | GQLResponse(); 455 | } catch (err) { 456 | const errObj = { 457 | log: "sendResponse error", 458 | status: 400, 459 | message: "Invalid response", 460 | }; 461 | return next(err, errObj); 462 | } 463 | } 464 | 465 | createResponse(); 466 | } catch (err) { 467 | const errObj = { 468 | log: "QLutch error", 469 | status: 400, 470 | message: "Invalid request", 471 | }; 472 | return next(err, errObj); 473 | } 474 | }; 475 | }; 476 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "qlutch", 3 | "description": "QLutch is a lightweight, server-side caching solution for GraphQL using Redis", 4 | "version": "1.0.4", 5 | "main": "index.js", 6 | "scripts": { 7 | "build": "webpack --mode production", 8 | "dev": "concurrently \"webpack server --open\" \"nodemon src/server/server.js\"", 9 | "start": "node server/server.js", 10 | "test": "echo \"Error: no test specified\" && exit 1" 11 | }, 12 | "keywords": [ 13 | "GraphQL", 14 | "graphql", 15 | "Redis", 16 | "redis", 17 | "cache", 18 | "caching", 19 | "qlutch", 20 | "QLutch" 21 | ], 22 | "author": "Renee Toscan, Allan Ross, Lisa Rodrigues, Michael Weckop", 23 | "license": "ISC", 24 | "dependencies": { 25 | "bytes": "^3.1.2", 26 | "concurrently": "^8.2.1", 27 | "cors": "^2.8.5", 28 | "express": "^4.18.2", 29 | "express-graphql": "^0.12.0", 30 | "graphql": "^15.8.0", 31 | "graphql-request": "^6.1.0", 32 | "mongoose": "^7.6.3", 33 | "node-fetch": "^2.7.0", 34 | "react": "^18.2.0", 35 | "react-apexcharts": "^1.4.1", 36 | "react-dom": "^18.2.0", 37 | "redis": "^4.6.8", 38 | "webpack-dev-server": "^4.15.1" 39 | }, 40 | "devDependencies": { 41 | "@babel/core": "^7.22.11", 42 | "@babel/preset-env": "^7.22.14", 43 | "@babel/preset-react": "^7.22.5", 44 | "babel-loader": "^9.1.3", 45 | "css-loader": "^6.8.1", 46 | "dotenv": "^16.3.1", 47 | "file-loader": "^6.2.0", 48 | "html-webpack-plugin": "^5.5.3", 49 | "node-sass": "^9.0.0", 50 | "nodemon": "^3.0.1", 51 | "sass-loader": "^13.3.2", 52 | "style-loader": "^3.3.3", 53 | "webpack": "^5.88.2", 54 | "webpack-cli": "^5.1.4" 55 | } 56 | } 57 | --------------------------------------------------------------------------------