From bb29d24216fe4a5d1779605daabc55ccf9a556ce Mon Sep 17 00:00:00 2001 From: b-ma Date: Mon, 19 Feb 2024 16:39:01 +0100 Subject: [PATCH] feat: implement ping / pong connction check on client side, fix #86 --- src/client/Socket.js | 60 ++- src/common/constants.js | 5 + src/server/Socket.js | 97 ++--- tests/integration/ping-pong/.editorconfig | 12 + tests/integration/ping-pong/.eslintrc | 3 + tests/integration/ping-pong/.gitignore | 16 + tests/integration/ping-pong/.npmrc | 1 + tests/integration/ping-pong/.soundworks | 5 + tests/integration/ping-pong/LICENSE | 28 ++ tests/integration/ping-pong/README.md | 111 ++++++ .../ping-pong/config/application.json | 10 + tests/integration/ping-pong/package.json | 36 ++ .../integration/ping-pong/public/favicon.ico | Bin 0 -> 152037 bytes .../ping-pong/public/images/loader.gif | Bin 0 -> 60377 bytes .../src/clients/components/sw-audit.js | 78 ++++ .../src/clients/components/sw-credits.js | 80 ++++ .../ping-pong/src/clients/player/index.js | 45 +++ .../ping-pong/src/clients/styles/app.scss | 79 ++++ .../src/clients/styles/normalize.scss | 359 ++++++++++++++++++ .../integration/ping-pong/src/server/index.js | 40 ++ .../ping-pong/src/server/schemas/.gitkeep | 1 + .../ping-pong/src/server/tmpl/default.tmpl | 27 ++ .../src/utils/catch-unhandled-errors.js | 9 + .../ping-pong/src/utils/load-config.js | 80 ++++ 24 files changed, 1104 insertions(+), 78 deletions(-) create mode 100644 tests/integration/ping-pong/.editorconfig create mode 100644 tests/integration/ping-pong/.eslintrc create mode 100644 tests/integration/ping-pong/.gitignore create mode 100644 tests/integration/ping-pong/.npmrc create mode 100644 tests/integration/ping-pong/.soundworks create mode 100644 tests/integration/ping-pong/LICENSE create mode 100644 tests/integration/ping-pong/README.md create mode 100644 tests/integration/ping-pong/config/application.json create mode 100644 tests/integration/ping-pong/package.json create mode 100755 tests/integration/ping-pong/public/favicon.ico create mode 100644 tests/integration/ping-pong/public/images/loader.gif create mode 100644 tests/integration/ping-pong/src/clients/components/sw-audit.js create mode 100644 tests/integration/ping-pong/src/clients/components/sw-credits.js create mode 100644 tests/integration/ping-pong/src/clients/player/index.js create mode 100644 tests/integration/ping-pong/src/clients/styles/app.scss create mode 100644 tests/integration/ping-pong/src/clients/styles/normalize.scss create mode 100644 tests/integration/ping-pong/src/server/index.js create mode 100644 tests/integration/ping-pong/src/server/schemas/.gitkeep create mode 100644 tests/integration/ping-pong/src/server/tmpl/default.tmpl create mode 100644 tests/integration/ping-pong/src/utils/catch-unhandled-errors.js create mode 100644 tests/integration/ping-pong/src/utils/load-config.js diff --git a/src/client/Socket.js b/src/client/Socket.js index 9b7b20d8..0b631607 100644 --- a/src/client/Socket.js +++ b/src/client/Socket.js @@ -1,13 +1,19 @@ import { isBrowser } from '@ircam/sc-utils'; import WebSocket from 'isomorphic-ws'; +import { + PING_INTERVAL, + PING_LATENCY_TOLERANCE, + PING_MESSAGE, + PONG_MESSAGE, +} from '../common/constants.js'; +import logger from '../common/logger.js'; import { packBinaryMessage, unpackBinaryMessage, packStringMessage, unpackStringMessage, } from '../common/sockets-utils.js'; -import logger from '../common/logger.js'; // WebSocket events: // @@ -130,7 +136,28 @@ class Socket { ws.addEventListener('open', connectEvent => { // parse incoming messages for pubsub this.ws = ws; + + // ping/pong behaviour + let pingTimeout = null; + + const heartbeat = () => { + clearTimeout(pingTimeout); + + pingTimeout = setTimeout(() => { + this.terminate(); + }, PING_INTERVAL + PING_LATENCY_TOLERANCE); + } + + heartbeat(); + this.ws.addEventListener('message', e => { + if (e.data === PING_MESSAGE) { + heartbeat(); + this.ws.send(PONG_MESSAGE); + // do not propagate ping / pong messages + return; + } + const [channel, args] = unpackStringMessage(e.data); this._emit(false, channel, ...args); }); @@ -144,6 +171,7 @@ class Socket { // forward open event this._emit(false, 'open', connectEvent); + // continue with raw socket resolve(); }); @@ -173,36 +201,6 @@ class Socket { trySocket(); }); - // @todo - review/fix - // - the `ws.on` method only exists on node implementation, and the 'ping' - // message is not received on addEventListener - // - there seems to be no way to access the ping event in browsers... - // - // let pingTimeoutId = null; - // const pingInterval = config.env.websockets.pingInterval; - // // detect broken connection - // // cf. https://github.com/websockets/ws#how-to-detect-and-close-broken-connections - // const heartbeat = () => { - // try { - // console.log('ping received'); - // clearTimeout(pingTimeoutId); - - // // pingTimeoutId = setTimeout(() => { - // // console.log('terminate'); - // // this.terminate(); - // // }, pingInterval + 2000); - // } catch (err) { - // console.error(err); - // } - // }; - // - // this.ws.on('ping', heartbeat); - // this.ws.addEventListener('close', () => { - // clearTimeout(pingTimeoutId); - // }); - - // heartbeat(); - // ---------------------------------------------------------- // init binary socket // ---------------------------------------------------------- diff --git a/src/common/constants.js b/src/common/constants.js index d7c352f7..5c27e4ff 100644 --- a/src/common/constants.js +++ b/src/common/constants.js @@ -1,6 +1,11 @@ // id of the server when owner of a state export const SERVER_ID = -1; +export const PING_INTERVAL = 10 * 1000; +export const PING_LATENCY_TOLERANCE = 1000; +export const PING_MESSAGE = 'h:ping'; +export const PONG_MESSAGE = 'h:pong'; + // batched transport channel export const BATCHED_TRANSPORT_CHANNEL = 'b:t'; diff --git a/src/server/Socket.js b/src/server/Socket.js index a7ab4950..1caa24a0 100644 --- a/src/server/Socket.js +++ b/src/server/Socket.js @@ -1,4 +1,9 @@ import { getTime } from '@ircam/sc-utils'; +import { + PING_INTERVAL, + PING_MESSAGE, + PONG_MESSAGE, +} from '../common/constants.js'; import { packBinaryMessage, unpackBinaryMessage, @@ -32,17 +37,7 @@ import { * @hideconstructor */ class Socket { - constructor(ws, binaryWs, rooms, sockets, options = {}) { - /** - * Configuration object - * - * @type {object} - */ - this.config = { - pingInterval: 5 * 1000, - ...options, - }; - + constructor(ws, binaryWs, rooms, sockets) { /** * `ws` socket instance configured with `binaryType=blob` (string) * @@ -82,15 +77,54 @@ class Socket { /** @private */ this._binaryListeners = new Map(); + // heartbeat system (run only on string socket), adapted from: + // https://github.com/websockets/ws#how-to-detect-and-close-broken-connections + /** @private */ + this._isAlive = true; + let msg = { + type: 'add-measurement', + value: { + ping: 0, + pong: 0, + }, + }; + // ---------------------------------------------------------- - // init string socket + // String socket + // implements ping/pong behavior // ---------------------------------------------------------- this.ws.addEventListener('message', e => { + if (e.data === PONG_MESSAGE) { + this._isAlive = true; + + msg.value.pong = getTime(); + this.sockets._latencyStatsWorker.postMessage(msg); + // do not propagate ping / pong messages + return; + } + const [channel, args] = unpackStringMessage(e.data); this._emit(false, channel, ...args); }); - // broadcast all `ws` "native" events + const heartbeat = () => { + if (this._isAlive === false) { + // emit a 'close' event to go trough all the disconnection pipeline + this._emit(false, 'close'); + return; + } + + this._isAlive = false; + msg.value.ping = getTime(); + + this.ws.send(PING_MESSAGE); + + setTimeout(heartbeat, PING_INTERVAL); + }; + + setTimeout(heartbeat, PING_INTERVAL); + + // broadcast all "native" events [ 'close', 'error', @@ -107,14 +141,14 @@ class Socket { }); // ---------------------------------------------------------- - // init binary socket + // Binary socket // ---------------------------------------------------------- this.binaryWs.addEventListener('message', e => { const [channel, data] = unpackBinaryMessage(e.data); this._emit(true, channel, data); }); - // broadcast all `ws` "native" events + // broadcast all "native" events [ 'close', 'error', @@ -129,38 +163,6 @@ class Socket { this._emit(true, eventName, e.data); }); }); - - // heartbeat system (run only on string socket), adapted from: - // https://github.com/websockets/ws#how-to-detect-and-close-broken-connections - this._isAlive = true; - let msg = { - type: 'add-measurement', - value: { - ping: 0, - pong: 0, - }, - }; - - // heartbeat system, only on "regular" socket - this.ws.on('pong', () => { - this._isAlive = true; - - msg.value.pong = getTime(); - this.sockets._latencyStatsWorker.postMessage(msg); - }); - - this._intervalId = setInterval(() => { - if (this._isAlive === false) { - // emit a 'close' event to go trough all the disconnection pipeline - this._emit(false, 'close'); - return; - } - - this._isAlive = false; - msg.value.ping = getTime(); - - this.ws.ping(); - }, this.config.pingInterval); } /** @@ -172,6 +174,7 @@ class Socket { terminate() { // clear ping/pong check clearInterval(this._intervalId); + // clean rooms for (let [_key, room] of this.rooms) { room.delete(this); diff --git a/tests/integration/ping-pong/.editorconfig b/tests/integration/ping-pong/.editorconfig new file mode 100644 index 00000000..48fe40a1 --- /dev/null +++ b/tests/integration/ping-pong/.editorconfig @@ -0,0 +1,12 @@ +root = true + +[*] +indent_style = space +indent_size = 2 +end_of_line = LF +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true + +[*.md] +trim_trailing_whitespace = false diff --git a/tests/integration/ping-pong/.eslintrc b/tests/integration/ping-pong/.eslintrc new file mode 100644 index 00000000..9eee557e --- /dev/null +++ b/tests/integration/ping-pong/.eslintrc @@ -0,0 +1,3 @@ +{ + "extends": "@ircam", +} diff --git a/tests/integration/ping-pong/.gitignore b/tests/integration/ping-pong/.gitignore new file mode 100644 index 00000000..cdca7f80 --- /dev/null +++ b/tests/integration/ping-pong/.gitignore @@ -0,0 +1,16 @@ +# transpiled files and dependencies +/node_modules +# application build files +.build +.data + +# ignore all environment config files +/config/env-* + +# junk files +package-lock.json +.DS_Store +Thumbs.db + +# TLS certificates +/**/*.pem diff --git a/tests/integration/ping-pong/.npmrc b/tests/integration/ping-pong/.npmrc new file mode 100644 index 00000000..9cf94950 --- /dev/null +++ b/tests/integration/ping-pong/.npmrc @@ -0,0 +1 @@ +package-lock=false \ No newline at end of file diff --git a/tests/integration/ping-pong/.soundworks b/tests/integration/ping-pong/.soundworks new file mode 100644 index 00000000..e9add505 --- /dev/null +++ b/tests/integration/ping-pong/.soundworks @@ -0,0 +1,5 @@ +{ + "name": "ping-pong", + "eslint": true, + "language": "js" +} \ No newline at end of file diff --git a/tests/integration/ping-pong/LICENSE b/tests/integration/ping-pong/LICENSE new file mode 100644 index 00000000..5ce949ee --- /dev/null +++ b/tests/integration/ping-pong/LICENSE @@ -0,0 +1,28 @@ +Copyright (c) 2014-present IRCAM – Centre Pompidou (France, Paris) + +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +* Redistributions in binary form must reproduce the above copyright notice, this + list of conditions and the following disclaimer in the documentation and/or + other materials provided with the distribution. + +* Neither the name of the IRCAM nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/tests/integration/ping-pong/README.md b/tests/integration/ping-pong/README.md new file mode 100644 index 00000000..5ce4251f --- /dev/null +++ b/tests/integration/ping-pong/README.md @@ -0,0 +1,111 @@ +# `ping-pong` + +Thanks for using soundworks! + +If you are developping an appliction using `soundworks`, let us know by filling a comment there [https://github.com/collective-soundworks/soundworks/discussions/61](https://github.com/collective-soundworks/soundworks/discussions/61) + +## Links + +- [General Documentation / Tutorials](https://soundworks.dev/) +- [API](https://soundworks.dev/api) +- [Examples](https://github.com/collective-soundworks/soundworks-examples) +- [Issue Tracker](https://github.com/collective-soundworks/soundworks/issues) +- [Working with Max/MSP](https://github.com/collective-soundworks/soundworks-max) + +## Soundworks wizard + +The soundworks wizard is a interactive command line tool that gives you access to a bunch of high-level functionnalities, such as: +- configure and create new clients +- install / uninstall plugins and related libraries +- find some documentation +- create environment config files +- etc. + +```bash +npx soundworks +``` + +## Available npm scripts + +#### `npm run dev` + +Launch the application in development mode. Watch file system, transpile and bundle files on change (i.e. when a source file is saved), and restart the server when needed. + +#### `npm run build` + +Build the application. Transpile and bundle the source code without launching the server. + +#### `npm run build:prod` + +Build the application for production. Same as `npm run build` but additionally creates minified files for browser clients. + +#### `npm run start` + +Launch the server without rebuilding the application. Basically a shortcut for `node ./.build/server/index.js`. + +#### `npm run watch [name]` _(node clients only)_ + +Launch the `[name]` client and restart when the sources are updated. + +For example, if you are developping an application with a node client, you should run the `dev` script (to build the source and start the server) in one terminal: + +```bash +npm run dev +``` + +And launch and watch the node client(s) (e.g. called `thing`) in another terminal: + +```bash +npm run watch thing +``` + +## Environment variables + +#### `ENV` + +Define which environment config file should be used to run the application. Environment config files are located in the `/config/env` directory. + +For example, given the following config files: + +``` +├─ config +│ ├─ env +│ │ ├─ default.js +│ │ └─ prod.js +``` + +To start the server the `/config/env/prod.js` configuration file, you should run: + +```bash +ENV=prod npm run start +``` + +By default, the `/config/env/default.json` configuration file is used. + +#### `PORT` + +Override the port defined in the config file. + +For example, to launch the server on port `3000` whatever the `port` value defined in the default configuration file, you should run: + +```bash +PORT=3000 npm run start +``` + +#### `EMULATE` _(node clients only)_ + +Run several node client instances in parallel in the same terminal window. + +For example, to launch 4 instances of the client `thing` in parallel (each client instance being run inside its own `fork`), you should run: + +```bash +EMULATE=4 npm run watch:process thing +``` + +## Credits + +[soundworks](https://soundworks.dev) is a framework developped by the ISMM team at Ircam + +## License + +[BSD-3-Clause](./LICENSE) diff --git a/tests/integration/ping-pong/config/application.json b/tests/integration/ping-pong/config/application.json new file mode 100644 index 00000000..77b1cb65 --- /dev/null +++ b/tests/integration/ping-pong/config/application.json @@ -0,0 +1,10 @@ +{ + name: 'ping-pong', + author: '', + clients: { + player: { + target: 'browser', + default: true, + }, + }, +} \ No newline at end of file diff --git a/tests/integration/ping-pong/package.json b/tests/integration/ping-pong/package.json new file mode 100644 index 00000000..26214f51 --- /dev/null +++ b/tests/integration/ping-pong/package.json @@ -0,0 +1,36 @@ +{ + "name": "ping-pong", + "description": "soundworks application", + "authors": [], + "license": "BSD-3-Clause", + "version": "0.0.0", + "type": "module", + "private": true, + "scripts": { + "build": "npm run clean && sass src/clients/styles:.build/public/css && soundworks-build -b", + "build:production": "npm run clean && sass src/clients/styles:.build/public/css && soundworks-build -b -m", + "clean": "soundworks-build -D", + "dev": "npm run build && (concurrently -i -p \"none\" \"npm run watch:inspect server\" \"soundworks-build -b -w\" \"npm run watch:sass\")", + "postinstall": "soundworks-build -C", + "start": "node .build/server/index.js", + "watch": "soundworks-build -p", + "watch:inspect": "soundworks-build -d -p", + "watch:sass": "sass --watch src/clients/styles:.build/public/css", + "lint": "eslint ." + }, + "dependencies": { + "@ircam/sc-components": "^3.0.0-alpha.44", + "@soundworks/core": "^4.0.0-alpha.0", + "@soundworks/helpers": "^1.0.0-alpha.2", + "json5": "^2.2.2", + "lit": "^3.0.2" + }, + "devDependencies": { + "@ircam/eslint-config": "^1.2.1", + "@soundworks/build": "^1.0.0-alpha.0", + "@soundworks/create": "^1.0.0-alpha.19", + "concurrently": "^8.2.2", + "eslint": "^8.56.0", + "sass": "^1.57.1" + } +} diff --git a/tests/integration/ping-pong/public/favicon.ico b/tests/integration/ping-pong/public/favicon.ico new file mode 100755 index 0000000000000000000000000000000000000000..d51fa076fc4a7d8a8e843d14eba67d9c762b5521 GIT binary patch literal 152037 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

