diff --git a/client/package.json b/client/package.json index a24f8ad7..cc177779 100644 --- a/client/package.json +++ b/client/package.json @@ -19,7 +19,8 @@ "react": "^18.2.0", "react-dom": "^18.2.0", "react-router-dom": "^6.11.1", - "react-tooltip": "^5.18.1" + "react-tooltip": "^5.18.1", + "react-use-websocket": "^4.5.0" }, "devDependencies": { "@types/react": "^18.0.28", diff --git a/client/src/pages/filters.jsx b/client/src/pages/filters.jsx index bdf6c7ae..b1e54bf5 100644 --- a/client/src/pages/filters.jsx +++ b/client/src/pages/filters.jsx @@ -93,7 +93,7 @@ export default function Filters({ sendQuery }) { > setSearchParams({ ...currentSearchParams, datasets: e.target.checked })} /> diff --git a/client/src/pages/index.jsx b/client/src/pages/index.jsx index 3caaeb7f..72caa885 100644 --- a/client/src/pages/index.jsx +++ b/client/src/pages/index.jsx @@ -11,6 +11,7 @@ import { } from '@dataesr/react-dsfr'; import { useQuery } from '@tanstack/react-query'; import { useEffect, useState } from 'react'; +import useWebSocket from 'react-use-websocket'; import Actions from './actions'; import AffiliationsTab from './affiliationsTab'; @@ -43,6 +44,11 @@ export default function Home() { cacheTime: Infinity, }); + useWebSocket('ws://127.0.0.1:8080', { + onMessage: (message) => console.log(message.data), + share: true, + }); + const sendQuery = async (_options) => { setAllAffiliations([]); setAllDatasets([]); diff --git a/package-lock.json b/package-lock.json index bc8d08ef..8fd3162e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,7 +27,8 @@ "react": "^18.2.0", "react-dom": "^18.2.0", "react-router-dom": "^6.11.1", - "react-tooltip": "^5.18.1" + "react-tooltip": "^5.18.1", + "react-use-websocket": "^4.5.0" }, "devDependencies": { "@types/react": "^18.0.28", @@ -4114,6 +4115,15 @@ "react-dom": ">=16.6.0" } }, + "node_modules/react-use-websocket": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/react-use-websocket/-/react-use-websocket-4.5.0.tgz", + "integrity": "sha512-oxYVLWM3Lv0InCfjW7hG/Hk0hkE0P1SiLd5/I3d5x0W4riAnDUkD4VEu7qNVAqxNjBF3nU7k0jLMOetLXpwfsA==", + "peerDependencies": { + "react": ">= 18.0.0", + "react-dom": ">= 18.0.0" + } + }, "node_modules/readable-stream": { "version": "2.3.8", "license": "MIT", @@ -4960,8 +4970,13 @@ } }, "node_modules/uuid": { - "version": "9.0.0", - "license": "MIT", + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], "bin": { "uuid": "dist/bin/uuid" } @@ -5193,6 +5208,26 @@ "version": "1.0.2", "license": "ISC" }, + "node_modules/ws": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.16.0.tgz", + "integrity": "sha512-HS0c//TP7Ina87TfiPUz1rQzMhHrl/SG2guqRcTOIUYD2q8uhUdNHZYJUaQ8aTGPzCh+c6oawMKW35nFl1dxyQ==", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/xtend": { "version": "4.0.2", "license": "MIT", @@ -5275,6 +5310,7 @@ "express-async-errors": "^3.1.1", "express-openapi-validator": "^5.0.4", "winston": "^3.8.2", + "ws": "^8.16.0", "yamljs": "^0.3.0" }, "devDependencies": { diff --git a/server/package.json b/server/package.json index 8fd474fc..8eff9d23 100644 --- a/server/package.json +++ b/server/package.json @@ -17,6 +17,7 @@ "express-async-errors": "^3.1.1", "express-openapi-validator": "^5.0.4", "winston": "^3.8.2", + "ws": "^8.16.0", "yamljs": "^0.3.0" }, "devDependencies": { diff --git a/server/src/app.js b/server/src/app.js index 464d158c..81673db3 100644 --- a/server/src/app.js +++ b/server/src/app.js @@ -7,11 +7,19 @@ import * as OAV from 'express-openapi-validator'; import { handleErrors } from './commons/middlewares/handle-errors'; import router from './router'; +import webSocketServer from './webSocketServer'; const apiSpec = 'src/openapi/api.yml'; const apiDocument = YAML.load(apiSpec); const app = express(); +const expressServer = app.listen(process.env.WS_PORT, () => { console.log(`WebSocket server is running on port ${process.env.WS_PORT}`); }); +expressServer.on('upgrade', (request, socket, head) => { + webSocketServer.handleUpgrade(request, socket, head, (websocket) => { + webSocketServer.emit('connection', websocket, request); + }); +}); + app.use(express.json()); app.use(express.urlencoded({ extended: false })); app.disable('x-powered-by'); diff --git a/server/src/routes/works.routes.js b/server/src/routes/works.routes.js index fe763554..3ec1c2f5 100644 --- a/server/src/routes/works.routes.js +++ b/server/src/routes/works.routes.js @@ -2,6 +2,7 @@ import express from 'express'; import { range } from '../utils/utils'; import { deduplicateWorks, getFosmWorks, getOpenAlexPublications, groupByAffiliations } from '../utils/works'; +import webSocketServer from '../webSocketServer'; const router = new express.Router(); @@ -12,6 +13,7 @@ router.route('/works') if (!options?.affiliations) { res.status(400).json({ message: 'You must provide at least one affiliation.' }); } else { + webSocketServer.broadcast('start'); console.time(`0. Requests ${options.affiliations}`); options.affiliations = options.affiliations.split(','); options.datasets = options.datasets === 'true'; @@ -21,20 +23,24 @@ router.route('/works') getOpenAlexPublications({ options }), ]); console.timeEnd(`0. Requests ${options.affiliations}`); + webSocketServer.broadcast('step_0'); console.time(`1. Concat ${options.affiliations}`); const works = [ ...responses[0], ...responses[1], ]; console.timeEnd(`1. Concat ${options.affiliations}`); + webSocketServer.broadcast('step_1'); console.time(`2. Dedup ${options.affiliations}`); // Deduplicate publications by ids const deduplicatedWorks = deduplicateWorks(works); console.timeEnd(`2. Dedup ${options.affiliations}`); + webSocketServer.broadcast('step_2'); // Compute distinct affiliations of works console.time(`3. GroupBy ${options.affiliations}`); const uniqueAffiliations = groupByAffiliations({ options, works: deduplicatedWorks }); console.timeEnd(`3. GroupBy ${options.affiliations}`); + webSocketServer.broadcast('step_3'); // Sort between publications and datasets console.time(`4. Sort works ${options.affiliations}`); const publications = []; @@ -53,6 +59,7 @@ router.route('/works') } } console.timeEnd(`4. Sort works ${options.affiliations}`); + webSocketServer.broadcast('step_4'); // Compute distinct types & years for facet console.time(`5. Facet ${options.affiliations}`); // TODO chek if Set is optim @@ -65,6 +72,7 @@ router.route('/works') const publicationsTypes = [...new Set(publications.map((publication) => publication?.type))]; const datasetsTypes = [...new Set(datasets.map((dataset) => dataset?.type))]; console.timeEnd(`5. Facet ${options.affiliations}`); + webSocketServer.broadcast('step_5'); // Build and serialize response console.time(`6. Serialization ${options.affiliations}`); res.status(200).json({ @@ -73,6 +81,7 @@ router.route('/works') publications: { results: publications, types: publicationsTypes, years: publicationsYears }, }); console.timeEnd(`6. Serialization ${options.affiliations}`); + webSocketServer.broadcast('step_6'); } } catch (err) { console.error(err); diff --git a/server/src/webSocketServer.js b/server/src/webSocketServer.js new file mode 100644 index 00000000..fd279907 --- /dev/null +++ b/server/src/webSocketServer.js @@ -0,0 +1,14 @@ +import WebSocket, { WebSocketServer } from 'ws'; + +const wsServer = new WebSocketServer({ noServer: true }); + +// eslint-disable-next-line arrow-body-style, func-names +wsServer.broadcast = function (message) { + return this.clients.forEach((client) => { + if (client.readyState === WebSocket.OPEN) { + client.send(message); + } + }); +}; + +export default wsServer;