├── .editorconfig ├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── composer.json ├── config ├── bootstrap.php ├── routes.php └── websocket.default.php ├── phpunit.xml.dist ├── src ├── Controller │ └── ExampleController.php ├── Lib │ ├── Websocket.php │ ├── WebsocketInterface.php │ └── WebsocketWorker.php ├── Shell │ └── WebsocketServerShell.php └── Template │ ├── Element │ ├── user_data_panel.ctp │ └── user_form_panel.ctp │ └── Example │ └── index.ctp ├── webroot └── js │ ├── app │ └── controllers │ │ └── example │ │ ├── index_controller.js │ │ ├── user_data_panel_controller.js │ │ └── user_form_panel_controller.js │ └── lib │ └── websocket.js └── websocket.png /.editorconfig: -------------------------------------------------------------------------------- 1 | ; This file is for unifying the coding style for different editors and IDEs. 2 | ; More information at http://editorconfig.org 3 | 4 | root = true 5 | 6 | [*] 7 | indent_style = space 8 | indent_size = 4 9 | end_of_line = lf 10 | insert_final_newline = true 11 | trim_trailing_whitespace = true 12 | 13 | [*.bat] 14 | end_of_line = crlf 15 | 16 | [*.yml] 17 | indent_style = space 18 | indent_size = 2 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .tags 3 | composer.lock 4 | /vendor/* 5 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: php 2 | 3 | php: 4 | - 7.1 5 | 6 | before_script: 7 | - composer self-update 8 | - composer install --prefer-dist 9 | 10 | script: 11 | - vendor/bin/phpcs -pn --extensions=php --standard=vendor/cakephp/cakephp-codesniffer/CakePHP ./src 12 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 scherer software (http://scherer-software.de) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![CakePHP 3 Websocket Plugin](https://raw.githubusercontent.com/scherersoftware/cake-websocket/master/websocket.png) 2 | 3 | [![Build Status](https://travis-ci.org/scherersoftware/cake-websocket.svg?branch=master)](https://travis-ci.org/scherersoftware/cake-websocket) 4 | [![License](https://poser.pugx.org/scherersoftware/cake-websocket/license)](https://packagist.org/packages/scherersoftware/cake-websocket) 5 | [![Latest Stable Version](https://poser.pugx.org/scherersoftware/cake-websocket/v/stable)](https://packagist.org/packages/scherersoftware/cake-websocket) 6 | [![Latest Unstable Version](https://poser.pugx.org/scherersoftware/cake-websocket/v/unstable)](https://packagist.org/packages/scherersoftware/cake-websocket) 7 | [![Monthly Downloads](https://poser.pugx.org/scherersoftware/cake-websocket/d/monthly)](https://packagist.org/packages/scherersoftware/cake-websocket) 8 | 9 | ## Introduction 10 | 11 | This CakePHP 3 plugin gives you an easy way to add websocket capability to your web application. 12 | 13 | #### Main Packages 14 | 15 | - [Cake Frontend Bridge](https://github.com/scherersoftware/cake-frontend-bridge) 16 | - [Ratchet](https://github.com/ratchetphp/Ratchet) 17 | - [CakePHP Queuesadilla](https://github.com/josegonzalez/cakephp-queuesadilla) 18 | 19 | #### Requirements 20 | 21 | - CakePHP 3.3 or higher 22 | - PHP 7.1 23 | 24 | --- 25 | 26 | ## Usage in 4 easy steps 27 | 28 | **Note:** You can checkout our [CakePHP App Template](https://github.com/scherersoftware/cake-app-template) for testing it on a clean app setup with preinstalled dependencies. 29 | 30 | #### 1. Define a new event 31 | 32 | **Example for `websocket_events.php`** 33 | 34 | ``` 35 | ... 36 | 'userDataUpdated' => [ 37 | 'audience' => [ 38 | 'includeAllNotAuthenticated' => false, 39 | 'includeAllAuthenticated' => true 40 | ] 41 | ] 42 | ... 43 | ``` 44 | 45 | #### 2. Publish the event in server context (e.g. Shell, Controller, Table...) 46 | 47 | **Example for `UsersController.php`** 48 | 49 | ``` 50 | ... 51 | use Websocket\Lib\Websocket; 52 | ... 53 | if ($this->Users->save($exampleUser)) { 54 | Websocket::publishEvent('userDataUpdated', ['editedUserId' => $exampleUser->id]); 55 | } 56 | ... 57 | ``` 58 | 59 | #### 3. Let the client receive the event and define a callback 60 | 61 | **Example for `../users/index_controller.js`** 62 | 63 | ``` 64 | ... 65 | App.Websocket.onEvent('userDataUpdated', function(payload) { 66 | if (payload.editedUserId === this.exampleUser.id) { 67 | alert('Someone changed the data of this user!'); 68 | } 69 | }.bind(this)); 70 | ... 71 | ``` 72 | 73 | #### 4. Run the websocket server shell and start testing! 74 | 75 | ``` 76 | $ bin/cake websocket_server 77 | ``` 78 | 79 | --- 80 | 81 | ## Installation 82 | 83 | #### 1. Require the plugin 84 | 85 | You can install this plugin into your CakePHP application using [composer](http://getcomposer.org). 86 | 87 | The recommended way to install composer packages is: 88 | 89 | ``` 90 | composer require scherersoftware/cake-websocket 91 | ``` 92 | 93 | #### 2. Load the plugin 94 | 95 | The next step is to load the plugin properly inside your bootstrap.php: 96 | 97 | ``` 98 | Plugin::load('Websocket', ['bootstrap' => true, 'routes' => true]); 99 | ``` 100 | 101 | #### 3. Configure app config 102 | 103 | - **File:** `/config/app.php` 104 | 105 | ``` 106 | [ 109 | 'ssl' => false, 110 | 'host' => '127.0.0.1', 111 | 'externalHost' => 'cws.dev', 112 | 'port' => 8889, 113 | 'frontendPath' => [ 114 | 'ssl' => [ 115 | 'path' => '/wss/', 116 | 'usePort' => false 117 | ], 118 | 'normal' => [ 119 | 'path' => '/', 120 | 'usePort' => true 121 | ] 122 | ], 123 | 'sessionCookieName' => 'cws', 124 | 'Queue' => [ 125 | 'name' => 'websocket', 126 | 'loopInterval' => 0.1, 127 | ] 128 | ] 129 | ... 130 | ``` 131 | 132 | #### 4. Create and configure websocket events 133 | 134 | - **File:** `/config/websocket_events.php` 135 | 136 | ``` 137 | [ 140 | 'audience' => [ 141 | 'includeAllNotAuthenticated' => false, 142 | 'includeAllAuthenticated' => true 143 | ] 144 | ] 145 | ]; 146 | ``` 147 | 148 | #### 5. Configure `AppController.php` 149 | 150 | In your `src/Controller/AppController.php`, insert the following pieces of code 151 | 152 | 153 | **Usage:** 154 | 155 | ``` 156 | use Websocket\Lib\Websocket; 157 | ``` 158 | 159 | **beforeFilter():** 160 | 161 | ``` 162 | ... 163 | $this->FrontendBridge->setJson('websocketFrontendConfig', Websocket::getFrontendConfig()); 164 | ... 165 | ``` 166 | 167 | 168 | #### 6. Make the JS websocket lib globally accessible under `App.Websocket` 169 | 170 | - Load the file /webroot/lib/websocket.js after loading the Frontend Bridge assets 171 | 172 | #### 7. Setup sessions properly if not alread done 173 | 174 | Please follow the [Cake Sessions Documentation](https://book.cakephp.org/3.0/en/development/sessions.html) 175 | 176 | #### 8. Setup Apache SSL ProxyPass if necessary 177 | 178 | Make sure these modules are activated: 179 | - mod_proxy.so 180 | - mod_proxy_wstunnel.so 181 | 182 | Edit your vhosts configuration and add this to the ssl section: 183 | 184 | ``` 185 | ProxyPass /wss/ ws://localhost:8889/ 186 | ``` 187 | 188 | --- 189 | 190 | ## Roadmap 191 | 192 | #### 1.1.0 193 | - Unit Tests 194 | - Add a websocket_connections table which stores all active connections 195 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "scherersoftware/cake-websocket", 3 | "description": "Websocket plugin for CakePHP", 4 | "license": "MIT", 5 | "type": "cakephp-plugin", 6 | "homepage": "https://github.com/scherersoftware/cake-websocket", 7 | "require": { 8 | "php": ">=7.1.0", 9 | "cakephp/cakephp": ">=3.3.2 < 4.0.0", 10 | "react/event-loop": "0.4.*|0.3.*", 11 | "react/Socket": "^0.3 || ^0.4", 12 | "cboden/ratchet": "^0.3.6", 13 | "josegonzalez/cakephp-queuesadilla": ">=0.3.5", 14 | "codekanzlei/cake-frontend-bridge": ">=1.0.0" 15 | }, 16 | "require-dev": { 17 | "phpunit/phpunit": "*", 18 | "cakephp/cakephp-codesniffer": "dev-master" 19 | }, 20 | "autoload": { 21 | "psr-4": { 22 | "Websocket\\": "src" 23 | } 24 | }, 25 | "autoload-dev": { 26 | "psr-4": { 27 | "Websocket\\Test\\": "tests", 28 | "Cake\\Test\\": "./vendor/cakephp/cakephp/tests" 29 | } 30 | }, 31 | "scripts": { 32 | "cs-check": "vendor/bin/phpcs --colors -p --standard=vendor/cakephp/cakephp-codesniffer/CakePHP ./src" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /config/bootstrap.php: -------------------------------------------------------------------------------- 1 | '/websocket'], 9 | function (RouteBuilder $routes) { 10 | $routes->fallbacks(DashedRoute::class); 11 | } 12 | ); 13 | -------------------------------------------------------------------------------- /config/websocket.default.php: -------------------------------------------------------------------------------- 1 | [ 4 | 'ssl' => false, 5 | 'host' => 'cws.dev', 6 | 'externalHost' => 'cws.dev', 7 | 'port' => 8889, 8 | 'frontendPath' => [ 9 | 'ssl' => [ 10 | 'path' => '/wss/', 11 | 'usePort' => false 12 | ], 13 | 'normal' => [ 14 | 'path' => '/', 15 | 'usePort' => true 16 | ] 17 | ], 18 | 'sessionCookieName' => 'cws', 19 | 'Queue' => [ 20 | 'name' => 'websocket', 21 | 'loopInterval' => 0.1, 22 | ] 23 | ] 24 | ]; 25 | -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | ./tests/TestCase 18 | 19 | 20 | 21 | 22 | 23 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | ./vendor/ 36 | ./vendor/ 37 | 38 | ./tests/ 39 | ./tests/ 40 | 41 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /src/Controller/ExampleController.php: -------------------------------------------------------------------------------- 1 | loadModel('Users'); 19 | 20 | $exampleUser = $this->Users->find() 21 | ->order([ 22 | 'created' => 'ASC' 23 | ]) 24 | ->first(); 25 | 26 | $this->set('exampleUser', $exampleUser); 27 | } 28 | 29 | /** 30 | * action to load data panel of user 31 | * 32 | * @return \Cake\Network\Response 33 | */ 34 | public function userDataPanel(): Response 35 | { 36 | $this->loadModel('Users'); 37 | 38 | $exampleUser = $this->Users->find() 39 | ->order([ 40 | 'created' => 'ASC' 41 | ]) 42 | ->first(); 43 | 44 | $this->FrontendBridge->setBoth('exampleUser', $exampleUser); 45 | 46 | return $this->render('/Element/user_data_panel'); 47 | } 48 | 49 | /** 50 | * action to load form panel of user 51 | * 52 | * @return \Cake\Network\Response 53 | */ 54 | public function userFormPanel(): Response 55 | { 56 | $this->loadModel('Users'); 57 | 58 | $exampleUser = $this->Users->find() 59 | ->order([ 60 | 'created' => 'ASC' 61 | ]) 62 | ->first(); 63 | 64 | if ($this->request->is(['post', 'patch', 'put'])) { 65 | $exampleUser->accessible('*', false); 66 | $exampleUser->accessible(['firstname', 'lastname'], true); 67 | $this->Users->patchEntity($exampleUser, $this->request->data, [ 68 | 'validate' => false 69 | ]); 70 | 71 | if ($this->Users->save($exampleUser)) { 72 | Websocket::publishEvent('userDataUpdated', ['editedUserId' => $exampleUser->id], []); 73 | } 74 | } 75 | 76 | $this->FrontendBridge->setBoth('exampleUser', $exampleUser); 77 | 78 | return $this->render('/Element/user_form_panel'); 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/Lib/Websocket.php: -------------------------------------------------------------------------------- 1 | false, 23 | * // whether all authenticated clients should receive the event (overwrites event default) 24 | * 'includeAllAuthenticated' => true, 25 | * // authenticated clients to send the event to (works independent of the settings above) 26 | * 'userIds' => [] 27 | * ] 28 | * @return bool 29 | * @throws \Exception if config of given event name is invalid 30 | */ 31 | public static function publishEvent(string $eventName, array $payload = [], array $audience = []): bool 32 | { 33 | if (defined('PHPUNIT_TESTSUITE')) { 34 | return false; 35 | } 36 | 37 | if (!self::validateEventConfig($eventName)) { 38 | throw new \Exception('Invalid Websocket event config.'); 39 | } 40 | 41 | $audience = Hash::merge(Configure::read('Websocket.events.' . $eventName . '.audience'), $audience); 42 | 43 | return Queue::push($eventName, [ 44 | 'payload' => $payload, 45 | 'audience' => $audience 46 | ], [ 47 | 'queue' => Configure::read('Websocket.Queue.name') 48 | ]); 49 | } 50 | 51 | /** 52 | * Validates config of one given or all configured events 53 | * 54 | * @param string|null $eventName name of event 55 | * @return bool 56 | */ 57 | public static function validateEventConfig(string $eventName = null): bool 58 | { 59 | $eventConfigs = Configure::read('Websocket.events'); 60 | 61 | if (!is_null($eventName) && !array_key_exists($eventName, $eventConfigs)) { 62 | return false; 63 | } 64 | 65 | foreach ($eventConfigs as $eventName => $eventConfig) { 66 | if (empty($eventConfig['audience'])) { 67 | return false; 68 | } 69 | 70 | if (!isset($eventConfig['audience']['includeAllNotAuthenticated']) 71 | || !is_bool($eventConfig['audience']['includeAllNotAuthenticated']) 72 | ) { 73 | return false; 74 | } 75 | 76 | if (!isset($eventConfig['audience']['includeAllAuthenticated']) 77 | || !is_bool($eventConfig['audience']['includeAllAuthenticated']) 78 | ) { 79 | return false; 80 | } 81 | 82 | if (isset($eventConfig['audience']['userIds'])) { 83 | if (!is_array($eventConfig['audience']['userIds'])) { 84 | return false; 85 | } 86 | } else { 87 | Configure::write('Websocket.events.' . $eventName . '.audience.userIds', []); 88 | } 89 | } 90 | 91 | return true; 92 | } 93 | 94 | /** 95 | * get reduced websocket config for the frontend 96 | * 97 | * @return array 98 | */ 99 | public static function getFrontendConfig(): array 100 | { 101 | $paths = Configure::read('Websocket.frontendPath'); 102 | $pathUsed = Configure::read('Websocket.ssl') ? $paths['ssl'] : $paths['normal']; 103 | $host = Configure::read('Websocket.ssl') ? 'wss://' : 'ws://'; 104 | $host .= Configure::read('Websocket.externalHost'); 105 | 106 | return [ 107 | 'host' => $host, 108 | 'port' => Configure::read('Websocket.port'), 109 | 'path' => $pathUsed['path'], 110 | 'usePort' => $pathUsed['usePort'] 111 | ]; 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /src/Lib/WebsocketInterface.php: -------------------------------------------------------------------------------- 1 | __session = Session::create(Configure::read('Session')); 37 | } 38 | 39 | /** 40 | * Publish an event base on given queue entry 41 | * 42 | * @param array $queueEntry database entry array 43 | * @return true 44 | */ 45 | public function publishEvent(array $queueEntry): bool 46 | { 47 | $eventName = $queueEntry['class']; 48 | $payload = $queueEntry['args'][0]['payload']; 49 | $audience = $queueEntry['args'][0]['audience']; 50 | 51 | foreach ($this->__connections as $connection) { 52 | if ($this->_userIsInAudience($connection['userId'], $audience)) { 53 | $connection['connection']->send(json_encode(compact('eventName', 'payload'))); 54 | } 55 | } 56 | 57 | return true; 58 | } 59 | 60 | /** 61 | * {@inheritDoc} 62 | */ 63 | public function onOpen(ConnectionInterface $connection): void 64 | { 65 | $symphonySessionId = $connection->wrappedConn->WAMP->sessionId; 66 | $this->__connections[$symphonySessionId]['connection'] = $connection; 67 | $this->__connections[$symphonySessionId]['userId'] = $this->__getUserIdFromSession($connection); 68 | } 69 | 70 | /** 71 | * {@inheritDoc} 72 | */ 73 | public function onClose(ConnectionInterface $connection): void 74 | { 75 | unset($this->__connections[$connection->wrappedConn->WAMP->sessionId]); 76 | } 77 | 78 | /** 79 | * {@inheritDoc} 80 | */ 81 | public function onError(ConnectionInterface $connection, \Exception $e): void 82 | { 83 | unset($this->__connections[$connection->wrappedConn->WAMP->sessionId]); 84 | } 85 | 86 | /** 87 | * {@inheritDoc} 88 | */ 89 | public function onCall(ConnectionInterface $connection, $id, $topic, array $params): void 90 | { 91 | $connection->close(); 92 | } 93 | 94 | /** 95 | * {@inheritDoc} 96 | */ 97 | public function onPublish(ConnectionInterface $connection, $topic, $event, array $exclude, array $eligible): void 98 | { 99 | $connection->close(); 100 | } 101 | 102 | /** 103 | * {@inheritDoc} 104 | */ 105 | public function onUnSubscribe(ConnectionInterface $connection, $topic): void 106 | { 107 | $connection->close(); 108 | } 109 | 110 | /** 111 | * {@inheritDoc} 112 | */ 113 | public function onSubscribe(ConnectionInterface $connection, $topic): void 114 | { 115 | $connection->close(); 116 | } 117 | 118 | /** 119 | * Check if user is in given target audience 120 | * 121 | * @param null|string $userId user identifier or null if user is not logged in 122 | * @param array $audience ruleset of audience for event 123 | * @return bool 124 | */ 125 | protected function _userIsInAudience(?string $userId, array $audience): bool 126 | { 127 | if (in_array($userId, $audience['userIds'])) { 128 | return true; 129 | } 130 | if (empty($userId)) { 131 | return $audience['includeAllNotAuthenticated']; 132 | } else { 133 | return $audience['includeAllAuthenticated']; 134 | } 135 | } 136 | 137 | /** 138 | * Get the user id from the session 139 | * 140 | * @param ConnectionInterface $connection websocket client connection 141 | * @return null|string 142 | */ 143 | private function __getUserIdFromSession(ConnectionInterface $connection): ?string 144 | { 145 | $userId = null; 146 | $sessionId = null; 147 | $sessionCookieName = Configure::read('Websocket.sessionCookieName'); 148 | if (!empty($connection->WebSocket->request->getCookies()[$sessionCookieName])) { 149 | $sessionId = $connection->WebSocket->request->getCookies()[$sessionCookieName]; 150 | } 151 | if (!empty($sessionId)) { 152 | session_abort(); 153 | $this->__session->id($sessionId); 154 | session_start(); 155 | $userId = $this->__session->read('Auth.User.id'); 156 | } 157 | 158 | return $userId; 159 | } 160 | } 161 | -------------------------------------------------------------------------------- /src/Lib/WebsocketWorker.php: -------------------------------------------------------------------------------- 1 | __loop = $loop; 52 | $this->__websocketInterface = $websocketInterface; 53 | $this->__logger = $logger; 54 | 55 | $this->__loop->addPeriodicTimer(Configure::read('Websocket.Queue.loopInterval'), function () { 56 | $this->work(); 57 | }); 58 | 59 | $this->__initialSetup(); 60 | } 61 | 62 | /** 63 | * {@inheritDoc} 64 | * @SuppressWarnings(PHPMD.CyclomaticComplexity) 65 | */ 66 | public function work() 67 | { 68 | if (!$this->connect()) { 69 | $this->logger()->alert(sprintf('Worker unable to connect, exiting')); 70 | $this->dispatchEvent('Worker.job.connectionFailed'); 71 | 72 | return false; 73 | } 74 | 75 | $jobClass = $this->engine->getJobClass(); 76 | 77 | $this->iterations++; 78 | $item = $this->engine->pop($this->queue); 79 | $this->dispatchEvent('Worker.job.seen', ['item' => $item]); 80 | if (empty($item)) { 81 | $this->logger()->debug('No job!'); 82 | $this->dispatchEvent('Worker.job.empty'); 83 | 84 | return; 85 | } 86 | 87 | $success = false; 88 | $job = new $jobClass($item, $this->engine); 89 | 90 | try { 91 | $success = $this->perform($item); 92 | } catch (Exception $e) { 93 | $this->logger()->alert(sprintf('Exception: "%s"', $e->getMessage())); 94 | $this->dispatchEvent('Worker.job.exception', [ 95 | 'job' => $job, 96 | 'exception' => $e, 97 | ]); 98 | } 99 | 100 | if ($success) { 101 | $this->logger()->debug('Success. Acknowledging job on queue.'); 102 | $job->acknowledge(); 103 | $this->dispatchEvent('Worker.job.success', ['job' => $job]); 104 | 105 | return; 106 | } 107 | 108 | $this->logger()->info('Failed. Releasing job to queue'); 109 | $job->release(); 110 | $this->dispatchEvent('Worker.job.failure', ['job' => $job]); 111 | 112 | return true; 113 | } 114 | 115 | /** 116 | * Connect to the data storage using the configured engine 117 | * 118 | * @return bool 119 | */ 120 | public function connect(): bool 121 | { 122 | $maxIterations = $this->maxIterations ? sprintf(', max iterations %s', $this->maxIterations) : ''; 123 | $this->logger()->info(sprintf('Starting worker%s', $maxIterations)); 124 | 125 | return (bool)$this->engine->connection(); 126 | } 127 | 128 | /** 129 | * Publishes event based on database entry 130 | * 131 | * @param array $queueEntry database entry 132 | * @return bool 133 | */ 134 | public function perform(array $queueEntry): bool 135 | { 136 | return $this->__websocketInterface->publishEvent($queueEntry); 137 | } 138 | 139 | /** 140 | * helper method to do all the dirty construction work 141 | * 142 | * @return void 143 | */ 144 | private function __initialSetup(): void 145 | { 146 | // FIXME clean this up maybe 147 | $logger = Log::engine('error'); 148 | if (!empty($this->__logger)) { 149 | $logger = $this->__logger; 150 | } 151 | $engine = Queue::engine('default'); 152 | $engine->setLogger($logger); 153 | $engine->config('queue', Configure::read('Websocket.Queue.name')); 154 | $this->engine = $engine; 155 | $this->queue = Configure::read('Websocket.Queue.name'); 156 | $this->maxIterations = null; 157 | $this->iterations = 0; 158 | $this->maxRuntime = null; 159 | $this->runtime = 0; 160 | $this->name = get_class($this->engine) . ' Worker'; 161 | $this->setLogger($logger); 162 | $this->StatsListener = new StatsListener; 163 | $this->attachListener($this->StatsListener); 164 | register_shutdown_function([&$this, 'shutdownHandler']); 165 | } 166 | 167 | /** 168 | * {@inheritDoc} 169 | */ 170 | protected function disconnect() 171 | { 172 | } 173 | } 174 | -------------------------------------------------------------------------------- /src/Shell/WebsocketServerShell.php: -------------------------------------------------------------------------------- 1 | addOption('logger', [ 36 | 'help' => 'Name of a configured logger', 37 | 'short' => 'l', 38 | ]); 39 | 40 | return $parser; 41 | } 42 | 43 | /** 44 | * main function 45 | * 46 | * @return void 47 | */ 48 | public function main(): void 49 | { 50 | $loop = Factory::create(); 51 | $websocketInterface = new WebsocketInterface; 52 | 53 | $logger = null; 54 | if (isset($this->params['logger']) && Log::engine($this->params['logger']) !== false) { 55 | $logger = Log::engine($this->params['logger']); 56 | } 57 | 58 | $websocketWorker = new WebsocketWorker($loop, $websocketInterface, Queue::engine('default'), $logger); 59 | 60 | $websocketWorker->attachListener('Worker.job.exception', function ($event) { 61 | $exception = $event->data['exception']; 62 | $exception->job = $event->data['job']; 63 | $sentryHandler = new SentryHandler(); 64 | $sentryHandler->handle($exception); 65 | }); 66 | 67 | $websocketWorker->attachListener('Worker.job.start', function ($event) { 68 | ConnectionManager::get('default')->disconnect(); 69 | }); 70 | 71 | $websocketWorker->attachListener('Worker.job.success', function ($event) { 72 | ConnectionManager::get('default')->disconnect(); 73 | }); 74 | 75 | $websocketWorker->attachListener('Worker.job.failure', function ($event) { 76 | $failedJob = $event->data['job']; 77 | $failedItem = $failedJob->item(); 78 | $options = [ 79 | 'queue' => 'failed', 80 | 'failedJob' => $failedJob 81 | ]; 82 | Queue::push($failedItem['class'], $failedJob->data(), $options); 83 | ConnectionManager::get('default')->disconnect(); 84 | }); 85 | 86 | $serverSocket = new Server($loop); 87 | $serverSocket->listen(Configure::read('Websocket.port'), Configure::read('Websocket.host')); 88 | 89 | $webServer = new IoServer( 90 | new HttpServer( 91 | new WsServer( 92 | new WampServer( 93 | $websocketInterface 94 | ) 95 | ) 96 | ), 97 | $serverSocket 98 | ); 99 | 100 | $loop->run(); 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /src/Template/Element/user_data_panel.ctp: -------------------------------------------------------------------------------- 1 |
2 |
3 |

