diff --git a/.env.dist b/.env.dist index f522612..5acf959 100644 --- a/.env.dist +++ b/.env.dist @@ -18,3 +18,13 @@ DATABASE_URL=mysql://db_user:db_password@127.0.0.1:3306/db_name ###> symfony/mailer ### # MAILER_DSN=smtp://localhost ###< symfony/mailer ### + +###> symfony/mercure-bundle ### +# See https://symfony.com/doc/current/mercure.html#configuration +# The URL of the Mercure hub, used by the app to publish updates (can be a local URL) +MERCURE_URL=https://example.com/.well-known/mercure +# The public URL of the Mercure hub, used by the browser to connect +MERCURE_PUBLIC_URL=https://example.com/.well-known/mercure +# The secret used to sign the JWTs +MERCURE_JWT_SECRET="!ChangeThisMercureHubJWTSecretKey!" +###< symfony/mercure-bundle ### diff --git a/assets/js/mine-seeker.js b/assets/js/mine-seeker.js index 41d245e..cfb2252 100644 --- a/assets/js/mine-seeker.js +++ b/assets/js/mine-seeker.js @@ -6,7 +6,6 @@ ReactDOM.render( , document.getElementById("mine-wrapper"), ); diff --git a/assets/js/mine-seeker/app.js b/assets/js/mine-seeker/app.js index c0118a8..c142249 100644 --- a/assets/js/mine-seeker/app.js +++ b/assets/js/mine-seeker/app.js @@ -11,16 +11,22 @@ class MineSeeker extends React.Component { this.state = { env: props.env, - ssl: props.ssl, gameInherited: '' !== props.gameId, gameAssoc: gameAssoc, channel: channel, - session: null, - createGrid: false, stepCache: [], connectionLost: false, end: false, }; + + /** SSE connection (not React state — no re-render needed) */ + this.eventSource = null; + + /** + * Users from GET /api/game/connect (inherited game). + * Passed to wSubscribe so it can determine the local player's colour. + */ + this.rpcUsers = null; } currectGridSize() { @@ -37,9 +43,6 @@ class MineSeeker extends React.Component { /** * STEP - * - * @param coords - * @returns {{red: *, blue: *}} */ makePointsCalcAndStep(coords) { let users = this.refs.gridControl.refs.userControl, @@ -63,14 +66,10 @@ class MineSeeker extends React.Component { /** * START - * - * @param payload */ makeGameStart(payload) { - /** every time the blue starts */ this.refs.gridControl.refs.userControl.setState({ activePlayer: 1 }); - /** Set up player names w/ server data */ this.refs.gridControl.refs.userControl.refs.red.setState({ name: '' !== payload.users.red ? payload.users.red : payload.users.redAnon, }); @@ -88,10 +87,6 @@ class MineSeeker extends React.Component { /** * THE END - * - * @param bluePoints - * @param redPoints - * @param resign */ makeGameEndIfItEnds(bluePoints, redPoints, resign = false) { let redWins = 25 < redPoints, @@ -109,7 +104,6 @@ class MineSeeker extends React.Component { } this.refs.gridControl.showLeftMines(); - this.refs.gridControl.refs.userControl.setState({ activePlayer: false }); this.refs.gridControl.refs.userControl.refs.red.setState({ desc: '' }); this.refs.gridControl.refs.userControl.refs.blue.setState({ desc: '' }); @@ -128,25 +122,19 @@ class MineSeeker extends React.Component { }); this.setState({ end: true }); - this.makeGameEndIfItEnds(0, 0, true); } clickResign() { - /** PUBLISH */ - this.state.session.publish(this.state.channel, { - 'resign': this.refs.gridControl.refs.userControl.state.activePlayer ? 'blue' : 'red', - }); + let resignColor = this.refs.gridControl.refs.userControl.state.activePlayer ? 'blue' : 'red'; + this.publishStep({ resign: resignColor }); this.resignProcess(this.refs.gridControl.state.webPlayer); } clickResignCancel() { - this.refs.gridControl.setState({ - overlay: false, - }); + this.refs.gridControl.setState({ overlay: false }); } - /** RESIGN */ resign() { let users = this.refs.gridControl.refs.userControl, activePlayer = users.state.activePlayer ? 'blue' : 'red'; @@ -165,11 +153,133 @@ class MineSeeker extends React.Component { } } - wInit(session, gridServer, gridClient) { - this.setState({ session: session }); + // ------------------------------------------------------------------ // + // Mercure message handlers (same logic as former WAMP callbacks) + // ------------------------------------------------------------------ // - /** save session to GridControl */ - /** render grid fields - @see #12 */ + wSubscribe(payload, rpcUsers = null) { + 'dev' === this.state.env && console.info( + ('undefined' !== typeof payload.user ? payload.user : 'user') + ' has been subscribed to the channel!', + ); + + let firstUser = !rpcUsers; + + null === this.refs.gridControl.state.webPlayer && this.refs.gridControl.setState({ + webPlayer: payload.user === payload.users.blue + || ( + firstUser && '' !== payload.users.blueAnon + || !firstUser && ('' === rpcUsers.blueAnon && '' === rpcUsers.blue) + ) + ? 'blue' : 'red', + }); + + (900 > $(document).width()) && this.currectGridSize(); + + if ( + 2 === payload.userCnt + && ( + !this.state.connectionLost + || this.state.connectionLost && false === this.refs.gridControl.refs.userControl.state.activePlayer && !this.state.end + ) + ) { + this.makeGameStart(payload); + } + } + + wUnsubscribe(payload) { + 'dev' === this.state.env && console.info(payload.msg); + + this.refs.gridControl.setState({ + overlay: true, + overlayTitle: 'The connection has been lost w/ your friend...', + overlaySubTitle: 'Please, restart the game!', + }); + } + + wTopic(payload) { + if (this.refs.gridControl.state.webPlayer !== payload.data.player) { + if (null === payload.data.resign) { + 'dev' === this.state.env && console.warn(payload.user + ' has been stepped to coords: ' + payload.data.coords[0] + ', ' + payload.data.coords[1]); + 'dev' === this.state.env && console.warn('Opponent stepped: Auto-Step process'); + + this.refs.gridControl.refs.userControl.setState({ bombSelected: payload.data.bomb }); + + let points = this.makePointsCalcAndStep(payload.data.coords); + this.makeGameEndIfItEnds(points.blue, points.red); + } else { + this.resignProcess(payload.data.resign); + } + } + } + + // ------------------------------------------------------------------ // + // Mercure / SSE connection + // ------------------------------------------------------------------ // + + /** + * Dispatches every incoming SSE message. + * Distinguishes subscription events, game-step events, and disconnect events + * using the same payload shape as the former WAMP broadcast. + */ + handleMercureMessage(payload) { + let isTopicEvent = 'undefined' !== typeof payload.data; + let isNotUnsubscribe = 'undefined' === typeof payload.msg; + + if (isTopicEvent) { + this.wTopic(payload); + } else if (isNotUnsubscribe) { + this.wSubscribe(payload, this.rpcUsers); + } else { + this.wUnsubscribe(payload); + } + + /** Reconnection: replay cached steps once both players are back */ + if (2 === payload.userCnt && this.state.connectionLost) { + 'dev' === this.state.env && console.info('Reconnection process'); + + let cache = this.state.stepCache; + cache.forEach(item => this.publishStep(item)); + this.setState({ connectionLost: false, stepCache: [] }); + } + } + + openEventSource() { + const wrapper = document.getElementById('mine-wrapper'); + const hubUrl = wrapper.dataset.mercureHubUrl; + const subscriberJwt = wrapper.dataset.mercureSubscriberJwt; + + const url = new URL(hubUrl, window.location.origin); + url.searchParams.append('topic', this.state.channel); + if (subscriberJwt) { + url.searchParams.append('authorization', subscriberJwt); + } + + if (this.eventSource) { + this.eventSource.close(); + } + + this.eventSource = new EventSource(url.toString()); + + this.eventSource.onmessage = event => { + this.handleMercureMessage(JSON.parse(event.data)); + }; + + this.eventSource.onopen = () => { + 'dev' === this.state.env && console.info('SSE connection opened'); + + if (this.state.connectionLost) { + 'dev' === this.state.env && console.info('SSE reconnected — rejoining channel'); + this.joinGame(); + } + }; + + this.eventSource.onerror = () => { + 'dev' === this.state.env && console.error('SSE connection error / lost'); + this.setState({ connectionLost: true }); + }; + } + + wInit(gridServer, gridClient) { this.refs.gridControl.setState({ grid: this.state.gameInherited ? gridServer : gridClient, channel: this.state.channel, @@ -207,177 +317,86 @@ class MineSeeker extends React.Component { }); } - wSubscribe(payload, rpcUsers = null) { - 'dev' === this.state.env && console.info( - ('undefined' !== typeof payload.user ? payload.user : 'user') + ' has been subscribed to the channel!', - ); - - let firstUser = !rpcUsers; - - null === this.refs.gridControl.state.webPlayer && this.refs.gridControl.setState({ - webPlayer: payload.user === payload.users.blue - || ( - firstUser && '' !== payload.users.blueAnon - || !firstUser && ('' === rpcUsers.blueAnon && '' === rpcUsers.blue) - ) - ? 'blue' : 'red', - }); - - /** rwd */ - (900 > $(document).width()) && this.currectGridSize(); - - /** every user has been came */ - if ( - 2 === payload.userCnt - && ( - !this.state.connectionLost - || this.state.connectionLost && false === this.refs.gridControl.refs.userControl.state.activePlayer && !this.state.end - ) - ) { - this.makeGameStart(payload); - } + /** POST /api/game/join — register this player, broadcast subscription event via Mercure */ + joinGame() { + return fetch('/api/game/join/' + this.state.gameAssoc, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + }).catch(e => 'dev' === this.state.env && console.error('Join error', e)); } - wUnsubscribe(payload) { - 'dev' === this.state.env && console.info(payload.msg); - - this.refs.gridControl.setState({ - overlay: true, - overlayTitle: 'The connection has been lost w/ your friend...', - overlaySubTitle: 'Please, restart the game!', - }); + /** POST /api/game/step — persist a move and fan it out via Mercure */ + publishStep(dataPack) { + return fetch('/api/game/step/' + this.state.gameAssoc, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(dataPack), + }).catch(e => 'dev' === this.state.env && console.error('Step error', e)); } - wTopic(payload) { - /** Auto-Step if this player is not the current user */ - if (this.refs.gridControl.state.webPlayer !== payload.data.player) { - if (null === payload.data.resign) { - 'dev' === this.state.env && console.warn(payload.user + ' has been stepped to coords: ' + payload.data.coords[0] + ', ' + payload.data.coords[1]); - 'dev' === this.state.env && console.warn('Opponent stepped: Auto-Step process'); + // ------------------------------------------------------------------ // + // Lifecycle + // ------------------------------------------------------------------ // - this.refs.gridControl.refs.userControl.setState({ bombSelected: payload.data.bomb }); + async componentDidMount() { + if (!this.state.connectionLost) { + let gridClient = this.state.gameInherited ? null : new Grid().state.grid; - /** STEP */ - let points = this.makePointsCalcAndStep(payload.data.coords); + try { + if (this.state.gameInherited) { + /** Fetch existing grid and player info */ + const resp = await fetch('/api/game/connect/' + this.state.gameAssoc); + const b64 = await resp.text(); + const serverData = JSON.parse(window.atob(b64)); - /** THE END */ - this.makeGameEndIfItEnds(points.blue, points.red); - } else { - /** RESIGN */ - /** THE END */ - this.resignProcess(payload.data.resign); - } - } - } - - /** Connect - Subscribe */ - subscribe(rpcUsers = null) { - this.state.session.subscribe( - this.state.channel, - (uri, payload) => { - let isTopicEvent = 'undefined' !== typeof payload.data, - isNotUnsubscribe = 'undefined' === typeof payload.msg; - - /** CONNECTION */ - if (isTopicEvent) { - this.wTopic(payload); - } else { - if (isNotUnsubscribe) { - this.wSubscribe(payload, rpcUsers); - } else { - this.wUnsubscribe(payload); + if ('undefined' === typeof serverData.grid || null === serverData.grid) { + this.refs.gridControl.setState({ + overlay: true, + overlayTitle: 'This channel does not exists!', + overlaySubTitle: Restart game!, + }); + console.error('This channel does not exists!'); + return; } + + this.rpcUsers = serverData.users; + this.openEventSource(); + this.wInit(serverData.grid, null); + + } else { + /** Create the game record with this client's grid */ + await fetch('/api/game/start', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + grid: window.btoa(JSON.stringify(gridClient)), + gameAssoc: this.state.gameAssoc, + }), + }); + + this.openEventSource(); + this.wInit(null, gridClient); } - /** RECONNECTION */ - if (2 === payload.userCnt && this.state.connectionLost) { - 'dev' === this.state.env && console.info('Reconnection process'); + 'dev' === this.state.env && console.info('Connection initialised — joining channel'); + await this.joinGame(); - /** PUBLISH */ - let cache = this.state.stepCache; - cache.forEach(item => this.state.session.publish(this.state.channel, item)); - this.setState({ connectionLost: false, stepCache: [] }); - } - }); - } - - connectWithWebsocket() { - /** Create Websocket w/ Bahnhof.js */ - let websocket = WS.connect( - ('true' === this.state.ssl ? 'wss' : 'ws') + '://' + window.location.hostname + '/ws/', - ); - - /** - * Connect - * Session is an Autobahn JS WAMP session. - */ - websocket.on('socket/connect', session => { - 'dev' === this.state.env && console.info('Successfully connected to the Server!'); - - if (!this.state.connectionLost) { - let gridClient = this.state.gameInherited || new Grid().state.grid; - - /** - * Connect - RPC - * Send grid information to the server - */ - session - .call( - this.state.gameInherited ? 'mineseeker-rpc/connectGame' : 'mineseeker-rpc/startGame', - this.state.gameInherited ? this.state.gameAssoc : [window.btoa(JSON.stringify(gridClient)), this.state.gameAssoc], - ) - .then( - data => { - 'dev' === this.state.env && console.info('RPC has been called'); - - let serverData = true !== data[0] - ? JSON.parse(window.atob(data)) - : data; - - /** Check the grid if the user is inherited @see #30 */ - if ((this.state.gameInherited && 'undefined' !== typeof serverData.grid) || !this.state.gameInherited) { - this.wInit(session, serverData.grid, gridClient); - this.subscribe(this.state.gameInherited && serverData.users); - } else { - this.refs.gridControl.setState({ - overlay: true, - overlayTitle: 'This channel does not exists!', - overlaySubTitle: Restart game!, - }); - - console.error('This channel does not exists!'); - } - }, - (error, desc) => 'dev' === this.state.env && console.error(['RPC Error', error, desc]), - ); - } else { - this.setState({ session: session }); - this.subscribe(); + } catch (e) { + 'dev' === this.state.env && console.error('Connection error', e); + setTimeout(() => this.componentDidMount(), 500); } - }); - /** - * DisConnect - * Error provides us with some insight into the disconnection: error.reason and error.code - */ - websocket.on('socket/disconnect', error => { - 'dev' === this.state.env && console.error('Disconnected for ' + error.reason + ' with code ' + error.code); + } else { + /** Hard-reconnect path */ + this.openEventSource(); + } - 6 === error.code && this.setState({ connectionLost: true }); - 3 === error.code && setTimeout(this.componentDidMount.bind(this), 500); + /** Notify the server when the player closes / navigates away */ + window.addEventListener('pagehide', () => { + navigator.sendBeacon('/api/game/leave/' + this.state.gameAssoc); }); } - /** After rendering */ - componentDidMount() { - this.connectWithWebsocket(); - } - - /** - * Cache the steps unless reconnection - * - * @param dataPack - */ cachePublish(dataPack) { let cache = this.state.stepCache; cache.push(dataPack); @@ -387,30 +406,24 @@ class MineSeeker extends React.Component { onClick(coords) { let activePlayer = this.refs.gridControl.refs.userControl.state.activePlayer ? 'blue' : 'red'; - /** if the clicked field is NEVER CLICKED */ if (this.refs.gridControl.checkFieldHasBeenNeverClicked(coords[0], coords[1])) { - /** Player step and it is the current player */ if (activePlayer === this.refs.gridControl.state.webPlayer) { - /** STEP */ let points = this.makePointsCalcAndStep(coords); - - /** THE END */ this.makeGameEndIfItEnds(points.blue, points.red); let dataPack = { - 'coords': coords, - 'player': activePlayer, - 'bomb': this.refs.gridControl.refs.userControl.state.bombSelected, - 'redPoints': points.red, - 'bluePoints': points.blue, - 'resign': null, - 'redExplodedBomb': 'red' === activePlayer && this.refs.gridControl.refs.userControl.state.bombSelected, - 'blueExplodedBomb': 'blue' === activePlayer && this.refs.gridControl.refs.userControl.state.bombSelected, + coords: coords, + player: activePlayer, + bomb: this.refs.gridControl.refs.userControl.state.bombSelected, + redPoints: points.red, + bluePoints: points.blue, + resign: null, + redExplodedBomb: 'red' === activePlayer && this.refs.gridControl.refs.userControl.state.bombSelected, + blueExplodedBomb: 'blue' === activePlayer && this.refs.gridControl.refs.userControl.state.bombSelected, }; - /** PUBLISH */ !this.state.connectionLost - ? this.state.session.publish(this.state.channel, dataPack) + ? this.publishStep(dataPack) : this.cachePublish(dataPack); } } @@ -428,4 +441,4 @@ class MineSeeker extends React.Component { } } -export default MineSeeker; +export default MineSeeker; \ No newline at end of file diff --git a/assets/js/mine-seeker/grid/grid-control.js b/assets/js/mine-seeker/grid/grid-control.js index 3ad8677..7a7de85 100644 --- a/assets/js/mine-seeker/grid/grid-control.js +++ b/assets/js/mine-seeker/grid/grid-control.js @@ -173,8 +173,11 @@ class GridControl extends React.Component { inactivePlayer = userControl.state.activePlayer ? 'red' : 'blue'; if ( - userControl.state.bombSelected && idx === (max - 1) - || !idx && !userControl.state.bombSelected && 'm' !== currentObject + userControl.state.bombSelected + && idx === (max - 1) + || !idx + && !userControl.state.bombSelected + && 'm' !== currentObject ) { userControl.setState({ activePlayer: userControl.state.activePlayer ? 0 : 1, diff --git a/composer.json b/composer.json index 6fb2dc6..f502655 100644 --- a/composer.json +++ b/composer.json @@ -9,11 +9,13 @@ "doctrine/doctrine-bundle": ">=2.11 <2.14", "doctrine/doctrine-migrations-bundle": "^3.0", "doctrine/orm": "^2.6", - "gos/web-socket-bundle": "^3.0", "symfony/console": "6.4.*", "symfony/flex": "^2.10.0", "symfony/framework-bundle": "6.4.*", + "symfony/http-client": "6.4.*", "symfony/mailer": "6.4.*", + "symfony/mercure": "^0.6", + "symfony/mercure-bundle": "*", "symfony/monolog-bundle": "^3.8", "symfony/security-bundle": "6.4.*", "symfony/translation": "6.4.*", @@ -22,6 +24,7 @@ "symfony/yaml": "6.4.*" }, "require-dev": { + "firebase/php-jwt": "^7.0", "roave/security-advisories": "dev-master", "symfony/dotenv": "6.4.*", "symfony/maker-bundle": "^1.5", diff --git a/composer.lock b/composer.lock index e47e3c3..9924347 100644 --- a/composer.lock +++ b/composer.lock @@ -4,71 +4,8 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "ca1c9aca666ca6afdb45abc43022ffa5", + "content-hash": "c77d12baceb4246bf494147134d62712", "packages": [ - { - "name": "cboden/ratchet", - "version": "v0.4.4", - "source": { - "type": "git", - "url": "https://github.com/ratchetphp/Ratchet.git", - "reference": "5012dc954541b40c5599d286fd40653f5716a38f" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/ratchetphp/Ratchet/zipball/5012dc954541b40c5599d286fd40653f5716a38f", - "reference": "5012dc954541b40c5599d286fd40653f5716a38f", - "shasum": "" - }, - "require": { - "guzzlehttp/psr7": "^1.7|^2.0", - "php": ">=5.4.2", - "ratchet/rfc6455": "^0.3.1", - "react/event-loop": ">=0.4", - "react/socket": "^1.0 || ^0.8 || ^0.7 || ^0.6 || ^0.5", - "symfony/http-foundation": "^2.6|^3.0|^4.0|^5.0|^6.0", - "symfony/routing": "^2.6|^3.0|^4.0|^5.0|^6.0" - }, - "require-dev": { - "phpunit/phpunit": "~4.8" - }, - "type": "library", - "autoload": { - "psr-4": { - "Ratchet\\": "src/Ratchet" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Chris Boden", - "email": "cboden@gmail.com", - "role": "Developer" - }, - { - "name": "Matt Bonneau", - "role": "Developer" - } - ], - "description": "PHP WebSocket library", - "homepage": "http://socketo.me", - "keywords": [ - "Ratchet", - "WebSockets", - "server", - "sockets", - "websocket" - ], - "support": { - "chat": "https://gitter.im/reactphp/reactphp", - "issues": "https://github.com/ratchetphp/Ratchet/issues", - "source": "https://github.com/ratchetphp/Ratchet/tree/v0.4.4" - }, - "time": "2021-12-14T00:20:41+00:00" - }, { "name": "doctrine/cache", "version": "2.2.0", @@ -1457,423 +1394,77 @@ "time": "2025-03-06T22:45:56+00:00" }, { - "name": "evenement/evenement", - "version": "v3.0.2", + "name": "lcobucci/jwt", + "version": "5.6.0", "source": { "type": "git", - "url": "https://github.com/igorw/evenement.git", - "reference": "0a16b0d71ab13284339abb99d9d2bd813640efbc" + "url": "https://github.com/lcobucci/jwt.git", + "reference": "bb3e9f21e4196e8afc41def81ef649c164bca25e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/igorw/evenement/zipball/0a16b0d71ab13284339abb99d9d2bd813640efbc", - "reference": "0a16b0d71ab13284339abb99d9d2bd813640efbc", + "url": "https://api.github.com/repos/lcobucci/jwt/zipball/bb3e9f21e4196e8afc41def81ef649c164bca25e", + "reference": "bb3e9f21e4196e8afc41def81ef649c164bca25e", "shasum": "" }, "require": { - "php": ">=7.0" + "ext-openssl": "*", + "ext-sodium": "*", + "php": "~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0", + "psr/clock": "^1.0" }, "require-dev": { - "phpunit/phpunit": "^9 || ^6" - }, - "type": "library", - "autoload": { - "psr-4": { - "Evenement\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Igor Wiedler", - "email": "igor@wiedler.ch" - } - ], - "description": "Événement is a very simple event dispatching library for PHP", - "keywords": [ - "event-dispatcher", - "event-emitter" - ], - "support": { - "issues": "https://github.com/igorw/evenement/issues", - "source": "https://github.com/igorw/evenement/tree/v3.0.2" - }, - "time": "2023-08-08T05:53:35+00:00" - }, - { - "name": "gos/pubsub-router-bundle", - "version": "v2.8.0", - "source": { - "type": "git", - "url": "https://github.com/GeniusesOfSymfony/PubSubRouterBundle.git", - "reference": "f91b170d521c19e5fc3ec1c401652536c2d0f007" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/GeniusesOfSymfony/PubSubRouterBundle/zipball/f91b170d521c19e5fc3ec1c401652536c2d0f007", - "reference": "f91b170d521c19e5fc3ec1c401652536c2d0f007", - "shasum": "" - }, - "require": { - "php": "^7.2 || ^8.0", - "symfony/config": "^4.4.42 || ^5.4 || ^6.0", - "symfony/console": "^4.4.42 || ^5.4 || ^6.0", - "symfony/dependency-injection": "^4.4.42 || ^5.4 || ^6.0", - "symfony/deprecation-contracts": "^2.1 || ^3.0", - "symfony/http-foundation": "^4.4.42 || ^5.4 || ^6.0", - "symfony/http-kernel": "^4.4.42 || ^5.4 || ^6.0", - "symfony/polyfill-php80": "^1.22", - "symfony/yaml": "^4.4.42 || ^5.4 || ^6.0" - }, - "require-dev": { - "matthiasnoback/symfony-dependency-injection-test": "^4.1.2", - "phpstan/extension-installer": "^1.1", - "phpstan/phpstan": "1.8.5", - "phpstan/phpstan-phpunit": "1.1.1", - "phpstan/phpstan-symfony": "1.2.13", - "phpunit/phpunit": "^8.5 || ^9.3", - "psr/container": "^1.0 || ^2.0", - "symfony/phpunit-bridge": "^5.4 || ^6.0" - }, - "type": "symfony-bundle", - "autoload": { - "psr-4": { - "Gos\\Bundle\\PubSubRouterBundle\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Johann Saunier", - "email": "johann_27@hotmail.fr" - } - ], - "description": "Symfony PubSub Router Bundle", - "homepage": "https://github.com/GeniusesOfSymfony/PubSubRouterBundle", - "keywords": [ - "PubSub Bundle", - "WAMP", - "bundle", - "pubsub", - "redis", - "zmq" - ], - "support": { - "issues": "https://github.com/GeniusesOfSymfony/PubSubRouterBundle/issues", - "source": "https://github.com/GeniusesOfSymfony/PubSubRouterBundle/tree/v2.8.0" - }, - "funding": [ - { - "url": "https://github.com/mbabker", - "type": "github" - } - ], - "time": "2022-09-20T01:46:13+00:00" - }, - { - "name": "gos/web-socket-bundle", - "version": "v3.15.0", - "source": { - "type": "git", - "url": "https://github.com/GeniusesOfSymfony/WebSocketBundle.git", - "reference": "1c718de6434b87e28c65376fa61b159d24053aae" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/GeniusesOfSymfony/WebSocketBundle/zipball/1c718de6434b87e28c65376fa61b159d24053aae", - "reference": "1c718de6434b87e28c65376fa61b159d24053aae", - "shasum": "" - }, - "require": { - "cboden/ratchet": "^0.4.4", - "gos/pubsub-router-bundle": "^2.2", - "gos/websocket-client": "^1.1", - "php": "^7.4 || ^8.0", - "psr/log": "^1.1 || ^2.0 || ^3.0", - "react/event-loop": "^1.2", - "react/socket": "^1.9", - "symfony/config": "^4.4 || ^5.4 || ^6.0", - "symfony/console": "^4.4 || ^5.4 || ^6.0", - "symfony/dependency-injection": "^4.4 || ^5.4 || ^6.0", - "symfony/deprecation-contracts": "^2.1 || ^3.0", - "symfony/event-dispatcher": "^4.4 || ^5.4 || ^6.0", - "symfony/http-foundation": "^4.4 || ^5.4 || ^6.0", - "symfony/http-kernel": "^4.4 || ^5.4 || ^6.0", - "symfony/polyfill-php80": "^1.15", - "symfony/security-core": "^4.4 || ^5.4 || ^6.0", - "symfony/serializer": "^4.4 || ^5.4 || ^6.0", - "symfony/string": "^5.4 || ^6.0", - "symfony/yaml": "^4.4 || ^5.4 || ^6.0" - }, - "conflict": { - "doctrine/cache": "<1.11", - "doctrine/dbal": "<2.13.1 || ~3.0.0", - "gos/react-amqp": "<0.3", - "symfony/cache": "<4.4 || >=5.0,<5.3", - "symfony/monolog-bundle": "<3.0", - "symfony/options-resolver": "<4.4 || >=5.0,<5.3", - "symfony/stopwatch": "<4.4 || >=5.0,<5.3", - "symfony/twig-bundle": "<4.4 || >=5.0,<5.3", - "symfony/web-profiler-bundle": "<4.4 || >=5.0,<5.3", - "twig/twig": "<1.36 || >=2.0,<2.6" - }, - "require-dev": { - "doctrine/cache": "^1.11 || ^2.0", - "doctrine/dbal": "^2.13.1 || ^3.1", - "matthiasnoback/symfony-dependency-injection-test": "^4.2", - "phpstan/extension-installer": "^1.1", - "phpstan/phpstan": "1.8.5", - "phpstan/phpstan-phpunit": "1.1.1", - "phpstan/phpstan-symfony": "1.2.13", - "phpunit/phpunit": "^9.5", - "symfony/cache": "^4.4 || ^5.4 || ^6.0", - "symfony/options-resolver": "^4.4 || ^5.4 || ^6.0", - "symfony/phpunit-bridge": "^5.4 || ^6.0", - "symfony/stopwatch": "^4.4 || ^5.4 || ^6.0", - "symfony/twig-bundle": "^4.4 || ^5.4 || ^6.0", - "symfony/web-profiler-bundle": "^4.4 || ^5.4 || ^6.0" + "infection/infection": "^0.29", + "lcobucci/clock": "^3.2", + "lcobucci/coding-standard": "^11.0", + "phpbench/phpbench": "^1.2", + "phpstan/extension-installer": "^1.2", + "phpstan/phpstan": "^1.10.7", + "phpstan/phpstan-deprecation-rules": "^1.1.3", + "phpstan/phpstan-phpunit": "^1.3.10", + "phpstan/phpstan-strict-rules": "^1.5.0", + "phpunit/phpunit": "^11.1" }, "suggest": { - "doctrine/cache": "to use doctrine/cache as a client driver", - "doctrine/dbal": "to use Doctrine ping services", - "ext-amqp": "* to use the amqp pusher", - "ext-pcntl": "* to handle process signals", - "ext-pdo": "* to use PDO ping services", - "gos/react-amqp": "to use the amqp server push handler", - "symfony/cache": "to use symfony/cache as an authentication storage driver", - "symfony/options-resolver": "to use the pushers", - "symfony/serializer": "to use the pushers", - "symfony/stopwatch": "to use the data collectors" - }, - "type": "symfony-bundle", - "autoload": { - "psr-4": { - "Gos\\Bundle\\WebSocketBundle\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Jeremy Dare", - "email": "jeremy.d.dare@gmail.com" - }, - { - "name": "Johann Saunier", - "email": "johann_27@hotmail.fr" - } - ], - "description": "Symfony Web Socket Bundle", - "homepage": "https://github.com/GeniusesOfSymfony/WebSocketBundle", - "keywords": [ - "Ratchet", - "WAMP", - "Web Socket Bundle", - "io", - "websocket" - ], - "support": { - "issues": "https://github.com/GeniusesOfSymfony/WebSocketBundle/issues", - "source": "https://github.com/GeniusesOfSymfony/WebSocketBundle/tree/v3.15.0" - }, - "funding": [ - { - "url": "https://github.com/mbabker", - "type": "github" - } - ], - "time": "2022-09-20T02:03:09+00:00" - }, - { - "name": "gos/websocket-client", - "version": "v1.5.0", - "source": { - "type": "git", - "url": "https://github.com/GeniusesOfSymfony/WebSocketPhpClient.git", - "reference": "97354f2b970f59cb6d511307ef7af5f761b684ff" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/GeniusesOfSymfony/WebSocketPhpClient/zipball/97354f2b970f59cb6d511307ef7af5f761b684ff", - "reference": "97354f2b970f59cb6d511307ef7af5f761b684ff", - "shasum": "" - }, - "require": { - "ext-json": "*", - "php": "^7.2 || ^8.0", - "psr/log": "^1.0 || ^2.0 || ^3.0", - "symfony/deprecation-contracts": "^2.1 || ^3.0", - "symfony/options-resolver": "^4.4 || ^5.3 || ^6.0" - }, - "require-dev": { - "cboden/ratchet": "^0.4.3", - "phpstan/extension-installer": "^1.1", - "phpstan/phpstan": "1.2.0", - "phpstan/phpstan-phpunit": "1.0.0", - "phpunit/phpunit": "^8.5 || ^9.5" + "lcobucci/clock": ">= 3.2" }, "type": "library", "autoload": { "psr-4": { - "Gos\\Component\\WebSocketClient\\": "src/" + "Lcobucci\\JWT\\": "src" } }, "notification-url": "https://packagist.org/downloads/", "license": [ - "MIT" + "BSD-3-Clause" ], "authors": [ { - "name": "Martin Bažík", - "email": "martin@bazo.sk" - }, - { - "name": "Johann Saunier", - "email": "johann_27@hotmail.fr" + "name": "Luís Cobucci", + "email": "lcobucci@gmail.com", + "role": "Developer" } ], - "description": "WAMP client in PHP", + "description": "A simple library to work with JSON Web Token and JSON Web Signature", "keywords": [ - "Ratchet", - "WAMP", - "websocket" + "JWS", + "jwt" ], "support": { - "issues": "https://github.com/GeniusesOfSymfony/WebSocketPhpClient/issues", - "source": "https://github.com/GeniusesOfSymfony/WebSocketPhpClient/tree/v1.5.0" + "issues": "https://github.com/lcobucci/jwt/issues", + "source": "https://github.com/lcobucci/jwt/tree/5.6.0" }, "funding": [ { - "url": "https://github.com/mbabker", - "type": "github" - } - ], - "time": "2021-11-24T03:24:48+00:00" - }, - { - "name": "guzzlehttp/psr7", - "version": "2.9.0", - "source": { - "type": "git", - "url": "https://github.com/guzzle/psr7.git", - "reference": "7d0ed42f28e42d61352a7a79de682e5e67fec884" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/guzzle/psr7/zipball/7d0ed42f28e42d61352a7a79de682e5e67fec884", - "reference": "7d0ed42f28e42d61352a7a79de682e5e67fec884", - "shasum": "" - }, - "require": { - "php": "^7.2.5 || ^8.0", - "psr/http-factory": "^1.0", - "psr/http-message": "^1.1 || ^2.0", - "ralouphie/getallheaders": "^3.0" - }, - "provide": { - "psr/http-factory-implementation": "1.0", - "psr/http-message-implementation": "1.0" - }, - "require-dev": { - "bamarni/composer-bin-plugin": "^1.8.2", - "http-interop/http-factory-tests": "0.9.0", - "jshttp/mime-db": "1.54.0.1", - "phpunit/phpunit": "^8.5.44 || ^9.6.25" - }, - "suggest": { - "laminas/laminas-httphandlerrunner": "Emit PSR-7 responses" - }, - "type": "library", - "extra": { - "bamarni-bin": { - "bin-links": true, - "forward-command": false - } - }, - "autoload": { - "psr-4": { - "GuzzleHttp\\Psr7\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Graham Campbell", - "email": "hello@gjcampbell.co.uk", - "homepage": "https://github.com/GrahamCampbell" - }, - { - "name": "Michael Dowling", - "email": "mtdowling@gmail.com", - "homepage": "https://github.com/mtdowling" - }, - { - "name": "George Mponos", - "email": "gmponos@gmail.com", - "homepage": "https://github.com/gmponos" - }, - { - "name": "Tobias Nyholm", - "email": "tobias.nyholm@gmail.com", - "homepage": "https://github.com/Nyholm" - }, - { - "name": "Márk Sági-Kazár", - "email": "mark.sagikazar@gmail.com", - "homepage": "https://github.com/sagikazarmark" - }, - { - "name": "Tobias Schultze", - "email": "webmaster@tubo-world.de", - "homepage": "https://github.com/Tobion" - }, - { - "name": "Márk Sági-Kazár", - "email": "mark.sagikazar@gmail.com", - "homepage": "https://sagikazarmark.hu" - } - ], - "description": "PSR-7 message implementation that also provides common utility methods", - "keywords": [ - "http", - "message", - "psr-7", - "request", - "response", - "stream", - "uri", - "url" - ], - "support": { - "issues": "https://github.com/guzzle/psr7/issues", - "source": "https://github.com/guzzle/psr7/tree/2.9.0" - }, - "funding": [ - { - "url": "https://github.com/GrahamCampbell", + "url": "https://github.com/lcobucci", "type": "github" }, { - "url": "https://github.com/Nyholm", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/psr7", - "type": "tidelift" + "url": "https://www.patreon.com/lcobucci", + "type": "patreon" } ], - "time": "2026-03-10T16:41:02+00:00" + "time": "2025-10-17T11:30:53+00:00" }, { "name": "monolog/monolog", @@ -2179,76 +1770,24 @@ "time": "2019-01-08T18:20:26+00:00" }, { - "name": "psr/http-factory", - "version": "1.1.0", + "name": "psr/link", + "version": "2.0.1", "source": { "type": "git", - "url": "https://github.com/php-fig/http-factory.git", - "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a" + "url": "https://github.com/php-fig/link.git", + "reference": "84b159194ecfd7eaa472280213976e96415433f7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-fig/http-factory/zipball/2b4765fddfe3b508ac62f829e852b1501d3f6e8a", - "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "url": "https://api.github.com/repos/php-fig/link/zipball/84b159194ecfd7eaa472280213976e96415433f7", + "reference": "84b159194ecfd7eaa472280213976e96415433f7", "shasum": "" }, "require": { - "php": ">=7.1", - "psr/http-message": "^1.0 || ^2.0" + "php": ">=8.0.0" }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "1.0.x-dev" - } - }, - "autoload": { - "psr-4": { - "Psr\\Http\\Message\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "PHP-FIG", - "homepage": "https://www.php-fig.org/" - } - ], - "description": "PSR-17: Common interfaces for PSR-7 HTTP message factories", - "keywords": [ - "factory", - "http", - "message", - "psr", - "psr-17", - "psr-7", - "request", - "response" - ], - "support": { - "source": "https://github.com/php-fig/http-factory" - }, - "time": "2024-04-15T12:06:14+00:00" - }, - { - "name": "psr/http-message", - "version": "2.0", - "source": { - "type": "git", - "url": "https://github.com/php-fig/http-message.git", - "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/php-fig/http-message/zipball/402d35bcb92c70c026d1a6a9883f06b2ead23d71", - "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71", - "shasum": "" - }, - "require": { - "php": "^7.2 || ^8.0" + "suggest": { + "fig/link-util": "Provides some useful PSR-13 utilities" }, "type": "library", "extra": { @@ -2258,7 +1797,7 @@ }, "autoload": { "psr-4": { - "Psr\\Http\\Message\\": "src/" + "Psr\\Link\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", @@ -2268,23 +1807,23 @@ "authors": [ { "name": "PHP-FIG", - "homepage": "https://www.php-fig.org/" + "homepage": "http://www.php-fig.org/" } ], - "description": "Common interface for HTTP messages", - "homepage": "https://github.com/php-fig/http-message", + "description": "Common interfaces for HTTP links", + "homepage": "https://github.com/php-fig/link", "keywords": [ "http", - "http-message", + "http-link", + "link", "psr", - "psr-7", - "request", - "response" + "psr-13", + "rest" ], "support": { - "source": "https://github.com/php-fig/http-message/tree/2.0" + "source": "https://github.com/php-fig/link/tree/2.0.1" }, - "time": "2023-04-04T09:54:51+00:00" + "time": "2021-03-11T23:00:27+00:00" }, { "name": "psr/log", @@ -2336,558 +1875,6 @@ }, "time": "2024-09-11T13:17:53+00:00" }, - { - "name": "ralouphie/getallheaders", - "version": "3.0.3", - "source": { - "type": "git", - "url": "https://github.com/ralouphie/getallheaders.git", - "reference": "120b605dfeb996808c31b6477290a714d356e822" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/ralouphie/getallheaders/zipball/120b605dfeb996808c31b6477290a714d356e822", - "reference": "120b605dfeb996808c31b6477290a714d356e822", - "shasum": "" - }, - "require": { - "php": ">=5.6" - }, - "require-dev": { - "php-coveralls/php-coveralls": "^2.1", - "phpunit/phpunit": "^5 || ^6.5" - }, - "type": "library", - "autoload": { - "files": [ - "src/getallheaders.php" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Ralph Khattar", - "email": "ralph.khattar@gmail.com" - } - ], - "description": "A polyfill for getallheaders.", - "support": { - "issues": "https://github.com/ralouphie/getallheaders/issues", - "source": "https://github.com/ralouphie/getallheaders/tree/develop" - }, - "time": "2019-03-08T08:55:37+00:00" - }, - { - "name": "ratchet/rfc6455", - "version": "v0.3.1", - "source": { - "type": "git", - "url": "https://github.com/ratchetphp/RFC6455.git", - "reference": "7c964514e93456a52a99a20fcfa0de242a43ccdb" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/ratchetphp/RFC6455/zipball/7c964514e93456a52a99a20fcfa0de242a43ccdb", - "reference": "7c964514e93456a52a99a20fcfa0de242a43ccdb", - "shasum": "" - }, - "require": { - "guzzlehttp/psr7": "^2 || ^1.7", - "php": ">=5.4.2" - }, - "require-dev": { - "phpunit/phpunit": "^5.7", - "react/socket": "^1.3" - }, - "type": "library", - "autoload": { - "psr-4": { - "Ratchet\\RFC6455\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Chris Boden", - "email": "cboden@gmail.com", - "role": "Developer" - }, - { - "name": "Matt Bonneau", - "role": "Developer" - } - ], - "description": "RFC6455 WebSocket protocol handler", - "homepage": "http://socketo.me", - "keywords": [ - "WebSockets", - "rfc6455", - "websocket" - ], - "support": { - "chat": "https://gitter.im/reactphp/reactphp", - "issues": "https://github.com/ratchetphp/RFC6455/issues", - "source": "https://github.com/ratchetphp/RFC6455/tree/v0.3.1" - }, - "time": "2021-12-09T23:20:49+00:00" - }, - { - "name": "react/cache", - "version": "v1.2.0", - "source": { - "type": "git", - "url": "https://github.com/reactphp/cache.git", - "reference": "d47c472b64aa5608225f47965a484b75c7817d5b" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/reactphp/cache/zipball/d47c472b64aa5608225f47965a484b75c7817d5b", - "reference": "d47c472b64aa5608225f47965a484b75c7817d5b", - "shasum": "" - }, - "require": { - "php": ">=5.3.0", - "react/promise": "^3.0 || ^2.0 || ^1.1" - }, - "require-dev": { - "phpunit/phpunit": "^9.5 || ^5.7 || ^4.8.35" - }, - "type": "library", - "autoload": { - "psr-4": { - "React\\Cache\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Christian Lück", - "email": "christian@clue.engineering", - "homepage": "https://clue.engineering/" - }, - { - "name": "Cees-Jan Kiewiet", - "email": "reactphp@ceesjankiewiet.nl", - "homepage": "https://wyrihaximus.net/" - }, - { - "name": "Jan Sorgalla", - "email": "jsorgalla@gmail.com", - "homepage": "https://sorgalla.com/" - }, - { - "name": "Chris Boden", - "email": "cboden@gmail.com", - "homepage": "https://cboden.dev/" - } - ], - "description": "Async, Promise-based cache interface for ReactPHP", - "keywords": [ - "cache", - "caching", - "promise", - "reactphp" - ], - "support": { - "issues": "https://github.com/reactphp/cache/issues", - "source": "https://github.com/reactphp/cache/tree/v1.2.0" - }, - "funding": [ - { - "url": "https://opencollective.com/reactphp", - "type": "open_collective" - } - ], - "time": "2022-11-30T15:59:55+00:00" - }, - { - "name": "react/dns", - "version": "v1.14.0", - "source": { - "type": "git", - "url": "https://github.com/reactphp/dns.git", - "reference": "7562c05391f42701c1fccf189c8225fece1cd7c3" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/reactphp/dns/zipball/7562c05391f42701c1fccf189c8225fece1cd7c3", - "reference": "7562c05391f42701c1fccf189c8225fece1cd7c3", - "shasum": "" - }, - "require": { - "php": ">=5.3.0", - "react/cache": "^1.0 || ^0.6 || ^0.5", - "react/event-loop": "^1.2", - "react/promise": "^3.2 || ^2.7 || ^1.2.1" - }, - "require-dev": { - "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36", - "react/async": "^4.3 || ^3 || ^2", - "react/promise-timer": "^1.11" - }, - "type": "library", - "autoload": { - "psr-4": { - "React\\Dns\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Christian Lück", - "email": "christian@clue.engineering", - "homepage": "https://clue.engineering/" - }, - { - "name": "Cees-Jan Kiewiet", - "email": "reactphp@ceesjankiewiet.nl", - "homepage": "https://wyrihaximus.net/" - }, - { - "name": "Jan Sorgalla", - "email": "jsorgalla@gmail.com", - "homepage": "https://sorgalla.com/" - }, - { - "name": "Chris Boden", - "email": "cboden@gmail.com", - "homepage": "https://cboden.dev/" - } - ], - "description": "Async DNS resolver for ReactPHP", - "keywords": [ - "async", - "dns", - "dns-resolver", - "reactphp" - ], - "support": { - "issues": "https://github.com/reactphp/dns/issues", - "source": "https://github.com/reactphp/dns/tree/v1.14.0" - }, - "funding": [ - { - "url": "https://opencollective.com/reactphp", - "type": "open_collective" - } - ], - "time": "2025-11-18T19:34:28+00:00" - }, - { - "name": "react/event-loop", - "version": "v1.6.0", - "source": { - "type": "git", - "url": "https://github.com/reactphp/event-loop.git", - "reference": "ba276bda6083df7e0050fd9b33f66ad7a4ac747a" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/reactphp/event-loop/zipball/ba276bda6083df7e0050fd9b33f66ad7a4ac747a", - "reference": "ba276bda6083df7e0050fd9b33f66ad7a4ac747a", - "shasum": "" - }, - "require": { - "php": ">=5.3.0" - }, - "require-dev": { - "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36" - }, - "suggest": { - "ext-pcntl": "For signal handling support when using the StreamSelectLoop" - }, - "type": "library", - "autoload": { - "psr-4": { - "React\\EventLoop\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Christian Lück", - "email": "christian@clue.engineering", - "homepage": "https://clue.engineering/" - }, - { - "name": "Cees-Jan Kiewiet", - "email": "reactphp@ceesjankiewiet.nl", - "homepage": "https://wyrihaximus.net/" - }, - { - "name": "Jan Sorgalla", - "email": "jsorgalla@gmail.com", - "homepage": "https://sorgalla.com/" - }, - { - "name": "Chris Boden", - "email": "cboden@gmail.com", - "homepage": "https://cboden.dev/" - } - ], - "description": "ReactPHP's core reactor event loop that libraries can use for evented I/O.", - "keywords": [ - "asynchronous", - "event-loop" - ], - "support": { - "issues": "https://github.com/reactphp/event-loop/issues", - "source": "https://github.com/reactphp/event-loop/tree/v1.6.0" - }, - "funding": [ - { - "url": "https://opencollective.com/reactphp", - "type": "open_collective" - } - ], - "time": "2025-11-17T20:46:25+00:00" - }, - { - "name": "react/promise", - "version": "v3.3.0", - "source": { - "type": "git", - "url": "https://github.com/reactphp/promise.git", - "reference": "23444f53a813a3296c1368bb104793ce8d88f04a" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/reactphp/promise/zipball/23444f53a813a3296c1368bb104793ce8d88f04a", - "reference": "23444f53a813a3296c1368bb104793ce8d88f04a", - "shasum": "" - }, - "require": { - "php": ">=7.1.0" - }, - "require-dev": { - "phpstan/phpstan": "1.12.28 || 1.4.10", - "phpunit/phpunit": "^9.6 || ^7.5" - }, - "type": "library", - "autoload": { - "files": [ - "src/functions_include.php" - ], - "psr-4": { - "React\\Promise\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Jan Sorgalla", - "email": "jsorgalla@gmail.com", - "homepage": "https://sorgalla.com/" - }, - { - "name": "Christian Lück", - "email": "christian@clue.engineering", - "homepage": "https://clue.engineering/" - }, - { - "name": "Cees-Jan Kiewiet", - "email": "reactphp@ceesjankiewiet.nl", - "homepage": "https://wyrihaximus.net/" - }, - { - "name": "Chris Boden", - "email": "cboden@gmail.com", - "homepage": "https://cboden.dev/" - } - ], - "description": "A lightweight implementation of CommonJS Promises/A for PHP", - "keywords": [ - "promise", - "promises" - ], - "support": { - "issues": "https://github.com/reactphp/promise/issues", - "source": "https://github.com/reactphp/promise/tree/v3.3.0" - }, - "funding": [ - { - "url": "https://opencollective.com/reactphp", - "type": "open_collective" - } - ], - "time": "2025-08-19T18:57:03+00:00" - }, - { - "name": "react/socket", - "version": "v1.17.0", - "source": { - "type": "git", - "url": "https://github.com/reactphp/socket.git", - "reference": "ef5b17b81f6f60504c539313f94f2d826c5faa08" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/reactphp/socket/zipball/ef5b17b81f6f60504c539313f94f2d826c5faa08", - "reference": "ef5b17b81f6f60504c539313f94f2d826c5faa08", - "shasum": "" - }, - "require": { - "evenement/evenement": "^3.0 || ^2.0 || ^1.0", - "php": ">=5.3.0", - "react/dns": "^1.13", - "react/event-loop": "^1.2", - "react/promise": "^3.2 || ^2.6 || ^1.2.1", - "react/stream": "^1.4" - }, - "require-dev": { - "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36", - "react/async": "^4.3 || ^3.3 || ^2", - "react/promise-stream": "^1.4", - "react/promise-timer": "^1.11" - }, - "type": "library", - "autoload": { - "psr-4": { - "React\\Socket\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Christian Lück", - "email": "christian@clue.engineering", - "homepage": "https://clue.engineering/" - }, - { - "name": "Cees-Jan Kiewiet", - "email": "reactphp@ceesjankiewiet.nl", - "homepage": "https://wyrihaximus.net/" - }, - { - "name": "Jan Sorgalla", - "email": "jsorgalla@gmail.com", - "homepage": "https://sorgalla.com/" - }, - { - "name": "Chris Boden", - "email": "cboden@gmail.com", - "homepage": "https://cboden.dev/" - } - ], - "description": "Async, streaming plaintext TCP/IP and secure TLS socket server and client connections for ReactPHP", - "keywords": [ - "Connection", - "Socket", - "async", - "reactphp", - "stream" - ], - "support": { - "issues": "https://github.com/reactphp/socket/issues", - "source": "https://github.com/reactphp/socket/tree/v1.17.0" - }, - "funding": [ - { - "url": "https://opencollective.com/reactphp", - "type": "open_collective" - } - ], - "time": "2025-11-19T20:47:34+00:00" - }, - { - "name": "react/stream", - "version": "v1.4.0", - "source": { - "type": "git", - "url": "https://github.com/reactphp/stream.git", - "reference": "1e5b0acb8fe55143b5b426817155190eb6f5b18d" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/reactphp/stream/zipball/1e5b0acb8fe55143b5b426817155190eb6f5b18d", - "reference": "1e5b0acb8fe55143b5b426817155190eb6f5b18d", - "shasum": "" - }, - "require": { - "evenement/evenement": "^3.0 || ^2.0 || ^1.0", - "php": ">=5.3.8", - "react/event-loop": "^1.2" - }, - "require-dev": { - "clue/stream-filter": "~1.2", - "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36" - }, - "type": "library", - "autoload": { - "psr-4": { - "React\\Stream\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Christian Lück", - "email": "christian@clue.engineering", - "homepage": "https://clue.engineering/" - }, - { - "name": "Cees-Jan Kiewiet", - "email": "reactphp@ceesjankiewiet.nl", - "homepage": "https://wyrihaximus.net/" - }, - { - "name": "Jan Sorgalla", - "email": "jsorgalla@gmail.com", - "homepage": "https://sorgalla.com/" - }, - { - "name": "Chris Boden", - "email": "cboden@gmail.com", - "homepage": "https://cboden.dev/" - } - ], - "description": "Event-driven readable and writable streams for non-blocking I/O in ReactPHP", - "keywords": [ - "event-driven", - "io", - "non-blocking", - "pipe", - "reactphp", - "readable", - "stream", - "writable" - ], - "support": { - "issues": "https://github.com/reactphp/stream/issues", - "source": "https://github.com/reactphp/stream/tree/v1.4.0" - }, - "funding": [ - { - "url": "https://opencollective.com/reactphp", - "type": "open_collective" - } - ], - "time": "2024-06-11T12:45:25+00:00" - }, { "name": "symfony/asset", "version": "v6.4.34", @@ -4259,6 +3246,182 @@ ], "time": "2026-03-25T17:41:29+00:00" }, + { + "name": "symfony/http-client", + "version": "v6.4.36", + "source": { + "type": "git", + "url": "https://github.com/symfony/http-client.git", + "reference": "1baea3a592ec5ee1f58de6548a034268d4946db6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/http-client/zipball/1baea3a592ec5ee1f58de6548a034268d4946db6", + "reference": "1baea3a592ec5ee1f58de6548a034268d4946db6", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/log": "^1|^2|^3", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/http-client-contracts": "~3.4.4|^3.5.2", + "symfony/polyfill-php83": "^1.29", + "symfony/service-contracts": "^2.5|^3" + }, + "conflict": { + "php-http/discovery": "<1.15", + "symfony/http-foundation": "<6.3" + }, + "provide": { + "php-http/async-client-implementation": "*", + "php-http/client-implementation": "*", + "psr/http-client-implementation": "1.0", + "symfony/http-client-implementation": "3.0" + }, + "require-dev": { + "amphp/amp": "^2.5", + "amphp/http-client": "^4.2.1", + "amphp/http-tunnel": "^1.0", + "amphp/socket": "^1.1", + "guzzlehttp/promises": "^1.4|^2.0", + "nyholm/psr7": "^1.0", + "php-http/httplug": "^1.0|^2.0", + "psr/http-client": "^1.0", + "symfony/dependency-injection": "^5.4|^6.0|^7.0", + "symfony/http-kernel": "^5.4|^6.0|^7.0", + "symfony/messenger": "^5.4|^6.0|^7.0", + "symfony/process": "^5.4|^6.0|^7.0", + "symfony/stopwatch": "^5.4|^6.0|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\HttpClient\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides powerful methods to fetch HTTP resources synchronously or asynchronously", + "homepage": "https://symfony.com", + "keywords": [ + "http" + ], + "support": { + "source": "https://github.com/symfony/http-client/tree/v6.4.36" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-23T20:48:09+00:00" + }, + { + "name": "symfony/http-client-contracts", + "version": "v3.6.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/http-client-contracts.git", + "reference": "75d7043853a42837e68111812f4d964b01e5101c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/75d7043853a42837e68111812f4d964b01e5101c", + "reference": "75d7043853a42837e68111812f4d964b01e5101c", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.6-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\HttpClient\\": "" + }, + "exclude-from-classmap": [ + "/Test/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to HTTP clients", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/http-client-contracts/tree/v3.6.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-04-29T11:18:49+00:00" + }, { "name": "symfony/http-foundation", "version": "v6.4.35", @@ -4542,6 +3705,173 @@ ], "time": "2026-02-24T09:34:36+00:00" }, + { + "name": "symfony/mercure", + "version": "v0.6.5", + "source": { + "type": "git", + "url": "https://github.com/symfony/mercure.git", + "reference": "304cf84609ef645d63adc65fc6250292909a461b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/mercure/zipball/304cf84609ef645d63adc65fc6250292909a461b", + "reference": "304cf84609ef645d63adc65fc6250292909a461b", + "shasum": "" + }, + "require": { + "php": ">=7.1.3", + "symfony/deprecation-contracts": "^2.0|^3.0|^4.0", + "symfony/http-client": "^4.4|^5.0|^6.0|^7.0", + "symfony/http-foundation": "^4.4|^5.0|^6.0|^7.0", + "symfony/polyfill-php80": "^1.22", + "symfony/web-link": "^4.4|^5.0|^6.0|^7.0" + }, + "require-dev": { + "lcobucci/jwt": "^3.4|^4.0|^5.0", + "symfony/event-dispatcher": "^4.4|^5.0|^6.0|^7.0", + "symfony/http-kernel": "^4.4|^5.0|^6.0|^7.0", + "symfony/phpunit-bridge": "^5.2|^6.0|^7.0", + "symfony/stopwatch": "^4.4|^5.0|^6.0|^7.0", + "twig/twig": "^2.0|^3.0|^4.0" + }, + "suggest": { + "symfony/stopwatch": "Integration with the profiler performances" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/dunglas/mercure", + "name": "dunglas/mercure" + }, + "branch-alias": { + "dev-main": "0.6.x-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Component\\Mercure\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Kévin Dunglas", + "email": "dunglas@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony Mercure Component", + "homepage": "https://symfony.com", + "keywords": [ + "mercure", + "push", + "sse", + "updates" + ], + "support": { + "issues": "https://github.com/symfony/mercure/issues", + "source": "https://github.com/symfony/mercure/tree/v0.6.5" + }, + "funding": [ + { + "url": "https://github.com/dunglas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/mercure", + "type": "tidelift" + } + ], + "time": "2024-04-08T12:51:34+00:00" + }, + { + "name": "symfony/mercure-bundle", + "version": "v0.4.2", + "source": { + "type": "git", + "url": "https://github.com/symfony/mercure-bundle.git", + "reference": "eae8bf5a75b4e1203bd9aa4181c7950a4df4b3e3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/mercure-bundle/zipball/eae8bf5a75b4e1203bd9aa4181c7950a4df4b3e3", + "reference": "eae8bf5a75b4e1203bd9aa4181c7950a4df4b3e3", + "shasum": "" + }, + "require": { + "lcobucci/jwt": "^3.4|^4.0|^5.0", + "php": ">=8.1", + "symfony/config": "^6.4|^7.3|^8.0", + "symfony/dependency-injection": "^6.4|^7.3|^8.0", + "symfony/http-kernel": "^6.4|^7.3|^8.0", + "symfony/mercure": "*", + "symfony/web-link": "^6.4|^7.3|^8.0" + }, + "require-dev": { + "symfony/phpunit-bridge": "^7.3.4|^8.0", + "symfony/stopwatch": "^6.4|^7.3|^8.0", + "symfony/ux-turbo": "*", + "symfony/var-dumper": "^6.4|^7.3|^8.0" + }, + "suggest": { + "symfony/messenger": "To use the Messenger integration" + }, + "type": "symfony-bundle", + "extra": { + "branch-alias": { + "dev-main": "0.3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Bundle\\MercureBundle\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Kévin Dunglas", + "email": "dunglas@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony MercureBundle", + "homepage": "https://symfony.com", + "keywords": [ + "mercure", + "push", + "sse", + "updates" + ], + "support": { + "issues": "https://github.com/symfony/mercure-bundle/issues", + "source": "https://github.com/symfony/mercure-bundle/tree/v0.4.2" + }, + "funding": [ + { + "url": "https://github.com/dunglas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/mercure-bundle", + "type": "tidelift" + } + ], + "time": "2025-11-25T12:51:49+00:00" + }, { "name": "symfony/mime", "version": "v6.4.36", @@ -4794,77 +4124,6 @@ ], "time": "2026-04-02T18:23:01+00:00" }, - { - "name": "symfony/options-resolver", - "version": "v6.4.30", - "source": { - "type": "git", - "url": "https://github.com/symfony/options-resolver.git", - "reference": "eeaa8cabe54c7b3516938c72a4a161c0cc80a34f" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/options-resolver/zipball/eeaa8cabe54c7b3516938c72a4a161c0cc80a34f", - "reference": "eeaa8cabe54c7b3516938c72a4a161c0cc80a34f", - "shasum": "" - }, - "require": { - "php": ">=8.1", - "symfony/deprecation-contracts": "^2.5|^3" - }, - "type": "library", - "autoload": { - "psr-4": { - "Symfony\\Component\\OptionsResolver\\": "" - }, - "exclude-from-classmap": [ - "/Tests/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Fabien Potencier", - "email": "fabien@symfony.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Provides an improved replacement for the array_replace PHP function", - "homepage": "https://symfony.com", - "keywords": [ - "config", - "configuration", - "options" - ], - "support": { - "source": "https://github.com/symfony/options-resolver/tree/v6.4.30" - }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://github.com/nicolas-grekas", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2025-11-12T13:06:53+00:00" - }, { "name": "symfony/password-hasher", "version": "v6.4.32", @@ -6152,108 +5411,6 @@ ], "time": "2026-02-16T20:44:03+00:00" }, - { - "name": "symfony/serializer", - "version": "v6.4.36", - "source": { - "type": "git", - "url": "https://github.com/symfony/serializer.git", - "reference": "90e4e0187dca57331ea301506545aa26895b7787" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/serializer/zipball/90e4e0187dca57331ea301506545aa26895b7787", - "reference": "90e4e0187dca57331ea301506545aa26895b7787", - "shasum": "" - }, - "require": { - "php": ">=8.1", - "symfony/deprecation-contracts": "^2.5|^3", - "symfony/polyfill-ctype": "~1.8" - }, - "conflict": { - "doctrine/annotations": "<1.12", - "phpdocumentor/reflection-docblock": "<3.2.2", - "phpdocumentor/type-resolver": "<1.4.0", - "symfony/dependency-injection": "<5.4", - "symfony/property-access": "<5.4", - "symfony/property-info": "<5.4.24|>=6,<6.2.11", - "symfony/uid": "<5.4", - "symfony/validator": "<6.4", - "symfony/yaml": "<5.4" - }, - "require-dev": { - "doctrine/annotations": "^1.12|^2", - "phpdocumentor/reflection-docblock": "^3.2|^4.0|^5.0", - "seld/jsonlint": "^1.10", - "symfony/cache": "^5.4|^6.0|^7.0", - "symfony/config": "^5.4|^6.0|^7.0", - "symfony/console": "^5.4|^6.0|^7.0", - "symfony/dependency-injection": "^5.4|^6.0|^7.0", - "symfony/error-handler": "^5.4|^6.0|^7.0", - "symfony/filesystem": "^5.4|^6.0|^7.0", - "symfony/form": "^5.4|^6.0|^7.0", - "symfony/http-foundation": "^5.4|^6.0|^7.0", - "symfony/http-kernel": "^5.4|^6.0|^7.0", - "symfony/messenger": "^5.4|^6.0|^7.0", - "symfony/mime": "^5.4|^6.0|^7.0", - "symfony/property-access": "^5.4.26|^6.3|^7.0", - "symfony/property-info": "^5.4.24|^6.2.11|^7.0", - "symfony/translation-contracts": "^2.5|^3", - "symfony/uid": "^5.4|^6.0|^7.0", - "symfony/validator": "^6.4|^7.0", - "symfony/var-dumper": "^5.4|^6.0|^7.0", - "symfony/var-exporter": "^5.4|^6.0|^7.0", - "symfony/yaml": "^5.4|^6.0|^7.0" - }, - "type": "library", - "autoload": { - "psr-4": { - "Symfony\\Component\\Serializer\\": "" - }, - "exclude-from-classmap": [ - "/Tests/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Fabien Potencier", - "email": "fabien@symfony.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Handles serializing and deserializing data structures, including object graphs, into array structures or other formats like XML and JSON.", - "homepage": "https://symfony.com", - "support": { - "source": "https://github.com/symfony/serializer/tree/v6.4.36" - }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://github.com/nicolas-grekas", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2026-03-30T15:37:17+00:00" - }, { "name": "symfony/service-contracts", "version": "v3.6.1", @@ -7047,6 +6204,93 @@ ], "time": "2026-03-10T15:06:19+00:00" }, + { + "name": "symfony/web-link", + "version": "v6.4.32", + "source": { + "type": "git", + "url": "https://github.com/symfony/web-link.git", + "reference": "636d5e34cd5c4a2538b02ba48a3c02989bfdf06b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/web-link/zipball/636d5e34cd5c4a2538b02ba48a3c02989bfdf06b", + "reference": "636d5e34cd5c4a2538b02ba48a3c02989bfdf06b", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/link": "^1.1|^2.0" + }, + "conflict": { + "symfony/http-kernel": "<5.4" + }, + "provide": { + "psr/link-implementation": "1.0|2.0" + }, + "require-dev": { + "symfony/http-kernel": "^5.4|^6.0|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\WebLink\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Kévin Dunglas", + "email": "dunglas@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Manages links between resources", + "homepage": "https://symfony.com", + "keywords": [ + "dns-prefetch", + "http", + "http2", + "link", + "performance", + "prefetch", + "preload", + "prerender", + "psr13", + "push" + ], + "support": { + "source": "https://github.com/symfony/web-link/tree/v6.4.32" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-01-01T13:45:34+00:00" + }, { "name": "symfony/webpack-encore-bundle", "version": "v1.17.2", @@ -7278,6 +6522,70 @@ } ], "packages-dev": [ + { + "name": "firebase/php-jwt", + "version": "v7.0.5", + "source": { + "type": "git", + "url": "https://github.com/firebase/php-jwt.git", + "reference": "47ad26bab5e7c70ae8a6f08ed25ff83631121380" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/firebase/php-jwt/zipball/47ad26bab5e7c70ae8a6f08ed25ff83631121380", + "reference": "47ad26bab5e7c70ae8a6f08ed25ff83631121380", + "shasum": "" + }, + "require": { + "php": "^8.0" + }, + "require-dev": { + "guzzlehttp/guzzle": "^7.4", + "phpfastcache/phpfastcache": "^9.2", + "phpspec/prophecy-phpunit": "^2.0", + "phpunit/phpunit": "^9.5", + "psr/cache": "^2.0||^3.0", + "psr/http-client": "^1.0", + "psr/http-factory": "^1.0" + }, + "suggest": { + "ext-sodium": "Support EdDSA (Ed25519) signatures", + "paragonie/sodium_compat": "Support EdDSA (Ed25519) signatures when libsodium is not present" + }, + "type": "library", + "autoload": { + "psr-4": { + "Firebase\\JWT\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Neuman Vong", + "email": "neuman+pear@twilio.com", + "role": "Developer" + }, + { + "name": "Anant Narayanan", + "email": "anant@php.net", + "role": "Developer" + } + ], + "description": "A simple library to encode and decode JSON Web Tokens (JWT) in PHP. Should conform to the current spec.", + "homepage": "https://github.com/firebase/php-jwt", + "keywords": [ + "jwt", + "php" + ], + "support": { + "issues": "https://github.com/firebase/php-jwt/issues", + "source": "https://github.com/firebase/php-jwt/tree/v7.0.5" + }, + "time": "2026-04-01T20:38:03+00:00" + }, { "name": "nikic/php-parser", "version": "v5.7.0", diff --git a/config/bundles.php b/config/bundles.php index fff3113..c8419b9 100644 --- a/config/bundles.php +++ b/config/bundles.php @@ -8,8 +8,7 @@ return [ Doctrine\Bundle\DoctrineBundle\DoctrineBundle::class => ['all' => true], Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle::class => ['all' => true], Symfony\Bundle\SecurityBundle\SecurityBundle::class => ['all' => true], - Gos\Bundle\PubSubRouterBundle\GosPubSubRouterBundle::class => ['all' => true], - Gos\Bundle\WebSocketBundle\GosWebSocketBundle::class => ['all' => true], Symfony\WebpackEncoreBundle\WebpackEncoreBundle::class => ['all' => true], Symfony\Bundle\MonologBundle\MonologBundle::class => ['all' => true], + Symfony\Bundle\MercureBundle\MercureBundle::class => ['all' => true], ]; diff --git a/config/packages/gos_web_socket.yaml b/config/packages/gos_web_socket.yaml deleted file mode 100644 index 8c0f86c..0000000 --- a/config/packages/gos_web_socket.yaml +++ /dev/null @@ -1,28 +0,0 @@ -#doctrine_cache: -# providers: -# redis_cache: -# redis: -# host: localhost -# port: 6379 -# database: 3 -# websocket_cache_client: -# type: redis -# alias: gos_web_socket.client_storage.driver.redis - -gos_web_socket: - server: - host: 0.0.0.0 - port: "%mineseeker.websocket%" - router: - resources: - - '%kernel.project_dir%/config/pubsub/routing.yaml' - client: - firewall: secured_area - # session_handler: "@session.handler.pdo" - # storage: - # driver: "@gos_web_socket.client_storage.driver.predis" - # ttl: 28800 #(optionally) time to live if you use redis driver - # prefix: client #(optionally) prefix if you use redis driver, create key "client:1" instead key "1" - ping: - services: - - { name: doctrine.dbal.default_connection, type: doctrine, interval: 300 } diff --git a/config/packages/mercure.yaml b/config/packages/mercure.yaml new file mode 100644 index 0000000..9fa41b2 --- /dev/null +++ b/config/packages/mercure.yaml @@ -0,0 +1,8 @@ +mercure: + hubs: + default: + url: '%env(MERCURE_URL)%' + public_url: '%env(MERCURE_PUBLIC_URL)%' + jwt: + secret: '%env(MERCURE_JWT_SECRET)%' + publish: '*' diff --git a/config/pubsub/routing.yaml b/config/pubsub/routing.yaml deleted file mode 100644 index 52b2d81..0000000 --- a/config/pubsub/routing.yaml +++ /dev/null @@ -1,14 +0,0 @@ -## Topic Configuration -mineseeker_topic: - channel: mineseeker/channel/{game} - callback: 'mineseeker.topic' -# requirements: -# method: -# path: '[a-z1-9A-Z]+' - -# Remote Procedure Call Configuration -mineseeker_rpc: - channel: mineseeker-rpc/{method} - callback: 'mineseeker.rpc' - requirements: - method: "[a-zA-Z_]+" diff --git a/config/routes/mineseeker.yaml b/config/routes/mineseeker.yaml index 657bc8a..7fab5bd 100644 --- a/config/routes/mineseeker.yaml +++ b/config/routes/mineseeker.yaml @@ -24,4 +24,30 @@ MineSeekerBundle_contact: MineSeekerBundle_landing: path: /landing-page - controller: App\Controller\GameController::landing \ No newline at end of file + controller: App\Controller\GameController::landing + +MineSeekerBundle_api_game_start: + path: /api/game/start + controller: App\Controller\MercureController::start + methods: [POST] + +MineSeekerBundle_api_game_connect: + path: /api/game/connect/{gameAssoc} + controller: App\Controller\MercureController::connect + methods: [GET] + +MineSeekerBundle_api_game_join: + path: /api/game/join/{gameAssoc} + controller: App\Controller\MercureController::join + methods: [POST] + +MineSeekerBundle_api_game_step: + path: /api/game/step/{gameAssoc} + controller: App\Controller\MercureController::step + methods: [POST] + +MineSeekerBundle_api_game_leave: + path: /api/game/leave/{gameAssoc} + controller: App\Controller\MercureController::leave + methods: [POST] + diff --git a/config/services.yaml b/config/services.yaml index b53c5c8..8020794 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -3,11 +3,6 @@ parameters: locale: 'en' jotunheimr.version: 1.1.0-20191026 - facebook.api: 320599508311862 - facebook.api-secret: 18d4f48cdd274bccee2678e5eff3f557 - facebook.version: 'v2.8' - facebook.scope: 'public_profile,email,user_friends' - mineseeker.websocket: 6450 services: # default configuration for services in *this* file @@ -16,50 +11,16 @@ services: autoconfigure: true # Automatically registers your services as commands, event subscribers, etc. public: false # Allows optimizing the container by removing unused services; this also means # fetching services directly from the container via $container->get() won't work. - # The best practice is to be explicit about your dependencies anyway. + # The best practice is to be explicit about your dependencies anyway. # makes classes in src/ available to be used as services # this creates a service per class whose id is the fully-qualified class name App\: resource: '../src/*' - exclude: '../src/{Command/WebsocketServerCommand.php,Entity,Migrations,Tests,Kernel.php,Periodic}' + exclude: '../src/{Entity,Migrations,Tests,Kernel.php}' # controllers are imported separately to make sure services can be injected # as action arguments even if you don't extend any base controller class App\Controller\: resource: '../src/Controller' tags: [ 'controller.service_arguments' ] - - # add more service definitions when explicit configuration is needed - # please note that last definitions always *replace* previous ones - - mineseeker.topic_sample_service: - class: App\Topic\MineseekerTopic - tags: - - { name: gos_web_socket.topic } - - mineseeker.rpc_sample_service: - class: App\Rpc\MineseekerRpc - public: true - tags: - - { name: gos_web_socket.rpc } - - # Override gos WebsocketServerCommand to avoid --profile conflict with Symfony 6.4 global option - gos_web_socket.command.websocket_server: - class: App\Command\WebsocketServerCommand - arguments: - - '@gos_web_socket.server.launcher' - - '%gos_web_socket.server.host%' - - '%gos_web_socket.server.port%' - - '@gos_web_socket.registry.server' - tags: - - { name: console.command, command: 'gos:websocket:server' } - - gos_web_socket_server.client_event.listener: - class: App\EventListener\MineseekerClientEventListener - tags: - - { name: kernel.event_listener, event: 'gos_web_socket.client_connected', method: onClientConnect } - - { name: kernel.event_listener, event: 'gos_web_socket.client_disconnected', method: onClientDisconnect } - - { name: kernel.event_listener, event: 'gos_web_socket.client_error', method: onClientError } - - { name: kernel.event_listener, event: 'gos_web_socket.server_launched', method: onServerStart } - - { name: kernel.event_listener, event: 'gos_web_socket.client_rejected', method: onClientRejected } diff --git a/package.json b/package.json index 1f2f2fa..81a2ffa 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,6 @@ "private": true, "dependencies": { "@fortawesome/fontawesome-free": "^5.2.0", - "autobahn": "^19.10.1", "bootstrap": "3", "buffer": "^5.4.3", "howler": "^2.1.2", diff --git a/src/Command/WebsocketServerCommand.php b/src/Command/WebsocketServerCommand.php deleted file mode 100644 index fef104c..0000000 --- a/src/Command/WebsocketServerCommand.php +++ /dev/null @@ -1,79 +0,0 @@ -addArgument('name', InputArgument::OPTIONAL, 'Name of the server to start, launches the first registered server if not specified') - ->addOption('ws-profile', 'm', InputOption::VALUE_NONE, 'Enable profiling of the websocket server') - ->addOption('host', 'a', InputOption::VALUE_OPTIONAL, 'The hostname of the websocket server') - ->addOption('port', 'p', InputOption::VALUE_OPTIONAL, 'The port of the websocket server'); - } - - protected function execute(InputInterface $input, OutputInterface $output): int - { - /** @var string $name */ - $name = $input->getArgument('name'); - - /** @var string $host */ - $host = null === $input->getOption('host') ? $this->host : $input->getOption('host'); - - /** @var int|string $port */ - $port = null === $input->getOption('port') ? $this->port : $input->getOption('port'); - - if (!is_numeric($port)) { - throw new InvalidArgumentException('The port option must be a numeric value.'); - } - - /** @var bool $profile */ - $profile = (bool) $input->getOption('ws-profile'); - - $this->serverLauncher->launch($name, $host, (int) $port, $profile); - - return 0; - } - - public function complete(CompletionInput $input, CompletionSuggestions $suggestions): void - { - if ($input->mustSuggestArgumentValuesFor('name') && null !== $this->serverRegistry) { - $suggestions->suggestValues(array_keys($this->serverRegistry->getServers())); - } - } -} \ No newline at end of file diff --git a/src/Controller/GameController.php b/src/Controller/GameController.php index 12b3d19..251ca8d 100644 --- a/src/Controller/GameController.php +++ b/src/Controller/GameController.php @@ -12,7 +12,6 @@ namespace App\Controller; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\DependencyInjection\Attribute\Autowire; -use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\HttpFoundation\Response; /** @@ -29,8 +28,11 @@ class GameController extends AbstractController { public function __construct( #[Autowire(env: 'APP_ENV')] - private readonly string $env, - private readonly RequestStack $request, + private readonly string $env, + #[Autowire(env: 'MERCURE_PUBLIC_URL')] + private readonly string $mercurePublicUrl, + #[Autowire(env: 'MERCURE_SUBSCRIBER_JWT')] + private readonly string $mercureSubscriberJwt, ) { } @@ -42,8 +44,9 @@ class GameController extends AbstractController public function play(): Response { return $this->render('Game/play.html.twig', [ - 'env' => $this->env, - 'ssl' => $this->request->getCurrentRequest()->isSecure() ? 'true' : 'false', + 'env' => $this->env, + 'mercure_hub_url' => $this->mercurePublicUrl, + 'mercure_subscriber_jwt' => $this->mercureSubscriberJwt, ]); } diff --git a/src/Controller/MercureController.php b/src/Controller/MercureController.php new file mode 100644 index 0000000..af1f6c0 --- /dev/null +++ b/src/Controller/MercureController.php @@ -0,0 +1,98 @@ + + * @category Class + * @license https://www.gnu.org/licenses/lgpl-3.0.en.html GNU Lesser General Public License + * @link www.splendidbear.org + * @since 2026. 04. 09. + */ +class MercureController extends AbstractController +{ + public function __construct( + private readonly TopicManager $topicManager, + private readonly RpcManager $rpcManager, + ) { + } + + /** POST /api/game/start — save the grid and create the PlayedGame record */ + public function start(Request $request): JsonResponse + { + $data = $request->toArray(); + $result = $this->rpcManager->saveGrid([$data['grid'], $data['gameAssoc']]); + + return $this->json(['success' => $result]); + } + + /** GET /api/game/connect/{gameAssoc} — return grid + current user info (base64 JSON) */ + public function connect(string $gameAssoc): Response + { + $payload = $this->rpcManager->getConnectInformation($gameAssoc); + + return new Response($payload, Response::HTTP_OK, ['Content-Type' => 'text/plain']); + } + + /** POST /api/game/join/{gameAssoc} — register the player, broadcast subscription event via Mercure */ + public function join(string $gameAssoc, Request $request): JsonResponse + { + $this->topicManager->subscribe($gameAssoc, $this->resolveUserName($request), $this->getUser()); + + return $this->json(['success' => true]); + } + + /** POST /api/game/step/{gameAssoc} — persist the step and broadcast game event via Mercure */ + public function step(string $gameAssoc, Request $request): JsonResponse + { + $this->topicManager->publish($gameAssoc, $this->resolveUserName($request), $request->toArray()); + + return $this->json(['success' => true]); + } + + /** POST /api/game/leave/{gameAssoc} — broadcast disconnect event via Mercure */ + public function leave(string $gameAssoc, Request $request): JsonResponse + { + $this->topicManager->unSubscribe($gameAssoc, $this->resolveUserName($request)); + + return $this->json(['success' => true]); + } + + private function resolveUserName(Request $request): string + { + $user = $this->getUser(); + + if (null !== $user) { + return $user->getUserIdentifier(); + } + + $sessionId = $request->getSession()->getId(); + if (empty($sessionId)) { + $sessionId = bin2hex(random_bytes(16)); + } + + return 'anon_' . $sessionId; + } +} diff --git a/src/Entity/PlayedGame.php b/src/Entity/PlayedGame.php index 48174b4..4c00c13 100644 --- a/src/Entity/PlayedGame.php +++ b/src/Entity/PlayedGame.php @@ -12,6 +12,8 @@ namespace App\Entity; use App\Repository\PlayedGameRepository; use DateTime; +use Doctrine\Common\Collections\ArrayCollection; +use Doctrine\Common\Collections\Collection; use Doctrine\DBAL\Types\Types; use Doctrine\ORM\Mapping\Column; use Doctrine\ORM\Mapping\Entity; @@ -19,6 +21,7 @@ use Doctrine\ORM\Mapping\GeneratedValue; use Doctrine\ORM\Mapping\Id; use Doctrine\ORM\Mapping\JoinColumn; use Doctrine\ORM\Mapping\ManyToOne; +use Doctrine\ORM\Mapping\OneToMany; use Doctrine\ORM\Mapping\OneToOne; /** @@ -80,107 +83,28 @@ class PlayedGame #[JoinColumn(name: 'blue_anon', referencedColumnName: 'id', nullable: true)] private ?Gamer $blueAnon = null; - #[OneToOne(mappedBy: 'playedGame')] - private ?Step $step = null; + #[OneToMany(mappedBy: 'playedGame', targetEntity: Step::class)] + private Collection $steps; + public function __construct() + { + $this->steps = new ArrayCollection(); + } + public function getId(): ?int { return $this->id; } - public function setId(?int $id): self - { - $this->id = $id; - return $this; - } - public function getGameAssoc(): ?string { return $this->gameAssoc; } - public function setGameAssoc(?string $gameAssoc): self + public function setGameAssoc(?string $gameAssoc): void { $this->gameAssoc = $gameAssoc; - return $this; - } - - public function getRedPoints(): ?int - { - return $this->redPoints; - } - - public function setRedPoints(?int $redPoints): self - { - $this->redPoints = $redPoints; - return $this; - } - - public function getBluePoints(): ?int - { - return $this->bluePoints; - } - - public function setBluePoints(?int $bluePoints): self - { - $this->bluePoints = $bluePoints; - return $this; - } - - public function getRedExplodedBomb(): ?bool - { - return $this->redExplodedBomb; - } - - public function setRedExplodedBomb(?bool $redExplodedBomb): self - { - $this->redExplodedBomb = $redExplodedBomb; - return $this; - } - - public function getBlueExplodedBomb(): ?bool - { - return $this->blueExplodedBomb; - } - - public function setBlueExplodedBomb(?bool $blueExplodedBomb): self - { - $this->blueExplodedBomb = $blueExplodedBomb; - return $this; - } - - public function getResign(): ?string - { - return $this->resign; - } - - public function setResign(?string $resign): self - { - $this->resign = $resign; - return $this; - } - - public function getCreated(): ?DateTime - { - return $this->created; - } - - public function setCreated(?DateTime $created): self - { - $this->created = $created; - return $this; - } - - public function getUpdated(): ?DateTime - { - return $this->updated; - } - - public function setUpdated(?DateTime $updated): self - { - $this->updated = $updated; - return $this; } public function getGrid(): ?Grid @@ -188,10 +112,9 @@ class PlayedGame return $this->grid; } - public function setGrid(?Grid $grid): self + public function setGrid(?Grid $grid): void { $this->grid = $grid; - return $this; } public function getRed(): ?User @@ -199,10 +122,9 @@ class PlayedGame return $this->red; } - public function setRed(?User $red): self + public function setRed(?User $red): void { $this->red = $red; - return $this; } public function getRedAnon(): ?Gamer @@ -210,10 +132,9 @@ class PlayedGame return $this->redAnon; } - public function setRedAnon(?Gamer $redAnon): self + public function setRedAnon(?Gamer $redAnon): void { $this->redAnon = $redAnon; - return $this; } public function getBlue(): ?User @@ -221,10 +142,9 @@ class PlayedGame return $this->blue; } - public function setBlue(?User $blue): self + public function setBlue(?User $blue): void { $this->blue = $blue; - return $this; } public function getBlueAnon(): ?Gamer @@ -232,20 +152,83 @@ class PlayedGame return $this->blueAnon; } - public function setBlueAnon(?Gamer $blueAnon): self + public function setBlueAnon(?Gamer $blueAnon): void { $this->blueAnon = $blueAnon; - return $this; } - public function getStep(): ?Step + public function getRedPoints(): ?int { - return $this->step; + return $this->redPoints; } - public function setStep(?Step $step): self + public function setRedPoints(?int $redPoints): void { - $this->step = $step; - return $this; + $this->redPoints = $redPoints; + } + + public function getBluePoints(): ?int + { + return $this->bluePoints; + } + + public function setBluePoints(?int $bluePoints): void + { + $this->bluePoints = $bluePoints; + } + + public function getRedExplodedBomb(): ?bool + { + return $this->redExplodedBomb; + } + + public function setRedExplodedBomb(?bool $redExplodedBomb): void + { + $this->redExplodedBomb = $redExplodedBomb; + } + + public function getBlueExplodedBomb(): ?bool + { + return $this->blueExplodedBomb; + } + + public function setBlueExplodedBomb(?bool $blueExplodedBomb): void + { + $this->blueExplodedBomb = $blueExplodedBomb; + } + + public function getResign(): ?string + { + return $this->resign; + } + + public function setResign(?string $resign): void + { + $this->resign = $resign; + } + + public function getCreated(): ?DateTime + { + return $this->created; + } + + public function setCreated(?DateTime $created): void + { + $this->created = $created; + } + + public function getUpdated(): ?DateTime + { + return $this->updated; + } + + public function setUpdated(?DateTime $updated): void + { + $this->updated = $updated; + } + + public function getSteps(): Collection + { + return $this->steps; } } diff --git a/src/Entity/Step.php b/src/Entity/Step.php index 82e554a..77430f0 100644 --- a/src/Entity/Step.php +++ b/src/Entity/Step.php @@ -44,7 +44,7 @@ class Step #[Column(nullable: true)] private ?bool $wBomb = null; - #[ManyToOne(inversedBy: 'step')] + #[ManyToOne(inversedBy: 'steps')] private ?PlayedGame $playedGame = null; #[Column(type: Types::DATETIME_MUTABLE, nullable: true)] diff --git a/src/EventListener/MineseekerClientEventListener.php b/src/EventListener/MineseekerClientEventListener.php deleted file mode 100644 index 722487a..0000000 --- a/src/EventListener/MineseekerClientEventListener.php +++ /dev/null @@ -1,60 +0,0 @@ - - * @category Class - * @license https://www.gnu.org/licenses/lgpl-3.0.en.html GNU Lesser General Public License - * @link www.splendidbear.org - * @since 2026. 04. 09. - */ -class MineseekerClientEventListener -{ - public function onClientConnect(ClientConnectedEvent $event): void - { - $conn = $event->getConnection(); - echo $conn->resourceId . ' connected' . PHP_EOL; - } - - public function onClientDisconnect(ClientDisconnectedEvent $event): void - { - $conn = $event->getConnection(); - echo $conn->resourceId . ' disconnected' . PHP_EOL; - } - - public function onClientError(ClientErrorEvent $event): void - { - $conn = $event->getConnection(); - $e = $event->getException(); - echo 'connection error occurred: ' . $e->getMessage() . PHP_EOL; - } - - public function onServerStart(ServerLaunchedEvent $event): void - { - echo 'Server was successfully started !' . PHP_EOL; - } - - public function onClientRejected(ClientRejectedEvent $event): void - { - $origin = $event->getOrigin(); - echo 'connection rejected from ' . $origin . PHP_EOL; - } -} diff --git a/src/Interfaces/TopicManagerInterface.php b/src/Interfaces/TopicManagerInterface.php index 2b68264..da8378e 100644 --- a/src/Interfaces/TopicManagerInterface.php +++ b/src/Interfaces/TopicManagerInterface.php @@ -10,8 +10,7 @@ namespace App\Interfaces; -use Ratchet\ConnectionInterface; -use Ratchet\Wamp\Topic; +use Symfony\Component\Security\Core\User\UserInterface; /** * Interface TopicManagerInterface @@ -25,9 +24,9 @@ use Ratchet\Wamp\Topic; */ interface TopicManagerInterface { - public function subscribe(Topic $topic, ConnectionInterface $connection): void; + public function subscribe(string $gameAssoc, string $userName, ?UserInterface $user): void; - public function unSubscribe(Topic $topic, ConnectionInterface $connection): void; + public function unSubscribe(string $gameAssoc, string $userName): void; - public function publish(Topic $topic, ConnectionInterface $connection, $event): void; + public function publish(string $gameAssoc, string $userName, array $event): void; } diff --git a/src/Interfaces/WebsocketManagerInterface.php b/src/Interfaces/WebsocketManagerInterface.php deleted file mode 100644 index d965b7c..0000000 --- a/src/Interfaces/WebsocketManagerInterface.php +++ /dev/null @@ -1,28 +0,0 @@ - - * @category Interface - * @license https://www.gnu.org/licenses/lgpl-3.0.en.html GNU Lesser General Public License - * @link www.splendidbear.org - * @since 2026. 04. 09. - */ -interface WebsocketManagerInterface -{ - public function reConnect(EntityManagerInterface $entityManager): ?EntityManagerInterface; -} diff --git a/src/Migrations/.gitignore b/src/Migrations/.gitignore deleted file mode 100644 index e69de29..0000000 diff --git a/src/Migrations/2026/04/Version20260409194708.php b/src/Migrations/2026/04/Version20260409194708.php new file mode 100644 index 0000000..2afca42 --- /dev/null +++ b/src/Migrations/2026/04/Version20260409194708.php @@ -0,0 +1,42 @@ + + * @category Class + * @license https://www.gnu.org/licenses/lgpl-3.0.en.html GNU Lesser General Public License + * @link www.splendidbear.org + * @since 2026. 04. 09. + */ +final class Version20260409194708 extends AbstractMigration +{ + public function getDescription(): string + { + return 'Refactor entities'; + } + + public function up(Schema $schema): void + { + $this->addSql('ALTER TABLE step RENAME COLUMN wbomb TO w_bomb'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE step RENAME COLUMN w_bomb TO wbomb'); + } +} diff --git a/src/Periodic/MinePeriodic.php b/src/Periodic/MinePeriodic.php deleted file mode 100644 index 0caed85..0000000 --- a/src/Periodic/MinePeriodic.php +++ /dev/null @@ -1,51 +0,0 @@ - - * @category Class - * @license https://www.gnu.org/licenses/lgpl-3.0.en.html GNU Lesser General Public License - * @link www.splendidbear.org - * @since 2026. 04. 09. - * - * @method MinePeriodic|null find($id, $lockMode = null, $lockVersion = null) - * @method MinePeriodic|null findOneBy(array $criteria, array $orderBy = null) - * @method MinePeriodic[] findAll() - * @method MinePeriodic[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null) - */ -class MinePeriodic implements PeriodicInterface -{ - public function __construct(private PdoPeriodicPing $ping) { } - - /** - * This function is executed every 5 seconds. - * - * For more advanced functionality, try injecting - * a Topic Service to perform actions on your - * connections every x seconds. - */ - public function tick(): void - { - $this->ping->tick(); - } - - public function getTimeout(): int - { - return 300; - } -} diff --git a/src/Rpc/MineseekerRpc.php b/src/Rpc/MineseekerRpc.php deleted file mode 100644 index 1711a4f..0000000 --- a/src/Rpc/MineseekerRpc.php +++ /dev/null @@ -1,69 +0,0 @@ - - * @category Class - * @license https://www.gnu.org/licenses/lgpl-3.0.en.html GNU Lesser General Public License - * @link www.splendidbear.org - * @since 2026. 04. 09. - */ -class MineseekerRpc implements RpcInterface -{ - public function __construct(private RpcManager $manager) { } - - /** - * Name of RPC, use for pubsub router (see step3) - * - * @return string - */ - public function getName(): string - { - return 'mineseeker.rpc'; - } - - /** - * It handles the game starting processes - * - * @param ConnectionInterface $connection - * @param WampRequest $request - * @param array $params - * - * @return boolean - */ - public function startGame(ConnectionInterface $connection, WampRequest $request, array $params): bool - { - return $this->manager->saveGrid($params); - } - - /** - * It handles when somebody trying to connect to the party - * - * @param ConnectionInterface $connection - * @param WampRequest $request - * @param array $params - * - * @return string Json string for frontend w/ numbering consideration. (=> a number is not string) - */ - public function connectGame(ConnectionInterface $connection, WampRequest $request, array $params): string - { - return $this->manager->getConnectInformation($params); - } -} diff --git a/src/Topic/MineseekerTopic.php b/src/Topic/MineseekerTopic.php deleted file mode 100644 index b575d9f..0000000 --- a/src/Topic/MineseekerTopic.php +++ /dev/null @@ -1,95 +0,0 @@ - - * @category Class - * @license https://www.gnu.org/licenses/lgpl-3.0.en.html GNU Lesser General Public License - * @link www.splendidbear.org - * @since 2026. 04. 09. - */ -class MineseekerTopic implements TopicInterface -{ - public function __construct(private TopicManager $manager) { } - - /** - * Like RPC is will use to prefix the channel - * - * @return string - */ - public function getName(): string - { - return 'mineseeker.topic'; - } - - /** - * This will receive any Subscription requests for this topic. - * - * @param ConnectionInterface $connection - * @param Topic $topic - * @param WampRequest $request - * - * @return void - */ - public function onSubscribe(ConnectionInterface $connection, Topic $topic, WampRequest $request): void - { - $this->manager->subscribe($topic, $connection); - } - - /** - * This will receive any UnSubscription requests for this topic. - * - * @param ConnectionInterface $connection - * @param Topic $topic - * @param WampRequest $request - * - * @return void - */ - public function onUnSubscribe(ConnectionInterface $connection, Topic $topic, WampRequest $request): void - { - $this->manager->unSubscribe($topic, $connection); - } - - /** - * This will receive any Publish requests for this topic. - * - * @param ConnectionInterface $connection - * @param Topic $topic - * @param WampRequest $request - * @param $event - * @param array $exclude - * @param array $eligible - * - * @return mixed|void - * @internal param Topic $Topic - * @internal param array $eligibles - */ - public function onPublish( - ConnectionInterface $connection, - Topic $topic, - WampRequest $request, - $event, - array $exclude, - array $eligible - ) { - $this->manager->publish($topic, $connection, $event); - } -} diff --git a/src/Util/RpcManager.php b/src/Util/RpcManager.php index 1c72ba1..49faf15 100644 --- a/src/Util/RpcManager.php +++ b/src/Util/RpcManager.php @@ -16,9 +16,10 @@ use App\Entity\PlayedGame; use App\Interfaces\RpcManagerInterface; use DateTime; use Doctrine\ORM\EntityManagerInterface; -use Doctrine\ORM\ORMException; use Exception; +use JsonException; use Psr\Log\LoggerInterface; +use RuntimeException; /** * Class RpcManager @@ -30,13 +31,12 @@ use Psr\Log\LoggerInterface; * @link www.splendidbear.org * @since 2026. 04. 09. */ -class RpcManager extends WebsocketManager implements RpcManagerInterface +class RpcManager implements RpcManagerInterface { public function __construct( - private EntityManagerInterface $entityManager, - private LoggerInterface $logger, + private readonly EntityManagerInterface $entityManager, + private readonly LoggerInterface $logger, ) { - parent::__construct($logger); } public function getConnectInformation($params): string @@ -45,17 +45,33 @@ class RpcManager extends WebsocketManager implements RpcManagerInterface $grid = $this->getGrid($gameAssoc); $users = null !== $grid ? $this->getUsers($gameAssoc) : null; - return base64_encode(json_encode([ - 'grid' => $grid, - 'users' => $users, - ], JSON_THROW_ON_ERROR, 512)); + try { + return base64_encode(json_encode([ + 'grid' => $grid, + 'users' => $users, + ], JSON_THROW_ON_ERROR, 512)); + } catch (JsonException $e) { + throw new RuntimeException($e->getMessage()); + } } public function saveGrid($data): bool { + $existingGame = $this->entityManager + ->getRepository(PlayedGame::class) + ->findOneByGameAssoc($data[1]); + + if (null !== $existingGame) { + return true; + } + $playedGame = new PlayedGame(); $grid = new Grid(); - $rows = json_decode(base64_decode($data[0]), true, 512, JSON_THROW_ON_ERROR); + try { + $rows = json_decode(base64_decode($data[0]), true, 512, JSON_THROW_ON_ERROR); + } catch (JsonException $e) { + throw new RuntimeException($e->getMessage()); + } try { foreach ($rows as $row) { @@ -66,7 +82,6 @@ class RpcManager extends WebsocketManager implements RpcManagerInterface /** Save Row */ $gridRow->setGrid($grid); $this->entityManager->persist($gridRow); - } /** Save Grid */ @@ -81,8 +96,6 @@ class RpcManager extends WebsocketManager implements RpcManagerInterface $this->entityManager->persist($playedGame); $this->entityManager->flush(); - } catch (ORMException $e) { - $this->logger->error($e->getMessage()); } catch (Exception $e) { $this->logger->error($e->getMessage()); } diff --git a/src/Util/TopicManager.php b/src/Util/TopicManager.php index 57bb23f..07665cb 100644 --- a/src/Util/TopicManager.php +++ b/src/Util/TopicManager.php @@ -10,20 +10,20 @@ namespace App\Util; -use App\Entity\User; use App\Entity\Gamer; use App\Entity\PlayedGame; use App\Entity\Step; +use App\Entity\User; use App\Interfaces\TopicManagerInterface; use DateTime; use Doctrine\ORM\EntityManagerInterface; use Exception; -use Gos\Bundle\WebSocketBundle\Client\ClientManipulatorInterface; +use RuntimeException; +use JsonException; use Psr\Log\LoggerInterface; -use Ratchet\ConnectionInterface; -use Ratchet\Wamp\Topic; -use Symfony\Component\HttpFoundation\RequestStack; -use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; +use Symfony\Component\Mercure\HubInterface; +use Symfony\Component\Mercure\Update; +use Symfony\Component\Security\Core\User\UserInterface; /** * Class TopicManager @@ -35,79 +35,110 @@ use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; * @link www.splendidbear.org * @since 2026. 04. 09. */ -class TopicManager extends WebsocketManager implements TopicManagerInterface +class TopicManager implements TopicManagerInterface { public function __construct( - protected ClientManipulatorInterface $clientManipulator, - protected EntityManagerInterface $entityManager, - protected RequestStack $requestStack, - protected LoggerInterface $logger - ) - { - parent::__construct($logger); + private readonly HubInterface $hub, + private readonly EntityManagerInterface $entityManager, + private readonly LoggerInterface $logger + ) { } - public function subscribe(Topic $topic, ConnectionInterface $connection): void + public function subscribe(string $gameAssoc, string $userName, ?UserInterface $user): void { - /** this will broadcast the message to ALL subscribers of this topic. */ - $user = $this->clientManipulator->getClient($connection); - $userName = $user->getUserIdentifier() ?: 'anon_' . $connection->resourceId; + $playedGame = $this->getPlayedGame($gameAssoc); + if (null === $playedGame) { + return; + } - /** if more user wants to connect than 2 to one channel */ - if ($topic->count() > 2) { - $topic->remove($connection); - } else { - $users = $this->controlUsers($topic, $userName, $user); + $users = $this->getUserCollection($playedGame); + $count = $this->getPlayerCount($users); + $isKnown = in_array($userName, array_filter(array_values($users)), true); - $topic->broadcast([ - 'userTopicId' => $connection->resourceId, - 'channel' => $topic->getId(), - 'user' => $userName, - 'userCnt' => $topic->count(), - 'users' => $users - ]); + /** Reject a third player who is not a reconnecting player */ + if ($count >= 2 && !$isKnown) { + return; + } + + /** Save the player to the database on a fresh join */ + if (!$isKnown && $count < 2) { + $users = $this->saveUserToDb($gameAssoc, $userName, $user, $count + 1); + $count = $this->getPlayerCount($users); + } + + $topic = 'mineseeker/channel/' . $gameAssoc; + + try { + $this->hub->publish(new Update( + $topic, + json_encode([ + 'userTopicId' => $userName, + 'channel' => $topic, + 'user' => $userName, + 'userCnt' => $count, + 'users' => $users, + ], JSON_THROW_ON_ERROR) + )); + } catch (JsonException $e) { + throw new RuntimeException($e->getMessage()); } } - public function unSubscribe(Topic $topic, ConnectionInterface $connection): void + public function unSubscribe(string $gameAssoc, string $userName): void { - /** This will broadcasts the message to ALL subscribers of this topic. */ - $topic->broadcast(['msg' => $connection->resourceId . ' has left ' . $topic->getId()]); + $topic = 'mineseeker/channel/' . $gameAssoc; + + $this->hub->publish(new Update( + $topic, + json_encode(['msg' => $userName . ' has left ' . $topic]) + )); } - public function publish(Topic $topic, ConnectionInterface $connection, $event): void + public function publish(string $gameAssoc, string $userName, array $event): void { - $user = $this->clientManipulator->getClient($connection); - $userName = $user->getUserIdentifier(); - - /** Save every step by user to db */ null === $event['resign'] - ? $this->saveStepToDb($topic, $event) - : $this->saveResignToDb($topic, $event['resign']); + ? $this->saveStepToDb($gameAssoc, $event) + : $this->saveResignToDb($gameAssoc, $event['resign']); - $topic->broadcast([ - 'userTopicId' => $connection->resourceId, - 'channel' => $topic->getId(), - 'user' => $userName, - 'userCnt' => $topic->count(), - 'data' => $event - ]); + $playedGame = $this->getPlayedGame($gameAssoc); + $users = $this->getUserCollection($playedGame); + $count = $this->getPlayerCount($users); + $topic = 'mineseeker/channel/' . $gameAssoc; + + try { + $this->hub->publish(new Update( + $topic, + json_encode([ + 'userTopicId' => $userName, + 'channel' => $topic, + 'user' => $userName, + 'userCnt' => $count, + 'data' => $event, + ], JSON_THROW_ON_ERROR) + )); + } catch (JsonException $e) { + throw new RuntimeException($e->getMessage()); + } } - /** - * Save Resign event to database - * - * @param $topic - * @param $color - */ - private function saveResignToDb(Topic $topic, $color): void + private function getPlayedGame(string $gameAssoc): ?PlayedGame { - $gameAssoc = explode('/', $topic->getId())[2]; - - /** @var PlayedGame $playedGame */ - $playedGame = $this->entityManager + return $this->entityManager ->getRepository(PlayedGame::class) ->findOneByGameAssoc($gameAssoc); + } + + private function getPlayerCount(array $users): int + { + $red = '' !== $users['red'] || '' !== $users['redAnon'] ? 1 : 0; + $blue = '' !== $users['blue'] || '' !== $users['blueAnon'] ? 1 : 0; + + return $red + $blue; + } + + private function saveResignToDb(string $gameAssoc, string $color): void + { + $playedGame = $this->getPlayedGame($gameAssoc); $playedGame->setResign($color); @@ -115,30 +146,17 @@ class TopicManager extends WebsocketManager implements TopicManagerInterface $this->entityManager->flush(); } - /** - * Save steps and point information to database - * - * @param $topic - * @param $event - */ - private function saveStepToDb(Topic $topic, $event): void + private function saveStepToDb(string $gameAssoc, array $event): void { try { - $gameAssoc = explode('/', $topic->getId())[2]; - - /** @var PlayedGame $playedGame */ - $playedGame = $this->entityManager - ->getRepository(PlayedGame::class) - ->findOneByGameAssoc($gameAssoc); + $playedGame = $this->getPlayedGame($gameAssoc); $step = new Step(); - $step->setRow($event['coords'][0]); $step->setCol($event['coords'][1]); $step->setWBomb($event['bomb']); $step->setPlayedGame($playedGame); $step->setCreated(new DateTime()); - $this->entityManager->persist($step); $playedGame->setBluePoints($event['bluePoints']); @@ -146,7 +164,6 @@ class TopicManager extends WebsocketManager implements TopicManagerInterface $playedGame->setBlueExplodedBomb($event['blueExplodedBomb'] ? true : null); $playedGame->setRedExplodedBomb($event['redExplodedBomb'] ? true : null); $playedGame->setUpdated(new DateTime()); - $this->entityManager->persist($playedGame); $this->entityManager->flush(); @@ -155,62 +172,11 @@ class TopicManager extends WebsocketManager implements TopicManagerInterface } } - /** - * Control all users in a channel - * - * @param Topic $topic - * @param string $userName - * @param $user - * - * @return array - */ - private function controlUsers(Topic $topic, string $userName, TokenInterface $user): array + private function saveUserToDb(string $gameAssoc, string $userName, ?UserInterface $user, int $count): array { - $gameAssoc = explode('/', $topic->getId())[2]; + $playedGame = $this->getPlayedGame($gameAssoc); - /** @var PlayedGame $playedGame */ - $playedGame = $this->entityManager - ->getRepository(PlayedGame::class) - ->findOneByGameAssoc($gameAssoc); - - /** @var $users {array} */ - $users = $this->getUserCollection($playedGame); - - $red = '' !== $users['red'] || '' !== $users['redAnon'] ? 1 : 0; - $blue = '' !== $users['blue'] || '' !== $users['blueAnon'] ? 1 : 0; - $one = $topic->count() === 1; - $two = $topic->count() === 2; - - /** This checks it is a reconnection */ - if (($one && ($red + $blue === 0)) || ($two && ($red + $blue === 1))) { - /** @var $users {array} w/ save users to database */ - $users = $this->saveUserToDb($topic, $userName, $user, $topic->count()); - } - - return $users; - } - - /** - * Save user data to database - * - * @param $topic - * @param $userName - * @param $user - * @param $count - * - * @return array - */ - private function saveUserToDb(Topic $topic, string $userName, TokenInterface $user, $count) - { - $gameAssoc = explode('/', $topic->getId())[2]; - - /** @var PlayedGame $playedGame */ - $playedGame = $this->entityManager - ->getRepository(PlayedGame::class) - ->findOneByGameAssoc($gameAssoc); - - /** when the user is not anonym */ - null !== $user->getUser() + null !== $user ? $this->saveRegisteredUser($userName, $count, $playedGame) : $this->saveAnonUser($userName, $count, $playedGame); @@ -220,23 +186,15 @@ class TopicManager extends WebsocketManager implements TopicManagerInterface return $this->getUserCollection($playedGame); } - /** - * Saves the registered user to the database - * - * @param string $userName - * @param int $count - * @param PlayedGame $playedGame - */ - private function saveRegisteredUser(string $userName, int $count, PlayedGame $playedGame) + private function saveRegisteredUser(string $userName, int $count, PlayedGame $playedGame): void { - /** @var User $FOSUser */ + /** @var User $user */ $user = $this->entityManager ->getRepository(User::class) ->findOneByUsername($userName); try { if ($count === 1) { - /** @var $random {integer} Active player: red: 0, blue: 1 */ $random = random_int(0, 1); !$random ? $playedGame->setRed($user) : $playedGame->setBlue($user); } else { @@ -249,17 +207,8 @@ class TopicManager extends WebsocketManager implements TopicManagerInterface } } - /** - * Save anonymous Gamer to database - * - * @param string $userName - * @param int $count - * @param PlayedGame $playedGame - */ - private function saveAnonUser(string $userName, int $count, PlayedGame $playedGame) + private function saveAnonUser(string $userName, int $count, PlayedGame $playedGame): void { - // $request = $this->requestStack->getCurrentRequest(); // TODO nem megy... - try { $anon = new Gamer(); $anon->setUsername($userName); @@ -267,7 +216,6 @@ class TopicManager extends WebsocketManager implements TopicManagerInterface $this->entityManager->persist($anon); if ($count === 1) { - /** @var $random {integer} Active player: red: 0, blue: 1 */ $random = random_int(0, 1); !$random ? $playedGame->setRedAnon($anon) : $playedGame->setBlueAnon($anon); } else { @@ -280,20 +228,13 @@ class TopicManager extends WebsocketManager implements TopicManagerInterface } } - /** - * Get user collection from PlayedGame entity - * - * @param $playedGame - * - * @return array - */ private function getUserCollection(PlayedGame $playedGame): array { return [ - 'red' => null !== $playedGame->getRed() ? $playedGame->getRed()->getUsername() : '', - 'blue' => null !== $playedGame->getBlue() ? $playedGame->getBlue()->getUsername() : '', - 'redAnon' => null !== $playedGame->getRedAnon() ? $playedGame->getRedAnon()->getUserName() : '', - 'blueAnon' => null !== $playedGame->getBlueAnon() ? $playedGame->getBlueAnon()->getUserName() : '' + 'red' => null !== $playedGame->getRed() ? $playedGame->getRed()->getUsername() : '', + 'blue' => null !== $playedGame->getBlue() ? $playedGame->getBlue()->getUsername() : '', + 'redAnon' => null !== $playedGame->getRedAnon() ? $playedGame->getRedAnon()->getUserName() : '', + 'blueAnon' => null !== $playedGame->getBlueAnon() ? $playedGame->getBlueAnon()->getUserName() : '', ]; } } diff --git a/src/Util/WebsocketManager.php b/src/Util/WebsocketManager.php deleted file mode 100644 index 35f41bf..0000000 --- a/src/Util/WebsocketManager.php +++ /dev/null @@ -1,47 +0,0 @@ - - * @category Class - * @license https://www.gnu.org/licenses/lgpl-3.0.en.html GNU Lesser General Public License - * @link www.splendidbear.org - * @since 2026. 04. 09. - */ -class WebsocketManager implements WebsocketManagerInterface -{ - public function __construct(private LoggerInterface $logger) { } - - public function reConnect(EntityManagerInterface $entityManager): ?EntityManagerInterface - { - try { - $connection = $entityManager->getConnection(); - - if (false === $connection->ping()) { - $connection->close(); - $connection->connect(); - } - } catch (RuntimeException $e) { - $this->logger->error($e->getMessage()); - } - - return $entityManager; - } -} diff --git a/symfony.lock b/symfony.lock index fec0ec9..da7b284 100644 --- a/symfony.lock +++ b/symfony.lock @@ -1,7 +1,4 @@ { - "cboden/ratchet": { - "version": "v0.4.1" - }, "cocur/slugify": { "version": "v3.1" }, @@ -74,27 +71,12 @@ "egulias/email-validator": { "version": "2.1.4" }, - "evenement/evenement": { - "version": "v3.0.1" - }, "friendsofsymfony/user-bundle": { "version": "v2.1.2" }, "gos/pnctl-event-loop-emitter": { "version": "v0.1.7" }, - "gos/pubsub-router-bundle": { - "version": "v0.3.3" - }, - "gos/web-socket-bundle": { - "version": "v1.8.12" - }, - "gos/websocket-client": { - "version": "v0.1.2" - }, - "guzzlehttp/psr7": { - "version": "1.4.2" - }, "jdorn/sql-formatter": { "version": "v1.2.17" }, @@ -119,42 +101,15 @@ "psr/container": { "version": "1.0.0" }, - "psr/http-message": { - "version": "1.0.1" - }, "psr/log": { "version": "1.0.2" }, "psr/simple-cache": { "version": "1.0.1" }, - "ralouphie/getallheaders": { - "version": "3.0.3" - }, - "ratchet/rfc6455": { - "version": "0.2.4" - }, - "react/cache": { - "version": "v0.4.2" - }, - "react/dns": { - "version": "v0.4.13" - }, - "react/event-loop": { - "version": "v0.5.2" - }, - "react/promise": { - "version": "v2.5.1" - }, "react/promise-timer": { "version": "v1.3.0" }, - "react/socket": { - "version": "v0.8.11" - }, - "react/stream": { - "version": "v0.7.7" - }, "roave/security-advisories": { "version": "dev-master" }, @@ -275,6 +230,18 @@ "ref": "fadbfe33303a76e25cb63401050439aa9b1a9c7f" } }, + "symfony/mercure-bundle": { + "version": "0.4", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "0.4", + "ref": "b141b8c8f13bc8c31d718a5488039b712c0d3592" + }, + "files": [ + "config/packages/mercure.yaml" + ] + }, "symfony/mime": { "version": "v4.3.5" }, @@ -295,9 +262,6 @@ "config/packages/test/monolog.yaml" ] }, - "symfony/options-resolver": { - "version": "v4.0.9" - }, "symfony/orm-pack": { "version": "v1.0.5" }, diff --git a/templates/Game/play.html.twig b/templates/Game/play.html.twig index f71ce0c..8a59943 100644 --- a/templates/Game/play.html.twig +++ b/templates/Game/play.html.twig @@ -9,8 +9,9 @@
+ data-game-id="{{ app.request.get('gameAssoc') }}" + data-mercure-hub-url="{{ mercure_hub_url }}" + data-mercure-subscriber-jwt="{{ mercure_subscriber_jwt }}">
{% endblock %} @@ -27,7 +28,7 @@ {% block stylesheets %} {{ encore_entry_link_tags('mineseekerStyle') }} -