IL{LN72nJAi2((7uSc*UGQ zJq!FnQ!rONl&>*X`MKohIGb8>o$zo?-1uUxUKM@G?%A< zZQ{7$C-J-F!jQ90{%VJh{)h8NYVUr+*d+IZJP-0Eh;t{;f!KLyTBJqcnaCr#Q{+LA zOE`4by^LpaCb-x6DCFcQPMrKfa>adPQ)Dg#}@u!{V97N*!;m_R1URFNVcHVy=%K@kmm#ycL!!rFk#kMO4 z5c)dDO8Ic6yV~5NUzT%6ZVP!=v~S`$mznP5HS>P@K%N8|9>s-{<`i#8-0JMjw4*8h`4!&H;w+U+{uuOl^7hoD(kvmR47Wt^AzjDlI|I{mQbG|ir z1ZV6gL7oXYIq+oAb~{+E$Z2#=r_OVq^2$meJ80q54KePBGtKlX#RPQU(M=LV8P?BAYNj)?LKp)YvsqTJNM zJMLx~qD{6f<>+1i9J*83VxJA{AA3vm89%}KqU74Ilwt9qME|fK3$#K{a}T_q_0wmZ z2Tjf^_;MR~zVJ`p6m4wKe(7j*Q0I1SEgQ@JQcv#dx3}Eig?FIi&>`gHkaNlR;a@nw zIXASO+$(Yr(aY#J{08!^vB~I8_MG54aue-WV7rBd@u!^aYm6WHHg)hLzW^Hu{?H%Z zmqT{NIS%F8TIrnnfKIXBDxb#R*uGImD{rYP#@?A2fyPli6_V+ z`k5Sea;Witu!a1F{^wfs2Ra6x)$vbdgR$+4w7(2G9E_8n*J<<3%oFE}PM?|i%#Xk7 zTwn5Rn&0LMa$V9t><}`C4QCG)=eeUFbRM4sxrGnp#+S7-&_jKNr~TAcY|_}{PD^8s zmCqx7V8dv${bXI!!0#w&%c}MZS+ozxvz*UX=y#4g>441tFYvC+JK+Pm245MBz*l^< z@h6|P(x1zDU*`T*ZF})PXjtjFytf(eMrMp}d|mj5zo@>zn~Kk_^v-p0UNk86i)^FR zmkpxr>w;eK&cH@sATSUZ2n+-U0t118z(8OiFc26B3mNuV6HR2w^WuJn=3B8t}yS*dLQdxE9c;wUKhyQ%bc>;EO;*O z)iZgXe=mJkneVJYaV`7FP1d?jwtn?`h52aaxb=L|$#cMZ59?FZX&8U<*`D8LJ&rxj zs}DLtYZ02)E=LFM*Lmi?S&L*JWA6p4efWqMnMdqVxqaBQM!|Xqbjtd?!t1=OV^a%a z+kYgl|4uspO6}!Yo2FiIi(@fL;}EiSt#`Ffqdi~sEMg~k_spOC!q*c!H14Fc3iK>l ziy+=YYeU5T5M#l59q|#Yxw0O_x*L1EvzI6PQL`@0x}4XsJwL8zsn4tnvgX9zbFAC2 z9>dyAz8@!Rne11}`f`plXbqjmed|oWuczWj*vFH#xSpa}wx08C$8Hc0!1^=s6zoyY zo~d~(8f%;EnMs@vYeoFdS})HSxOiKy#j%dazL&;Nfp=L9(Z8}78P#U+9_Flz7uGcW ze#N=4uEw6?tQU?x@&xxEy22~sOW?wj5&3gTQ?FJs-0cn|NNsrCON{f|H0bmy5v_t?k#sI#^?S9Kle2mEDi zbin+@-cuP&(k}20y;$D^oAfpBcg}zQMlY~uKW&G1lP|f!u^wQLbz$(w{@=v4z*p8g z+2;@$qpve=dC-0BHGH^u@lpZ{#Iw;C_C5sLUV|#oy!h^1XV3=mg!M)5WvlkG&OPW; zJ35Yp{mHlcS8_~7jrI=PW*nc=RGgmLkgYdq z4NCG$d)V*Y7*ZSwm_@F@*|0tLbz5_NMc=mh8}U2GnhyDX+Qe=^i>$vT@}Bp*u}|cK zbyMjP>lnQQULmj6U#0g#SLqP;BLn{eiKDE&>xqWGFZ36ESH=n!{95=>7!y*PgYo2|m$i?1rJR_8 pk6Gk@p?&n5*j>N)Xy)3j~@jasXu{vW<3K|%lk literal 0 HcmV?d00001 diff --git a/tests/integration/ping-pong/public/images/loader.gif b/tests/integration/ping-pong/public/images/loader.gif new file mode 100644 index 0000000000000000000000000000000000000000..0b2af50c6d629a69896b1a2f4e2c444466b66475 GIT binary patch literal 60377 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 D34{9W literal 0 HcmV?d00001 diff --git a/tests/integration/ping-pong/src/clients/components/sw-audit.js b/tests/integration/ping-pong/src/clients/components/sw-audit.js new file mode 100644 index 00000000..c04431bf --- /dev/null +++ b/tests/integration/ping-pong/src/clients/components/sw-audit.js @@ -0,0 +1,78 @@ +import { LitElement, html, css, nothing } from 'lit'; +import { unsafeHTML } from 'lit/directives/unsafe-html.js'; + +function padLeft(value, size) { + let str = value + ''; // cast to string + + while (str.length <= size) { + str = ` ${str}`; + } + + return str; +} + +/** + * Component for the soundworks internal audit state + */ +class SwAudit extends LitElement { + static styles = css` + :host > div { + background-color: var(--sw-light-background-color); + height: 100%; + padding: 0 20px; + overflow: hidden; + } + `; + + constructor() { + super(); + + this.client = null; + this._auditState = null; + this._numClientsString = ''; + } + + async connectedCallback() { + super.connectedCallback(); + + this._auditState = await this.client.getAuditState(); + this._auditState.onUpdate(updates => { + if ('numClients' in updates) { + const numClientsStrings = []; + const numClients = this._auditState.get('numClients'); + + for (let role in numClients) { + const str = `${role}: ${padLeft(numClients[role], 2)}`; + numClientsStrings.push(str); + } + + this._numClientsString = numClientsStrings.join(' - '); + } + + this.requestUpdate(); + }, true); + } + + disconnectedCallback() { + super.disconnectedCallback(); + + this._auditState.detach(); + } + + render() { + if (this._auditState === null) { + return nothing; + } + + const avgLatency = this._auditState.get('averageNetworkLatency'); + const avgLatencyString = padLeft((avgLatency * 1e3).toFixed(2), 6); + + return html` +

+ `; + } +} + +customElements.define('sw-audit', SwAudit); diff --git a/tests/integration/ping-pong/src/clients/components/sw-credits.js b/tests/integration/ping-pong/src/clients/components/sw-credits.js new file mode 100644 index 00000000..8cfa8599 --- /dev/null +++ b/tests/integration/ping-pong/src/clients/components/sw-credits.js @@ -0,0 +1,80 @@ +import { LitElement, html, css, nothing } from 'lit'; + +import '@ircam/sc-components/sc-icon.js'; + +class SwCredits extends LitElement { + static properties = { + _show: { + type: Boolean, + state: true, + }, + }; + + static styles = css` + :host { + width: 100%; + position: fixed; + bottom: 0; + left: 0; + z-index: 1000; + } + + :host > footer { + display: block; + line-height: 1.6rem; + padding: 20px; + background-color: var(--sw-light-background-color); + box-sizing: border-box; + } + + footer span { + font-style: italic; + color: var(--sw-light-font-color); + font-weight: bold; + } + + footer a { + color: var(--sw-light-font-color); + } + + sc-icon { + position: absolute; + bottom: 20px; + right: 20px; + z-index: 1001; + border: none; + background-color: transparent; + opacity: 0.6; + } + `; + + constructor() { + super(); + + this._show = false; + this.infos = {}; + } + + render() { + const $footer = html` + + `; + + return html` + ${this._show ? $footer : nothing} + this._show = !this._show}> + ` + } +} + +customElements.define('sw-credits', SwCredits); diff --git a/tests/integration/ping-pong/src/clients/player/index.js b/tests/integration/ping-pong/src/clients/player/index.js new file mode 100644 index 00000000..7b512c50 --- /dev/null +++ b/tests/integration/ping-pong/src/clients/player/index.js @@ -0,0 +1,45 @@ +import '@soundworks/helpers/polyfills.js'; +import Client from '../../../../../../src/client/Client.js'; +import launcher from '@soundworks/helpers/launcher.js'; + +import { html, render } from 'lit'; +import '../components/sw-audit.js'; + +// - General documentation: https://soundworks.dev/ +// - API documentation: https://soundworks.dev/api +// - Issue Tracker: https://github.com/collective-soundworks/soundworks/issues +// - Wizard & Tools: `npx soundworks` + +const config = window.SOUNDWORKS_CONFIG; + +async function main($container) { + const client = new Client(config); + + launcher.register(client, { + initScreensContainer: $container, + reloadOnVisibilityChange: false, + }); + + await client.start(); + + function renderApp() { + render(html` +
+
+

${client.config.app.name} | ${client.role}

+ +
+
+

Hello ${client.config.app.name}!

+
+
+ `, $container); + } + + renderApp(); +} + +launcher.execute(main, { + numClients: parseInt(new URLSearchParams(window.location.search).get('emulate')) || 1, + width: '50%', +}); diff --git a/tests/integration/ping-pong/src/clients/styles/app.scss b/tests/integration/ping-pong/src/clients/styles/app.scss new file mode 100644 index 00000000..117fbc3f --- /dev/null +++ b/tests/integration/ping-pong/src/clients/styles/app.scss @@ -0,0 +1,79 @@ +:root { + --sw-background-color: #000000; + --sw-medium-background-color: #121212; + --sw-light-background-color: #242424; + --sw-lighter-background-color: #363636; + --sw-font-color: #ffffff; + --sw-light-font-color: #cccccc; + --sw-font-color-error: #a94442; + --sw-font-family: Consolas, monaco, monospace; + --sw-font-size: 62.5%; // such that 1rem == 10px +} + +html, body { + width: 100%; + min-height: 100vh; + background-color: var(--sw-background-color); + color: var(--sw-font-color); +} + +html { + font-size: var(--sw-font-size); +} + +body { + display: flex; + flex-wrap: wrap; +} + +* { + box-sizing: border-box; + font-family: var(--sw-font-family); +} + +body.loading { + background-image: url(../../images/loader.gif); + background-position: 50% 50%; + background-repeat: no-repeat; +} + +.simple-layout { + display: block; + flex-grow: 1; + padding: 20px; + position: relative; +} + +// default styles for controller layout +.controller-layout { + display: block; + flex-grow: 1; + position: relative; + + & > header { + display: block; + height: 38px; + line-height: 38px; + background-color: var(--sw-medium-background-color); + display: flex; + flex-direction: row; + justify-content: space-between; + border-bottom: 1px solid var(--sw-lighter-background-color); + + h1 { + font-size: 12px; + margin: 0; + padding-left: 20px; + max-width: 50%; + overflow: hidden; + } + + sc-audit { + max-width: 50%; + } + } + + & > section { + padding: 20px; + } +} diff --git a/tests/integration/ping-pong/src/clients/styles/normalize.scss b/tests/integration/ping-pong/src/clients/styles/normalize.scss new file mode 100644 index 00000000..a42c4df0 --- /dev/null +++ b/tests/integration/ping-pong/src/clients/styles/normalize.scss @@ -0,0 +1,359 @@ +/*! normalize.css v8.0.1 | MIT License | github.com/necolas/normalize.css */ +/* + The MIT License (MIT) + Copyright © Nicolas Gallagher and Jonathan Neal + + Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +*/ + +/* Document + ========================================================================== */ + +/** + * 1. Correct the line height in all browsers. + * 2. Prevent adjustments of font size after orientation changes in iOS. + */ + +html { + line-height: 1.15; /* 1 */ + -webkit-text-size-adjust: 100%; /* 2 */ +} + +/* Sections + ========================================================================== */ + +/** + * Remove the margin in all browsers. + */ + +body { + margin: 0; +} + +/** + * Render the `main` element consistently in IE. + */ + +main { + display: block; +} + +/** + * Correct the font size and margin on `h1` elements within `section` and + * `article` contexts in Chrome, Firefox, and Safari. + */ + +h1 { + font-size: 2em; + margin: 0.67em 0; +} + +/* Grouping content + ========================================================================== */ + +/** + * 1. Add the correct box sizing in Firefox. + * 2. Show the overflow in Edge and IE. + */ + +hr { + box-sizing: content-box; /* 1 */ + height: 0; /* 1 */ + overflow: visible; /* 2 */ +} + +/** + * 1. Correct the inheritance and scaling of font size in all browsers. + * 2. Correct the odd `em` font sizing in all browsers. + */ + +pre { + font-family: monospace, monospace; /* 1 */ + font-size: 1em; /* 2 */ +} + +/* Text-level semantics + ========================================================================== */ + +/** + * Remove the gray background on active links in IE 10. + */ + +a { + background-color: transparent; +} + +/** + * 1. Remove the bottom border in Chrome 57- + * 2. Add the correct text decoration in Chrome, Edge, IE, Opera, and Safari. + */ + +abbr[title] { + border-bottom: none; /* 1 */ + text-decoration: underline; /* 2 */ + text-decoration: underline dotted; /* 2 */ +} + +/** + * Add the correct font weight in Chrome, Edge, and Safari. + */ + +b, +strong { + font-weight: bolder; +} + +/** + * 1. Correct the inheritance and scaling of font size in all browsers. + * 2. Correct the odd `em` font sizing in all browsers. + */ + +code, +kbd, +samp { + font-family: monospace, monospace; /* 1 */ + font-size: 1em; /* 2 */ +} + +/** + * Add the correct font size in all browsers. + */ + +small { + font-size: 80%; +} + +/** + * Prevent `sub` and `sup` elements from affecting the line height in + * all browsers. + */ + +sub, +sup { + font-size: 75%; + line-height: 0; + position: relative; + vertical-align: baseline; +} + +sub { + bottom: -0.25em; +} + +sup { + top: -0.5em; +} + +/* Embedded content + ========================================================================== */ + +/** + * Remove the border on images inside links in IE 10. + */ + +img { + border-style: none; +} + +/* Forms + ========================================================================== */ + +/** + * 1. Change the font styles in all browsers. + * 2. Remove the margin in Firefox and Safari. + */ + +button, +input, +optgroup, +select, +textarea { + font-family: inherit; /* 1 */ + font-size: 100%; /* 1 */ + line-height: 1.15; /* 1 */ + margin: 0; /* 2 */ +} + +/** + * Show the overflow in IE. + * 1. Show the overflow in Edge. + */ + +button, +input { /* 1 */ + overflow: visible; +} + +/** + * Remove the inheritance of text transform in Edge, Firefox, and IE. + * 1. Remove the inheritance of text transform in Firefox. + */ + +button, +select { /* 1 */ + text-transform: none; +} + +/** + * Correct the inability to style clickable types in iOS and Safari. + */ + +button, +[type="button"], +[type="reset"], +[type="submit"] { + -webkit-appearance: button; +} + +/** + * Remove the inner border and padding in Firefox. + */ + +button::-moz-focus-inner, +[type="button"]::-moz-focus-inner, +[type="reset"]::-moz-focus-inner, +[type="submit"]::-moz-focus-inner { + border-style: none; + padding: 0; +} + +/** + * Restore the focus styles unset by the previous rule. + */ + +button:-moz-focusring, +[type="button"]:-moz-focusring, +[type="reset"]:-moz-focusring, +[type="submit"]:-moz-focusring { + outline: 1px dotted ButtonText; +} + +/** + * Correct the padding in Firefox. + */ + +fieldset { + padding: 0.35em 0.75em 0.625em; +} + +/** + * 1. Correct the text wrapping in Edge and IE. + * 2. Correct the color inheritance from `fieldset` elements in IE. + * 3. Remove the padding so developers are not caught out when they zero out + * `fieldset` elements in all browsers. + */ + +legend { + box-sizing: border-box; /* 1 */ + color: inherit; /* 2 */ + display: table; /* 1 */ + max-width: 100%; /* 1 */ + padding: 0; /* 3 */ + white-space: normal; /* 1 */ +} + +/** + * Add the correct vertical alignment in Chrome, Firefox, and Opera. + */ + +progress { + vertical-align: baseline; +} + +/** + * Remove the default vertical scrollbar in IE 10+. + */ + +textarea { + overflow: auto; +} + +/** + * 1. Add the correct box sizing in IE 10. + * 2. Remove the padding in IE 10. + */ + +[type="checkbox"], +[type="radio"] { + box-sizing: border-box; /* 1 */ + padding: 0; /* 2 */ +} + +/** + * Correct the cursor style of increment and decrement buttons in Chrome. + */ + +[type="number"]::-webkit-inner-spin-button, +[type="number"]::-webkit-outer-spin-button { + height: auto; +} + +/** + * 1. Correct the odd appearance in Chrome and Safari. + * 2. Correct the outline style in Safari. + */ + +[type="search"] { + -webkit-appearance: textfield; /* 1 */ + outline-offset: -2px; /* 2 */ +} + +/** + * Remove the inner padding in Chrome and Safari on macOS. + */ + +[type="search"]::-webkit-search-decoration { + -webkit-appearance: none; +} + +/** + * 1. Correct the inability to style clickable types in iOS and Safari. + * 2. Change font properties to `inherit` in Safari. + */ + +::-webkit-file-upload-button { + -webkit-appearance: button; /* 1 */ + font: inherit; /* 2 */ +} + +/* Interactive + ========================================================================== */ + +/* + * Add the correct display in Edge, IE 10+, and Firefox. + */ + +details { + display: block; +} + +/* + * Add the correct display in all browsers. + */ + +summary { + display: list-item; +} + +/* Misc + ========================================================================== */ + +/** + * Add the correct display in IE 10+. + */ + +template { + display: none; +} + +/** + * Add the correct display in IE 10. + */ + +[hidden] { + display: none; +} diff --git a/tests/integration/ping-pong/src/server/index.js b/tests/integration/ping-pong/src/server/index.js new file mode 100644 index 00000000..72f52b80 --- /dev/null +++ b/tests/integration/ping-pong/src/server/index.js @@ -0,0 +1,40 @@ +import '@soundworks/helpers/polyfills.js'; +import Server from '../../../../../src/server/Server.js'; + +import { loadConfig } from '../utils/load-config.js'; +import '../utils/catch-unhandled-errors.js'; + +// - General documentation: https://soundworks.dev/ +// - API documentation: https://soundworks.dev/api +// - Issue Tracker: https://github.com/collective-soundworks/soundworks/issues +// - Wizard & Tools: `npx soundworks` + +const config = loadConfig(process.env.ENV, import.meta.url); + +console.log(` +-------------------------------------------------------- +- launching "${config.app.name}" in "${process.env.ENV || 'default'}" environment +- [pid: ${process.pid}] +-------------------------------------------------------- +`); + +/** + * Create the soundworks server + */ +const server = new Server(config); +// configure the server for usage within this application template +server.useDefaultApplicationTemplate(); + +/** + * Register plugins and schemas + */ +// server.pluginManager.register('my-plugin', plugin); +// server.stateManager.registerSchema('my-schema', definition); + +/** + * Launch application (init plugins, http server, etc.) + */ +await server.start(); + +// and do your own stuff! + diff --git a/tests/integration/ping-pong/src/server/schemas/.gitkeep b/tests/integration/ping-pong/src/server/schemas/.gitkeep new file mode 100644 index 00000000..e1664421 --- /dev/null +++ b/tests/integration/ping-pong/src/server/schemas/.gitkeep @@ -0,0 +1 @@ +.gitkeep diff --git a/tests/integration/ping-pong/src/server/tmpl/default.tmpl b/tests/integration/ping-pong/src/server/tmpl/default.tmpl new file mode 100644 index 00000000..17dc7559 --- /dev/null +++ b/tests/integration/ping-pong/src/server/tmpl/default.tmpl @@ -0,0 +1,27 @@ + + + + + + + + + ${d.app.name} | ${d.role} + + + + + ${ + d.env.useMinifiedFile === true + ? `` + : `` + } + + + + + + + diff --git a/tests/integration/ping-pong/src/utils/catch-unhandled-errors.js b/tests/integration/ping-pong/src/utils/catch-unhandled-errors.js new file mode 100644 index 00000000..313d2b99 --- /dev/null +++ b/tests/integration/ping-pong/src/utils/catch-unhandled-errors.js @@ -0,0 +1,9 @@ +process.on('unhandledRejection', (reason, _p) => { + console.log('> Unhandled Promise Rejection'); + console.log(reason); +}); + +process.on('uncaughtException', (reason, _p) => { + console.log('> Unhandled Promise Rejection'); + console.log(reason); +}); diff --git a/tests/integration/ping-pong/src/utils/load-config.js b/tests/integration/ping-pong/src/utils/load-config.js new file mode 100644 index 00000000..f0871c55 --- /dev/null +++ b/tests/integration/ping-pong/src/utils/load-config.js @@ -0,0 +1,80 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +import JSON5 from 'json5'; + +const DEFAULT_ENV_CONFIG = { + type: 'development', + port: 8000, + useHttps: false, + serverAddress: '127.0.0.1', +}; + +/** + * Load JS config object from json5 config files located in `/config`. + * + * @param {String} [ENV='default'] - name of the environment. Should correspond + * to a file located in the `/config/env/` directory. If the file is not found + * the DEFAULT_CONFIG object will be used + * @param {String} [callerURL=null] - for node clients, if `callerURL` is given, + * retrieves the `role` from caller directory name. + * + * @returns {Object} config + * @returns {Object} config.app - JS object of the informations contained in + * `/config/application.json`. + * @returns {Object} config.env - JS object of the informations contained in + * `/config/env/${ENV}.json` with ENV being the first argument. + * @returns {Object} config.role - node client only: type/role of the client + * as defined when the client has been created (see `/config/application.json` + * and directory name). + */ +export function loadConfig(ENV = 'default', callerURL = null) { + let env = null; + let app = null; + + // parse env config + const envConfigFilepath = path.join('config', `env-${ENV}.json`); + + try { + env = JSON5.parse(fs.readFileSync(envConfigFilepath, 'utf-8')); + } catch(err) { + console.info(''); + console.info('--------------------------------------------------------'); + console.info(`- Environment config file not found: "${envConfigFilepath}"`); + console.info(`- Using default config:`); + console.info(DEFAULT_ENV_CONFIG); + console.info('- run `npx soundworks --create-env` to create a custom environment file'); + console.info('--------------------------------------------------------'); + + env = DEFAULT_ENV_CONFIG; + } + + if (process.env.PORT) { + env.port = process.env.PORT; + } + + // parse app config + const appConfigFilepath = path.join('config', 'application.json'); + + try { + app = JSON5.parse(fs.readFileSync(appConfigFilepath, 'utf-8')); + } catch(err) { + console.error(`Invalid app config file: ${appConfigFilepath}`); + process.exit(1); + } + + if (callerURL !== null) { + // we can grab the role from the caller url dirname + const dirname = path.dirname(callerURL); + const parent = path.resolve(dirname, '..'); + const role = path.relative(parent, dirname); + + if (role !== 'server') { + return { role, env, app }; + } else { + return { env, app }; + } + } else { + return { env, app }; + } +}