4 | User Data 5 |

6 |
7 |
8 |
9 |
Firstname
10 |
firstname ?>
11 | 12 |
Lastname
13 |
lastname ?>
14 |
15 |
16 |
17 | -------------------------------------------------------------------------------- /src/Template/Element/user_form_panel.ctp: -------------------------------------------------------------------------------- 1 |
2 |
3 |

4 | User Form 5 |

6 |
7 |
8 | Form->create($exampleUser) ?> 9 | Form->input('firstname', [ 10 | 'value' => '' 11 | ]) ?> 12 | Form->input('lastname', [ 13 | 'value' => '' 14 | ]) ?> 15 | Form->submit() ?> 16 | Form->end() ?> 17 |
18 |
19 | -------------------------------------------------------------------------------- /src/Template/Example/index.ctp: -------------------------------------------------------------------------------- 1 |

Websocket Example

2 | 3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 | -------------------------------------------------------------------------------- /webroot/js/app/controllers/example/index_controller.js: -------------------------------------------------------------------------------- 1 | App.Controllers.ExampleIndexController = Frontend.AppController.extend({ 2 | startup: function() { 3 | App.Main.loadJsonAction({ 4 | 'plugin': 'websocket', 5 | 'controller': 'example', 6 | 'action': 'userDataPanel' 7 | }, { 8 | target: this.$('.user-data-panel-target'), 9 | replaceTarget: true 10 | }); 11 | 12 | App.Main.loadJsonAction({ 13 | 'plugin': 'websocket', 14 | 'controller': 'example', 15 | 'action': 'userFormPanel' 16 | }, { 17 | target: this.$('.user-form-panel-target'), 18 | replaceTarget: true 19 | }); 20 | } 21 | }); 22 | -------------------------------------------------------------------------------- /webroot/js/app/controllers/example/user_data_panel_controller.js: -------------------------------------------------------------------------------- 1 | App.Controllers.ExampleUserDataPanelController = Frontend.AppController.extend({ 2 | exampleUser: null, 3 | startup: function() { 4 | this.exampleUser = this.getVar('exampleUser'); 5 | App.Websocket.onEvent('userDataUpdated', function(payload) { 6 | if (payload.editedUserId === this.exampleUser.id) { 7 | this.reloadPanel(); 8 | } 9 | 10 | }.bind(this)); 11 | }, 12 | reloadPanel: function() { 13 | var url = { 14 | 'plugin': 'websocket', 15 | 'controller': 'example', 16 | 'action': 'userDataPanel' 17 | }; 18 | App.Main.loadJsonAction(url, { 19 | target: $(this._dom), 20 | replaceTarget: true 21 | }); 22 | } 23 | }); 24 | -------------------------------------------------------------------------------- /webroot/js/app/controllers/example/user_form_panel_controller.js: -------------------------------------------------------------------------------- 1 | App.Controllers.ExampleUserFormPanelController = Frontend.AppController.extend({ 2 | startup: function() { 3 | var $form = this.$('form'); 4 | $form.on('submit', function(e){ 5 | e.preventDefault(); 6 | var url = { 7 | 'plugin': 'websocket', 8 | 'controller': 'example', 9 | 'action': 'userFormPanel' 10 | }; 11 | App.Main.loadJsonAction(url, { 12 | data: $form.serialize(), 13 | onComplete: function() { 14 | this.$('input[type="text"]').val(''); 15 | }.bind(this) 16 | }); 17 | }.bind(this)); 18 | } 19 | }); 20 | -------------------------------------------------------------------------------- /webroot/js/lib/websocket.js: -------------------------------------------------------------------------------- 1 | Frontend.App.Websocket = Class.extend({ 2 | _socket: null, 3 | _eventCallbacks: {}, 4 | _isConnected: false, 5 | _host: null, 6 | _port: null, 7 | _reconnectCounter: 0, 8 | init: function(config) { 9 | this._host = config.host; 10 | this._port = config.port; 11 | this._path = config.path; 12 | this._usePort = config.usePort; 13 | }, 14 | setup: function() { 15 | if (this._isConnected) { 16 | return; 17 | } 18 | 19 | try { 20 | this._socket = new WebSocket(this._buildUrl()); 21 | this._socket.onopen = function (e) { 22 | this._isConnected = true; 23 | this.onOpened(e) 24 | return; 25 | }.bind(this); 26 | this._socket.onclose = function (e) { 27 | this._isConnected = false; 28 | this.onClosed(e) 29 | this._triggerReconnect() 30 | return; 31 | }.bind(this); 32 | } catch (e) { 33 | this._isConnected = false; 34 | this._triggerReconnect() 35 | } 36 | }, 37 | onOpened: function(e) { 38 | this._socket.onmessage = function(event) { 39 | var data = JSON.parse(event.data); 40 | if (this._eventCallbacks[data.eventName] !== undefined) { 41 | $.each(this._eventCallbacks[data.eventName], function(callbackId, callback) { 42 | if (typeof callback === "function") { 43 | callback(data.payload); 44 | } 45 | }.bind(this)); 46 | } 47 | }.bind(this); 48 | }, 49 | onClosed: function(e) { 50 | }, 51 | onEvent: function(action, callback) { 52 | if (!(action in this._eventCallbacks)) { 53 | this._eventCallbacks[action] = []; 54 | } 55 | this._eventCallbacks[action].push(callback); 56 | 57 | return this._eventCallbacks[action].length - 1; 58 | }, 59 | removeEventCallback: function(action, callbackId) { 60 | if (action in this._eventCallbacks && callbackId in this._eventCallbacks[action]) { 61 | delete this._eventCallbacks[action][callbackId]; 62 | return true; 63 | } 64 | 65 | return false; 66 | }, 67 | _triggerReconnect: function() { 68 | if (this._isConnected || this._reconnectCounter > 10) { 69 | return; 70 | } 71 | this._reconnectCounter++; 72 | setTimeout(function() { 73 | this.setup(); 74 | }.bind(this), 2000); 75 | }, 76 | _buildUrl: function() { 77 | var url = this._host; 78 | if (this._usePort) { 79 | url += ':' + this._port; 80 | } 81 | url += this._path; 82 | return url; 83 | } 84 | }); 85 | window.App.Websocket = new Frontend.App.Websocket(App.Main.appData.jsonData.websocketFrontendConfig); 86 | $(document).ready(function() { 87 | window.App.Websocket.setup(); 88 | }); 89 | -------------------------------------------------------------------------------- /websocket.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scherersoftware/cake-websocket/c4f84f70adb0524997d07785dfbfab14eaf20a3e/websocket.png --------------------------------------------------------------------------------