├── typings └── index.d.ts ├── jest.config.js ├── codecov.yml ├── .eslintrc.js ├── phpstan.neon.dist ├── config └── laravel-echo-api-gateway.php ├── phpunit.xml ├── tsconfig.json ├── LICENSE.md ├── rollup.config.js ├── src ├── ServiceProvider.php ├── ConnectionRepository.php ├── Commands │ └── VaporHandle.php ├── SubscriptionRepository.php ├── Driver.php └── Handler.php ├── composer.json ├── package.json ├── js-src ├── Connector.ts ├── Channel.ts └── Websocket.ts ├── js-tests └── Connector.test.ts └── README.md /typings/index.d.ts: -------------------------------------------------------------------------------- 1 | declare const axios: import('axios').AxiosStatic; 2 | declare const Pusher: any; 3 | declare const io: any; 4 | declare const Vue: any; 5 | declare const jQuery: any; 6 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | transform: { 3 | '^.+\\.tsx?$': 'ts-jest', 4 | }, 5 | moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node', 'd.ts'], 6 | transformIgnorePatterns: [ 7 | 'node_modules/(?!(laravel-echo)/)', 8 | ], 9 | }; 10 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | coverage: 2 | status: 3 | project: 4 | default: 5 | target: 25% 6 | pest: 7 | target: 25% 8 | flags: 9 | - pest 10 | jest: 11 | target: 25% 12 | flags: 13 | - jest 14 | 15 | flags: 16 | pest: 17 | paths: 18 | - src/ 19 | carryforward: false 20 | jest: 21 | paths: 22 | - js-src/ 23 | carryforward: false 24 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { 4 | browser: true, 5 | jest: true, 6 | node: true, 7 | }, 8 | parser: "@typescript-eslint/parser", 9 | plugins: ["@typescript-eslint"], 10 | extends: ["eslint:recommended", "plugin:@typescript-eslint/recommended"], 11 | rules: { 12 | "@typescript-eslint/ban-types": "off", 13 | "@typescript-eslint/no-explicit-any": "off", 14 | "prefer-const": "off", 15 | }, 16 | }; 17 | -------------------------------------------------------------------------------- /phpstan.neon.dist: -------------------------------------------------------------------------------- 1 | includes: 2 | - ./vendor/nunomaduro/larastan/extension.neon 3 | 4 | parameters: 5 | 6 | phpVersion: 80399 7 | 8 | paths: 9 | - src 10 | - tests 11 | 12 | level: max 13 | 14 | ignoreErrors: 15 | - '#^Cannot call method debug\(\) on Illuminate\\Log\\LogManager\|null.$#' 16 | - '#^Call to an undefined method Mockery\\ExpectationInterface\|Mockery\\HigherOrderMessage::withArgs\(\).$#' 17 | # 18 | # excludePaths: 19 | # - ./*/*/FileToBeExcluded.php 20 | 21 | checkMissingIterableValueType: false 22 | -------------------------------------------------------------------------------- /config/laravel-echo-api-gateway.php: -------------------------------------------------------------------------------- 1 | [ 6 | 'key' => env('AWS_ACCESS_KEY_ID'), 7 | 'secret' => env('AWS_SECRET_ACCESS_KEY'), 8 | 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'), 9 | ], 10 | 11 | 'api' => [ 12 | 'id' => env('LARAVEL_ECHO_API_GATEWAY_API_ID'), 13 | 'stage' => env('LARAVEL_ECHO_API_GATEWAY_API_STAGE'), 14 | ], 15 | 16 | 'dynamodb' => [ 17 | 'endpoint' => env('DYNAMODB_ENDPOINT'), 18 | 'table' => env('LARAVEL_ECHO_API_GATEWAY_DYNAMODB_TABLE', 'connections'), 19 | ], 20 | 21 | ]; 22 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | 9 | ./tests 10 | 11 | 12 | 13 | 14 | ./src 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "declaration": true, 4 | "module": "es6", 5 | "moduleResolution": "node", 6 | "outDir": "./dist", 7 | "sourceMap": false, 8 | "target": "es6", 9 | "isolatedModules": false, 10 | "esModuleInterop": true, 11 | "preserveSymlinks": true, 12 | "typeRoots": [ 13 | "node_modules/@types" 14 | ], 15 | "lib": [ 16 | "es2017", 17 | "dom" 18 | ] 19 | }, 20 | "include": [ 21 | "./typings/", 22 | "./js-src/", 23 | "./node_modules/laravel-echo/src/" 24 | ] 25 | } 26 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) George Boot 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 13 | all 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 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import babel from '@rollup/plugin-babel'; 2 | import typescript from 'rollup-plugin-typescript2'; 3 | 4 | export default { 5 | input: './js-src/Connector.ts', 6 | output: [ 7 | { file: './dist/laravel-echo-api-gateway.js', format: 'esm' }, 8 | { file: './dist/laravel-echo-api-gateway.common.js', format: 'cjs' }, 9 | { file: './dist/laravel-echo-api-gateway.iife.js', format: 'iife', name: 'LaravelEchoApiGateway' }, 10 | ], 11 | plugins: [ 12 | typescript({ 13 | tsconfig: './tsconfig.json', // Chemin explicite vers votre fichier TypeScript 14 | useTsconfigDeclarationDir: true, // Respecte les options `outDir` et `declarationDir` 15 | }), 16 | babel({ 17 | babelHelpers: 'bundled', 18 | exclude: 'node_modules/**', 19 | extensions: ['.ts'], 20 | presets: ['@babel/preset-env'], 21 | plugins: [ 22 | ['@babel/plugin-proposal-decorators', { legacy: true }], 23 | '@babel/plugin-proposal-function-sent', 24 | '@babel/plugin-proposal-export-namespace-from', 25 | '@babel/plugin-proposal-numeric-separator', 26 | '@babel/plugin-proposal-throw-expressions', 27 | '@babel/plugin-transform-object-assign', 28 | ], 29 | }), 30 | ], 31 | }; 32 | -------------------------------------------------------------------------------- /src/ServiceProvider.php: -------------------------------------------------------------------------------- 1 | mergeConfigFrom( 15 | __DIR__ . '/../config/laravel-echo-api-gateway.php', 16 | 'laravel-echo-api-gateway' 17 | ); 18 | 19 | Config::set('broadcasting.connections.laravel-echo-api-gateway', [ 20 | 'driver' => 'laravel-echo-api-gateway', 21 | ]); 22 | 23 | $config = config('laravel-echo-api-gateway'); 24 | 25 | $subscriptionRepository = new SubscriptionRepository($config); 26 | 27 | $this->app->bind(SubscriptionRepository::class, fn () => $subscriptionRepository); 28 | $this->app->bind(ConnectionRepository::class, fn () => new ConnectionRepository($subscriptionRepository, $config)); 29 | } 30 | 31 | public function boot(BroadcastManager $broadcastManager): void 32 | { 33 | $broadcastManager->extend('laravel-echo-api-gateway', fn () => $this->app->make(Driver::class)); 34 | 35 | $this->commands([ 36 | VaporHandle::class, 37 | ]); 38 | 39 | $this->publishes([ 40 | __DIR__ . '/../config/laravel-echo-api-gateway.php' => config_path('laravel-echo-api-gateway.php'), 41 | ], 'laravel-echo-api-gateway-config'); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/ConnectionRepository.php: -------------------------------------------------------------------------------- 1 | subscriptionRepository = $subscriptionRepository; 19 | 20 | $this->apiGatewayManagementApiClient = new ApiGatewayManagementApiClient(array_merge($config['connection'], [ 21 | 'version' => '2018-11-29', 22 | 'endpoint' => "https://{$config['api']['id']}.execute-api.{$config['connection']['region']}.amazonaws.com/{$config['api']['stage']}/", 23 | ])); 24 | } 25 | 26 | public function sendMessage(string $connectionId, string $data): void 27 | { 28 | try { 29 | $this->apiGatewayManagementApiClient->postToConnection([ 30 | 'ConnectionId' => $connectionId, 31 | 'Data' => $data, 32 | ]); 33 | } catch (ApiGatewayManagementApiException $e) { 34 | // GoneException: The connection with the provided id no longer exists 35 | if ( 36 | $e->getStatusCode() === Response::HTTP_GONE || 37 | $e->getAwsErrorCode() === 'GoneException' 38 | ) { 39 | $this->subscriptionRepository->clearConnection($connectionId); 40 | 41 | return; 42 | } 43 | 44 | throw $e; 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "georgeboot/laravel-echo-api-gateway", 3 | "description": "Use Laravel Echo with API Gateway Websockets", 4 | "keywords": [ 5 | "laravel", 6 | "echo", 7 | "websockets", 8 | "bref", 9 | "serverless", 10 | "laravel vapor" 11 | ], 12 | "homepage": "https://github.com/georgeboot/laravel-echo-api-gateway", 13 | "type": "library", 14 | "license": "MIT", 15 | "authors": [ 16 | { 17 | "name": "George Boot", 18 | "email": "george@famboot.nl" 19 | } 20 | ], 21 | "require": { 22 | "php": "^8.0|^8.1|^8.2", 23 | "ext-json": "*", 24 | "aws/aws-sdk-php": "^3.308", 25 | "bref/bref": "^1.1|^2.0", 26 | "guzzlehttp/guzzle": "^6.3|^7.0", 27 | "laravel/framework": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0" 28 | }, 29 | "require-dev": { 30 | "mockery/mockery": "^1.2", 31 | "nunomaduro/larastan": "^2.5", 32 | "orchestra/testbench": "^7.33.0", 33 | "pestphp/pest": "^1.0", 34 | "phpunit/phpunit": "^8.0|^9.0" 35 | }, 36 | "autoload": { 37 | "psr-4": { 38 | "Georgeboot\\LaravelEchoApiGateway\\": "src/" 39 | } 40 | }, 41 | "autoload-dev": { 42 | "psr-4": { 43 | "Tests\\": "tests/" 44 | } 45 | }, 46 | "scripts": { 47 | "test": "vendor/bin/phpunit" 48 | }, 49 | "extra": { 50 | "laravel": { 51 | "providers": [ 52 | "Georgeboot\\LaravelEchoApiGateway\\ServiceProvider" 53 | ] 54 | } 55 | }, 56 | "config": { 57 | "sort-packages": true, 58 | "platform": { 59 | "php": "8.0.12" 60 | }, 61 | "allow-plugins": { 62 | "pestphp/pest-plugin": true 63 | } 64 | }, 65 | "minimum-stability": "dev", 66 | "prefer-stable": true 67 | } 68 | -------------------------------------------------------------------------------- /src/Commands/VaporHandle.php: -------------------------------------------------------------------------------- 1 | websocketHandler = $websocketHandler; 35 | } 36 | 37 | /** 38 | * Execute the console command. 39 | * 40 | * @return int 41 | */ 42 | public function handle() 43 | { 44 | if ($this->laravel->isDownForMaintenance()) { 45 | return 0; 46 | } 47 | 48 | // fake a context 49 | $context = new Context($_ENV['AWS_REQUEST_ID'] ?? 'request-1', 0, $_ENV['AWS_LAMBDA_FUNCTION_NAME'] ?? 'arn-1', $_ENV['_X_AMZN_TRACE_ID'] ?? ''); 50 | 51 | if (Arr::get($this->message(), 'requestContext.connectionId')) { 52 | $this->handleWebsocketEvent($this->message(), $context); 53 | } 54 | 55 | return 0; 56 | } 57 | 58 | protected function handleWebsocketEvent(array $event, Context $context): void 59 | { 60 | $this->websocketHandler->handle($event, $context); 61 | } 62 | 63 | /** 64 | * Get the decoded message payload. 65 | * 66 | * @return array 67 | */ 68 | protected function message() 69 | { 70 | /** @var string $message */ 71 | $message = $this->argument('message'); 72 | 73 | return tap(json_decode(base64_decode($message), true), function ($message) { 74 | if ($message === false) { 75 | throw new InvalidArgumentException('Unable to unserialize message.'); 76 | } 77 | }); 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "laravel-echo-api-gateway", 3 | "version": "0.5.5", 4 | "description": "Use Laravel Echo with API Gateway Websockets", 5 | "keywords": [ 6 | "laravel", 7 | "echo", 8 | "websockets", 9 | "bref", 10 | "serverless", 11 | "laravel vapor" 12 | ], 13 | "homepage": "https://github.com/georgeboot/laravel-echo-api-gateway", 14 | "repository": { 15 | "type": "git", 16 | "url": "https://github.com/georgeboot/laravel-echo-api-gateway" 17 | }, 18 | "author": { 19 | "name": "George Boot" 20 | }, 21 | "license": "MIT", 22 | "main": "dist/laravel-echo-api-gateway.common.js", 23 | "module": "dist/laravel-echo-api-gateway.js", 24 | "types": "dist/laravel-echo-api-gateway.d.ts", 25 | "scripts": { 26 | "build": "npm run compile && npm run declarations", 27 | "compile": "./node_modules/.bin/rollup -c", 28 | "declarations": "./node_modules/.bin/tsc --emitDeclarationOnly", 29 | "lint": "eslint --ext .js,.ts ./js-src ./js-tests", 30 | "prepublish": "npm run build", 31 | "release": "npm run test && standard-version && git push --follow-tags && npm publish", 32 | "test": "jest" 33 | }, 34 | "devDependencies": { 35 | "@babel/plugin-proposal-decorators": "^7.8.3", 36 | "@babel/plugin-proposal-export-namespace-from": "^7.8.3", 37 | "@babel/plugin-proposal-function-sent": "^7.8.3", 38 | "@babel/plugin-proposal-numeric-separator": "^7.8.3", 39 | "@babel/plugin-proposal-throw-expressions": "^7.8.3", 40 | "@babel/plugin-transform-object-assign": "^7.8.3", 41 | "@babel/preset-env": "^7.9.6", 42 | "@rollup/plugin-babel": "^5.0.0", 43 | "@types/babel__traverse": "^7.20.6", 44 | "@types/jest": "^24.0.18", 45 | "@types/node": "^12.7.5", 46 | "@typescript-eslint/eslint-plugin": "^3.7.0", 47 | "@typescript-eslint/parser": "^3.7.0", 48 | "axios": "^0.21.1", 49 | "eslint": "^7.5.0", 50 | "jest": "^24.9.0", 51 | "jest-websocket-mock": "^2.2.0", 52 | "laravel-echo": "1.10.0", 53 | "mock-socket": "^9.0.3", 54 | "rollup": "^2.10.2", 55 | "rollup-plugin-commonjs": "^10.1.0", 56 | "rollup-plugin-json": "^4.0.0", 57 | "rollup-plugin-node-resolve": "^5.2.0", 58 | "rollup-plugin-typescript2": "^0.27.1", 59 | "standard-version": "^8.0.1", 60 | "ts-jest": "^24.1.0", 61 | "tslib": "^1.10.0", 62 | "typescript": "^5.7.2" 63 | }, 64 | "engines": { 65 | "node": ">=10" 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/SubscriptionRepository.php: -------------------------------------------------------------------------------- 1 | dynamoDb = new DynamoDbClient(array_merge($config['connection'], [ 18 | 'version' => '2012-08-10', 19 | 'endpoint' => $config['dynamodb']['endpoint'], 20 | ])); 21 | 22 | $this->table = $config['dynamodb']['table']; 23 | } 24 | 25 | public function getConnectionIdsForChannel(string ...$channels): Collection 26 | { 27 | $promises = collect($channels)->map(fn ($channel) => $this->dynamoDb->queryAsync([ 28 | 'TableName' => $this->table, 29 | 'IndexName' => 'lookup-by-channel', 30 | 'KeyConditionExpression' => 'channel = :channel', 31 | 'ExpressionAttributeValues' => [ 32 | ':channel' => ['S' => $channel], 33 | ], 34 | ]))->toArray(); 35 | 36 | $responses = Utils::all($promises)->wait(); 37 | 38 | return collect($responses) 39 | ->flatmap(fn (\Aws\Result $result): array => $result['Items']) 40 | ->map(fn (array $item): string => $item['connectionId']['S']) 41 | ->unique(); 42 | } 43 | 44 | public function clearConnection(string $connectionId): void 45 | { 46 | $response = $this->dynamoDb->query([ 47 | 'TableName' => $this->table, 48 | 'IndexName' => 'lookup-by-connection', 49 | 'KeyConditionExpression' => 'connectionId = :connectionId', 50 | 'ExpressionAttributeValues' => [ 51 | ':connectionId' => ['S' => $connectionId], 52 | ], 53 | ]); 54 | 55 | if (! empty($response['Items'])) { 56 | $this->dynamoDb->batchWriteItem([ 57 | 'RequestItems' => [ 58 | $this->table => collect($response['Items'])->map(fn ($item) => [ 59 | 'DeleteRequest' => [ 60 | 'Key' => Arr::only($item, ['connectionId', 'channel']), 61 | ], 62 | ])->toArray(), 63 | ], 64 | ]); 65 | } 66 | } 67 | 68 | public function subscribeToChannel(string $connectionId, string $channel): void 69 | { 70 | $this->dynamoDb->putItem([ 71 | 'TableName' => $this->table, 72 | 'Item' => [ 73 | 'connectionId' => ['S' => $connectionId], 74 | 'channel' => ['S' => $channel], 75 | ], 76 | ]); 77 | } 78 | 79 | public function unsubscribeFromChannel(string $connectionId, string $channel): void 80 | { 81 | $this->dynamoDb->deleteItem([ 82 | 'TableName' => $this->table, 83 | 'Key' => [ 84 | 'connectionId' => ['S' => $connectionId], 85 | 'channel' => ['S' => $channel], 86 | ], 87 | ]); 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /js-src/Connector.ts: -------------------------------------------------------------------------------- 1 | import {Connector as BaseConnector} from "laravel-echo/src/connector/connector"; 2 | import {Websocket} from "./Websocket"; 3 | import {Channel} from "./Channel"; 4 | 5 | export const broadcaster = (options: object): Connector => new Connector(options); 6 | 7 | const LOG_PREFIX = '[LE-AG-Connector]'; 8 | 9 | export class Connector extends BaseConnector { 10 | 11 | socket: Websocket; 12 | 13 | /** 14 | * All of the subscribed channel names. 15 | */ 16 | channels: { [name: string]: Channel } = {}; 17 | 18 | /** 19 | * Create a new class instance. 20 | */ 21 | constructor(options: any) { 22 | super(options); 23 | } 24 | 25 | /** 26 | * Create a fresh Socket.io connection. 27 | */ 28 | connect(): void { 29 | this.options.debug && console.log(LOG_PREFIX + 'Connect ...' ); 30 | 31 | this.socket = new Websocket(this.options); 32 | 33 | return; 34 | 35 | // 36 | // this.socket.on('reconnect', () => { 37 | // Object.values(this.channels).forEach((channel) => { 38 | // channel.subscribe(); 39 | // }); 40 | // }); 41 | // 42 | // return this.socket; 43 | } 44 | 45 | /** 46 | * Get a channel instance by name. 47 | */ 48 | channel(name: string): Channel { 49 | if (!this.channels[name]) { 50 | this.channels[name] = new Channel(this.socket, name, this.options); 51 | } 52 | 53 | return this.channels[name]; 54 | } 55 | 56 | /** 57 | * Get a private channel instance by name. 58 | */ 59 | privateChannel(name: string): Channel { 60 | if (!this.channels['private-' + name]) { 61 | this.channels['private-' + name] = new Channel(this.socket, 'private-' + name, this.options); 62 | } 63 | 64 | return this.channels['private-' + name] as Channel; 65 | } 66 | 67 | /** 68 | * Get a presence channel instance by name. 69 | */ 70 | presenceChannel(name: string): Channel { 71 | if (!this.channels['presence-' + name]) { 72 | this.channels['presence-' + name] = new Channel( 73 | this.socket, 74 | 'presence-' + name, 75 | this.options 76 | ); 77 | } 78 | 79 | return this.channels['presence-' + name] as Channel; 80 | } 81 | 82 | /** 83 | * Leave the given channel, as well as its private and presence variants. 84 | */ 85 | leave(name: string): void { 86 | let channels = [name, 'private-' + name, 'presence-' + name]; 87 | 88 | channels.forEach((name) => { 89 | this.leaveChannel(name); 90 | }); 91 | } 92 | 93 | /** 94 | * Leave the given channel. 95 | */ 96 | leaveChannel(name: string): void { 97 | if (this.channels[name]) { 98 | this.channels[name].unsubscribe(); 99 | 100 | delete this.channels[name]; 101 | } 102 | } 103 | 104 | /** 105 | * Get the socket ID for the connection. 106 | */ 107 | socketId(): string { 108 | return this.socket.getSocketId(); 109 | } 110 | 111 | /** 112 | * Disconnect socket connection. 113 | */ 114 | disconnect(): void { 115 | this.options.debug && console.log(LOG_PREFIX + 'Disconnect ...' ); 116 | 117 | this.socket.close(); 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /js-tests/Connector.test.ts: -------------------------------------------------------------------------------- 1 | import WS from "jest-websocket-mock"; 2 | import { Connector } from "../js-src/Connector"; 3 | import { Channel } from "../js-src/Channel"; 4 | 5 | const mockedHost = 'ws://localhost:1234'; 6 | 7 | describe('Connector', () => { 8 | let server: WS; 9 | 10 | beforeEach(() => { 11 | jest.useRealTimers(); 12 | server = new WS(mockedHost); 13 | }); 14 | 15 | afterEach(() => { 16 | server.close() 17 | }); 18 | 19 | test('socket id is correctly set', async () => { 20 | const connector = new Connector({ 21 | host: mockedHost, 22 | }) 23 | 24 | await server.connected; 25 | 26 | await expect(server).toReceiveMessage('{"event":"whoami"}'); 27 | server.send('{"event":"whoami","data":{"socket_id":"test-socket-id"}}') 28 | 29 | expect(connector.socketId()).toBe('test-socket-id') 30 | }) 31 | 32 | test('we reconnect to the server on error', async () => { 33 | const connector = new Connector({ 34 | host: mockedHost, 35 | }) 36 | 37 | await server.connected; 38 | await expect(server).toReceiveMessage('{"event":"whoami"}'); 39 | server.send('{"event":"whoami","data":{"socket_id":"test-socket-id"}}') 40 | 41 | server.close(); 42 | await server.closed; 43 | server.server.stop(() => (server = new WS(mockedHost))); 44 | 45 | await server.connected; 46 | await expect(server).toReceiveMessage('{"event":"whoami"}'); 47 | server.send('{"event":"whoami","data":{"socket_id":"test-socket-id2"}}') 48 | 49 | expect(connector.socketId()).toBe('test-socket-id2') 50 | }) 51 | 52 | test('we can subscribe to a channel and listen to events', async () => { 53 | const connector = new Connector({ 54 | host: mockedHost, 55 | }) 56 | 57 | await server.connected; 58 | 59 | await expect(server).toReceiveMessage('{"event":"whoami"}'); 60 | server.send('{"event":"whoami","data":{"socket_id":"test-socket-id"}}') 61 | 62 | const channel = connector.channel('my-test-channel') 63 | 64 | await expect(server).toReceiveMessage('{"event":"subscribe","data":{"channel":"my-test-channel"}}'); 65 | 66 | server.send('{"event":"subscription_succeeded","channel":"my-test-channel"}') 67 | 68 | expect(channel).toBeInstanceOf(Channel) 69 | 70 | const handler1 = jest.fn(); 71 | const handler2 = jest.fn(); 72 | 73 | channel.on('my-test-event', handler1) 74 | 75 | server.send('{"event":"my-test-event","channel":"my-test-channel","data":{}}') 76 | 77 | expect(handler1).toBeCalled(); 78 | expect(handler2).not.toBeCalled(); 79 | }) 80 | 81 | test('we can send a whisper event', async () => { 82 | const connector = new Connector({ 83 | host: mockedHost, 84 | }) 85 | 86 | await server.connected; 87 | 88 | await expect(server).toReceiveMessage('{"event":"whoami"}'); 89 | server.send('{"event":"whoami","data":{"socket_id":"test-socket-id"}}') 90 | 91 | const channel = connector.channel('my-test-channel') 92 | 93 | await expect(server).toReceiveMessage('{"event":"subscribe","data":{"channel":"my-test-channel"}}'); 94 | 95 | server.send('{"event":"subscription_succeeded","channel":"my-test-channel"}') 96 | 97 | expect(channel).toBeInstanceOf(Channel) 98 | 99 | const handler1 = jest.fn(); 100 | const handler2 = jest.fn(); 101 | 102 | channel.on('client-whisper', handler1) 103 | 104 | server.send('{"event":"client-whisper","data":"whisper","channel":"my-test-channel","data":{}}') 105 | 106 | expect(handler1).toBeCalled(); 107 | expect(handler2).not.toBeCalled(); 108 | }) 109 | }); 110 | -------------------------------------------------------------------------------- /js-src/Channel.ts: -------------------------------------------------------------------------------- 1 | import { EventFormatter } from 'laravel-echo/src/util'; 2 | import { Channel as BaseChannel } from 'laravel-echo/src/channel/channel'; 3 | import { PresenceChannel } from "laravel-echo/src/channel"; 4 | import { Websocket } from "./Websocket"; 5 | 6 | const LOG_PREFIX = '[LE-AG-Channel]'; 7 | 8 | /** 9 | * This class represents a Pusher channel. 10 | */ 11 | export class Channel extends BaseChannel implements PresenceChannel { 12 | /** 13 | * The Pusher client instance. 14 | */ 15 | socket: Websocket; 16 | 17 | /** 18 | * The name of the channel. 19 | */ 20 | name: string; 21 | 22 | /** 23 | * Channel options. 24 | */ 25 | options: object; 26 | 27 | /** 28 | * The event formatter. 29 | */ 30 | eventFormatter: EventFormatter; 31 | 32 | /** 33 | * Create a new class instance. 34 | */ 35 | constructor(socket: Websocket, name: string, options: object) { 36 | super(); 37 | 38 | this.name = name; 39 | this.socket = socket; 40 | this.options = options; 41 | this.eventFormatter = new EventFormatter(this.options["namespace"]); 42 | 43 | this.subscribe(); 44 | } 45 | 46 | /** 47 | * Subscribe to a Pusher channel. 48 | */ 49 | subscribe(): any { 50 | this.options["debug"] && console.log(`${LOG_PREFIX} subscribe for channel ${this.name} ...`); 51 | 52 | this.socket.subscribe(this) 53 | } 54 | 55 | /** 56 | * Unsubscribe from a Pusher channel. 57 | */ 58 | unsubscribe(): void { 59 | this.options["debug"] && console.log(`${LOG_PREFIX} unsubscribe for channel ${this.name} ...`); 60 | 61 | this.socket.unsubscribe(this); 62 | } 63 | 64 | /** 65 | * Listen for an event on the channel instance. 66 | */ 67 | listen(event: string, callback: Function): this { 68 | this.options["debug"] && console.log(`${LOG_PREFIX} listen to ${event} for channel ${this.name} ...`); 69 | 70 | this.on(this.eventFormatter.format(event), callback); 71 | 72 | return this; 73 | } 74 | 75 | /** 76 | * Stop listening for an event on the channel instance. 77 | */ 78 | stopListening(event: string, callback?: Function): this { 79 | this.options["debug"] && console.log(`${LOG_PREFIX} stop listening to ${event} for channel ${this.name} ...`); 80 | 81 | this.socket.unbindEvent(this, event, callback) 82 | 83 | return this; 84 | } 85 | 86 | /** 87 | * Register a callback to be called anytime a subscription succeeds. 88 | */ 89 | subscribed(callback: Function): this { 90 | this.options["debug"] && console.log(`${LOG_PREFIX} subscribed for channel ${this.name} ...`); 91 | 92 | this.on('subscription_succeeded', () => { 93 | callback(); 94 | }); 95 | 96 | return this; 97 | } 98 | 99 | /** 100 | * Register a callback to be called anytime a subscription error occurs. 101 | */ 102 | error(callback: Function): this { 103 | this.options["debug"] && console.log(`${LOG_PREFIX} error for channel ${this.name} ...`); 104 | 105 | this.on('error', (status) => { 106 | callback(status); 107 | }); 108 | 109 | return this; 110 | } 111 | 112 | /** 113 | * Bind a channel to an event. 114 | */ 115 | on(event: string, callback: Function): Channel { 116 | this.options["debug"] && console.log(`${LOG_PREFIX} on ${event} for channel ${this.name} ...`); 117 | 118 | this.socket.bind(this, event, callback) 119 | 120 | return this; 121 | } 122 | 123 | whisper(event: string, data: object): this { 124 | let channel = this.name; 125 | let formattedEvent = "client-" + event; 126 | this.socket.send({ 127 | "event": formattedEvent, 128 | data, 129 | channel, 130 | }) 131 | 132 | return this; 133 | } 134 | 135 | here(callback: Function): this { 136 | // TODO: implement 137 | 138 | return this 139 | } 140 | 141 | /** 142 | * Listen for someone joining the channel. 143 | */ 144 | joining(callback: Function): this { 145 | // TODO: implement 146 | 147 | return this 148 | } 149 | 150 | /** 151 | * Listen for someone leaving the channel. 152 | */ 153 | leaving(callback: Function): this { 154 | // TODO: implement 155 | 156 | return this 157 | } 158 | } 159 | 160 | export { PresenceChannel }; 161 | -------------------------------------------------------------------------------- /src/Driver.php: -------------------------------------------------------------------------------- 1 | subscriptionRepository = $subscriptionRepository; 23 | $this->connectionRepository = $connectionRepository; 24 | } 25 | 26 | /** 27 | * Authenticate the incoming request for a given channel. 28 | * 29 | * @param \Illuminate\Http\Request $request 30 | * 31 | * @return mixed 32 | */ 33 | public function auth($request) 34 | { 35 | $channelName = $this->normalizeChannelName($request->channel_name); 36 | 37 | if (empty($request->channel_name) || ($this->isGuardedChannel($request->channel_name) && ! $this->retrieveUser($request, $channelName))) { 38 | throw new AccessDeniedHttpException(); 39 | } 40 | 41 | return parent::verifyUserCanAccessChannel( 42 | $request, $channelName 43 | ); 44 | } 45 | 46 | /** 47 | * Return the valid authentication response. 48 | * 49 | * @param \Illuminate\Http\Request $request 50 | * @param mixed $result 51 | * 52 | * @return mixed 53 | */ 54 | public function validAuthenticationResponse($request, $result) 55 | { 56 | if (! Str::startsWith($request->channel_name, 'presence')) { 57 | return response()->json( 58 | $this->generateSignature($request->channel_name, $request->socket_id) 59 | ); 60 | } 61 | 62 | $channelName = $this->normalizeChannelName($request->channel_name); 63 | 64 | return response()->json( 65 | $this->generateSignaturePresence( 66 | $request->channel_name, 67 | $request->socket_id, 68 | $this->retrieveUser($request, $channelName)->getAuthIdentifier(), 69 | $result 70 | ), 71 | ); 72 | } 73 | 74 | protected function generateSignature(string $channel, string $socketId, string $customData = null): array 75 | { 76 | $data = $customData ? "{$socketId}:{$channel}:{$customData}" : "{$socketId}:{$channel}"; 77 | 78 | $signature = hash_hmac('sha256', $data, config('app.key'), false); 79 | 80 | $response = [ 81 | 'auth' => $signature, 82 | ]; 83 | 84 | if ($customData) { 85 | $response['channel_data'] = $customData; 86 | } 87 | 88 | return $response; 89 | } 90 | 91 | protected function generateSignaturePresence(string $channel, string $socketId, int $userId, array $userInfo = null): array 92 | { 93 | $userData = [ 94 | 'user_id' => $userId, 95 | ]; 96 | 97 | if ($userInfo) { 98 | $userData['user_info'] = $userInfo; 99 | } 100 | 101 | return $this->generateSignature($channel, $socketId, json_encode($userData, JSON_THROW_ON_ERROR)); 102 | } 103 | 104 | /** 105 | * Broadcast the given event. 106 | * 107 | * @param Channel[] $channels 108 | * @param string $event 109 | * @param array $payload 110 | * @return void 111 | * 112 | * @throws \Illuminate\Broadcasting\BroadcastException 113 | */ 114 | public function broadcast(array $channels, $event, array $payload = []) 115 | { 116 | $skipConnectionId = Arr::pull($payload, 'socket'); 117 | 118 | foreach ($channels as $channel) { 119 | $data = json_encode([ 120 | 'event' => $event, 121 | 'channel' => $channel->name, 122 | 'data' => $payload, 123 | ], JSON_THROW_ON_ERROR); 124 | 125 | $this->subscriptionRepository->getConnectionIdsForChannel($channel) 126 | ->reject(fn($connectionId) => $connectionId === $skipConnectionId) 127 | ->tap(fn($connectionIds) => logger()->debug("Preparing to send to connections for channel '{$channel->name}'", $connectionIds->toArray())) 128 | ->each(fn(string $connectionId) => $this->sendMessage($connectionId, $data)); 129 | } 130 | 131 | return; 132 | 133 | // if ((is_array($response) && $response['status'] >= 200 && $response['status'] <= 299) 134 | // || $response === true) { 135 | // return; 136 | // } 137 | // 138 | // throw new BroadcastException( 139 | // ! empty($response['body']) 140 | // ? sprintf('Pusher error: %s.', $response['body']) 141 | // : 'Failed to connect to Pusher.' 142 | // ); 143 | } 144 | 145 | protected function sendMessage(string $connectionId, string $data): void 146 | { 147 | logger()->debug("Sending message to connection '{$connectionId}'"); 148 | 149 | try { 150 | $this->connectionRepository->sendMessage($connectionId, $data); 151 | } catch (ApiGatewayManagementApiException $exception) { 152 | if ($exception->getAwsErrorCode() === 'GoneException') { 153 | $this->subscriptionRepository->clearConnection($connectionId); 154 | return; 155 | } 156 | 157 | throw $exception; 158 | 159 | // $exception->getErrorCode() is one of: 160 | // GoneException 161 | // LimitExceededException 162 | // PayloadTooLargeException 163 | // ForbiddenException 164 | 165 | // otherwise: call $exception->getPrevious() which is a guzzle exception 166 | } 167 | } 168 | } 169 | -------------------------------------------------------------------------------- /src/Handler.php: -------------------------------------------------------------------------------- 1 | subscriptionRepository = $subscriptionRepository; 24 | $this->connectionRepository = $connectionRepository; 25 | } 26 | 27 | public function handleWebsocket(WebsocketEvent $event, Context $context): HttpResponse 28 | { 29 | try { 30 | $method = Str::camel('handle_' . Str::lower($event->getEventType() ?? '')); 31 | 32 | if (! method_exists($this, $method)) { 33 | throw new \InvalidArgumentException("Event type {$event->getEventType()} has no handler implemented."); 34 | } 35 | 36 | $this->$method($event, $context); 37 | 38 | return new HttpResponse('OK'); 39 | } catch (Throwable $throwable) { 40 | report($throwable); 41 | 42 | throw $throwable; 43 | } 44 | } 45 | 46 | protected function handleDisconnect(WebsocketEvent $event, Context $context): void 47 | { 48 | $this->subscriptionRepository->clearConnection($event->getConnectionId()); 49 | } 50 | 51 | protected function handleMessage(WebsocketEvent $event, Context $context): void 52 | { 53 | $eventBody = json_decode($event->getBody(), true); 54 | 55 | if (! isset($eventBody['event'])) { 56 | throw new \InvalidArgumentException('event missing or no valid json'); 57 | } 58 | 59 | $eventType = $eventBody['event']; 60 | 61 | if ($eventType === 'ping') { 62 | $this->sendMessage($event, $context, [ 63 | 'event' => 'pong', 64 | 'channel' => $eventBody['channel'] ?? null, 65 | ]); 66 | } elseif ($eventType === 'whoami') { 67 | $this->sendMessage($event, $context, [ 68 | 'event' => 'whoami', 69 | 'data' => [ 70 | 'socket_id' => $event->getConnectionId(), 71 | ], 72 | ]); 73 | } elseif ($eventType === 'subscribe') { 74 | $this->subscribe($event, $context); 75 | } elseif ($eventType === 'unsubscribe') { 76 | $this->unsubscribe($event, $context); 77 | } elseif (Str::startsWith($eventType, 'client-')) { 78 | $this->broadcastToChannel($event, $context); 79 | } else { 80 | $this->sendMessage($event, $context, [ 81 | 'event' => 'error' 82 | ]); 83 | } 84 | } 85 | 86 | protected function subscribe(WebsocketEvent $event, Context $context): void 87 | { 88 | $eventBody = json_decode($event->getBody(), true); 89 | 90 | // fill missing values 91 | $eventBody['data'] += ['auth' => null, 'channel_data' => []]; 92 | 93 | [ 94 | 'channel' => $channel, 95 | 'auth' => $auth, 96 | 'channel_data' => $channelData, 97 | ] = $eventBody['data']; 98 | 99 | if (Str::startsWith($channel, ['private-', 'presence-'])) { 100 | $data = "{$event->getConnectionId()}:{$channel}"; 101 | 102 | if ($channelData) { 103 | $data .= ':' . $channelData; 104 | } 105 | 106 | $signature = hash_hmac('sha256', $data, config('app.key'), false); 107 | 108 | if ($signature !== $auth) { 109 | $this->sendMessage($event, $context, [ 110 | 'event' => 'error', 111 | 'channel' => $channel, 112 | 'data' => [ 113 | 'message' => 'Invalid auth signature', 114 | ], 115 | ]); 116 | 117 | return; 118 | } 119 | } 120 | 121 | $this->subscriptionRepository->subscribeToChannel($event->getConnectionId(), $channel); 122 | 123 | $this->sendMessage($event, $context, [ 124 | 'event' => 'subscription_succeeded', 125 | 'channel' => $channel, 126 | 'data' => [], 127 | ]); 128 | } 129 | 130 | protected function unsubscribe(WebsocketEvent $event, Context $context): void 131 | { 132 | $eventBody = json_decode($event->getBody(), true); 133 | $channel = $eventBody['data']['channel']; 134 | 135 | $this->subscriptionRepository->unsubscribeFromChannel($event->getConnectionId(), $channel); 136 | 137 | $this->sendMessage($event, $context, [ 138 | 'event' => 'unsubscription_succeeded', 139 | 'channel' => $channel, 140 | 'data' => [], 141 | ]); 142 | } 143 | 144 | public function broadcastToChannel(WebsocketEvent $event, Context $context): void 145 | { 146 | $skipConnectionId = $event->getConnectionId(); 147 | $eventBody = json_decode($event->getBody(), true); 148 | $channel = Arr::get($eventBody, 'channel'); 149 | $event = Arr::get($eventBody, 'event'); 150 | $payload = Arr::get($eventBody, 'data'); 151 | if (is_object($payload) || is_array($payload)) { 152 | $payload = json_encode($payload); 153 | } 154 | $data = json_encode([ 155 | 'event'=>$event, 156 | 'channel'=>$channel, 157 | 'data'=>$payload, 158 | ]) ?: ''; 159 | $this->subscriptionRepository->getConnectionIdsForChannel($channel) 160 | ->reject(fn ($connectionId) => $connectionId === $skipConnectionId) 161 | ->each(fn (string $connectionId) => $this->sendMessageToConnection($connectionId, $data)); 162 | } 163 | 164 | public function sendMessage(WebsocketEvent $event, Context $context, array $data): void 165 | { 166 | $this->connectionRepository->sendMessage($event->getConnectionId(), json_encode($data, JSON_THROW_ON_ERROR)); 167 | } 168 | 169 | protected function sendMessageToConnection(string $connectionId, string $data): void 170 | { 171 | try { 172 | $this->connectionRepository->sendMessage($connectionId, $data); 173 | } catch (ApiGatewayManagementApiException $exception) { 174 | if ($exception->getAwsErrorCode() === 'GoneException') { 175 | $this->subscriptionRepository->clearConnection($connectionId); 176 | return; 177 | } 178 | 179 | throw $exception; 180 | } 181 | } 182 | } 183 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # laravel-echo-api-gateway 2 | 3 | [![CI](https://github.com/georgeboot/laravel-echo-api-gateway/workflows/CI/badge.svg?event=push)](https://github.com/georgeboot/laravel-echo-api-gateway/actions?query=workflow%3ACI) 4 | [![codecov](https://codecov.io/gh/georgeboot/laravel-echo-api-gateway/branch/master/graph/badge.svg?token=UVIA3FBQPP)](https://codecov.io/gh/georgeboot/laravel-echo-api-gateway) 5 | 6 | This package enables you to use API Gateway‘s Websockets as a driver for [Laravel Echo](https://github.com/laravel/echo) 7 | , so you don’t have to use services like Pusher or Socket.io. 8 | 9 | It works by setting up a websocket API in API Gateway, and configure it to invoke a Lambda function, every time a 10 | message is sent to the websocket. This package includes and autoconfigures a handler to respond to these websocket 11 | messages. We also configure Laravel to use this connection as a broadcast driver. 12 | 13 | This package currently only works with either [Bref](https://bref.sh) or [Laravel Vapor](https://vapor.laravel.com), 14 | though the latter one involves some manual set-up. 15 | 16 | ## Requirements 17 | 18 | In order to use this package, your project needs to meet the following criteria: 19 | 20 | - PHP 7.4 or 8.x 21 | - Laravel 6 to 11 22 | - Uses either [bref](https://bref.sh) or [Laravel Vapor](https://vapor.laravel.com) to deploy to AWS 23 | - Has a working queue 24 | - Uses Laravel Mix or any other tool to bundle your assets 25 | 26 | ## Installation 27 | 28 | Installation of this package is fairly simply. 29 | 30 | First we have to install both the composer and npm package: 31 | 32 | ```shell 33 | composer require georgeboot/laravel-echo-api-gateway 34 | 35 | yarn add laravel-echo-api-gateway 36 | # or 37 | npm install --save-dev laravel-echo-api-gateway 38 | ``` 39 | 40 | ### Platform-specific instructions 41 | 42 | #### A. When using Bref 43 | 44 | Next, when using Bref, we have to add some elements to our `serverless.yml` file. If using Vapor, these resources have 45 | to be created by hand using the AWS CLI or console. 46 | 47 | Add a new function that will handle websocket events (messages etc): 48 | 49 | ```yaml 50 | functions: 51 | # Add this function 52 | websocket: 53 | handler: handlers/websocket.php 54 | layers: 55 | - ${bref:layer.php-80} 56 | events: 57 | - websocket: $disconnect 58 | - websocket: $default 59 | ``` 60 | 61 | Add a resource to create and configure our DynamoDB table, where connections will be stored in: 62 | 63 | ```yaml 64 | resources: 65 | Resources: 66 | # Add this resource 67 | ConnectionsTable: 68 | Type: AWS::DynamoDB::Table 69 | Properties: 70 | TableName: connections 71 | AttributeDefinitions: 72 | - AttributeName: connectionId 73 | AttributeType: S 74 | - AttributeName: channel 75 | AttributeType: S 76 | KeySchema: 77 | - AttributeName: connectionId 78 | KeyType: HASH 79 | - AttributeName: channel 80 | KeyType: RANGE 81 | GlobalSecondaryIndexes: 82 | - IndexName: lookup-by-channel 83 | KeySchema: 84 | - AttributeName: channel 85 | KeyType: HASH 86 | Projection: 87 | ProjectionType: ALL 88 | - IndexName: lookup-by-connection 89 | KeySchema: 90 | - AttributeName: connectionId 91 | KeyType: HASH 92 | Projection: 93 | ProjectionType: ALL 94 | BillingMode: PAY_PER_REQUEST 95 | ``` 96 | 97 | Add the following `iamRoleStatement` to enable our Lambda function to access the table: 98 | 99 | ```yaml 100 | provider: 101 | name: aws 102 | 103 | iamRoleStatements: 104 | # Add this iamRoleStatement 105 | - Effect: Allow 106 | Action: [ dynamodb:Query, dynamodb:GetItem, dynamodb:PutItem, dynamodb:UpdateItem, dynamodb:DeleteItem, dynamodb:BatchWriteItem ] 107 | Resource: 108 | - !GetAtt ConnectionsTable.Arn 109 | - !Join [ '', [ !GetAtt ConnectionsTable.Arn, '/index/*' ] ] 110 | ``` 111 | 112 | Add an environment variable to autogenerate our websocket URL: 113 | 114 | ```yaml 115 | provider: 116 | name: aws 117 | 118 | environment: 119 | # Add these variables 120 | # Please note : in Laravel 11, this setting is now BROADCAST_CONNECTION 121 | BROADCAST_DRIVER: laravel-echo-api-gateway 122 | LARAVEL_ECHO_API_GATEWAY_DYNAMODB_TABLE: !Ref ConnectionsTable 123 | LARAVEL_ECHO_API_GATEWAY_API_ID: !Ref WebsocketsApi 124 | LARAVEL_ECHO_API_GATEWAY_API_STAGE: "${self:provider.stage}" 125 | ``` 126 | 127 | Next, create the PHP handler file in `handlers/websocket.php` 128 | 129 | ```php 130 | make(Illuminate\Contracts\Console\Kernel::class); 141 | $kernel->bootstrap(); 142 | 143 | return $app->make(Handler::class); 144 | ``` 145 | 146 | Now, deploy your app by running `serverless deploy` or similar. Write down the websocket url the output gives you. 147 | 148 | #### B. When using Vapor 149 | 150 | When using Vapor, you will have to create these required resources by hand using the AWS CLI or Console: 151 | 152 | ##### B1. DynamoDB table for connections 153 | 154 | Create a DynamoDB table for the connections. Use `connectionId` (string) as a HASH key, and `channel` (string) as a SORT 155 | key. Set the capacity setting to whatever you like (probably on-demand). 156 | 157 | Create 2 indexes: 158 | 159 | 1. Name: `lookup-by-connection`, key: `connectionId`, no sort key, projected: ALL 160 | 2. Name: `lookup-by-channel`, key: `channel`, no sort key, projected: ALL 161 | 162 | ##### B2. API Gateway 163 | 164 | Create a new Websocket API. Enter a name and leave the route selection expression to what it is. Add a `$disconnect` 165 | and `$default`. Set both integrations to `Lambda` and select your CLI lambda from the list. Set the name of the stage to 166 | what you desire and create the API. Once created, write down the ID, as we'll need it later. 167 | 168 | ##### B3. IAM Permissions 169 | 170 | In IAM, go to roles and open `laravel-vapor-role`. Open the inline policy and edit it. On the JSON tab, 171 | add `"execute-api:*"` to the list of actions. 172 | 173 | Then, login to [Laravel Vapor](https://vapor.laravel.com/app), go to team settings, AWS Accounts, click on Role next to 174 | the correct account and deselect Receive Updates. 175 | 176 | Edit your `.env`: 177 | 178 | ```dotenv 179 | BROADCAST_DRIVER=laravel-echo-api-gateway 180 | LARAVEL_ECHO_API_GATEWAY_DYNAMODB_TABLE=the-table-name-you-entered-when-creating-it 181 | LARAVEL_ECHO_API_GATEWAY_API_ID=your-websocket-api-id 182 | LARAVEL_ECHO_API_GATEWAY_API_STAGE=your-api-stage-name 183 | ``` 184 | 185 | ### Generate front-end code 186 | 187 | Add to your javascript file: 188 | 189 | ```js 190 | import Echo from 'laravel-echo'; 191 | import {broadcaster} from 'laravel-echo-api-gateway'; 192 | 193 | window.Echo = new Echo({ 194 | broadcaster, 195 | // replace the placeholders 196 | host: 'wss://{api-ip}.execute-api.{region}.amazonaws.com/{stage}', 197 | authEndpoint: '{auth-url}/broadcasting/auth', // Optional: Use if you have a separate authentication endpoint 198 | bearerToken: '{token}', // Optional: Use if you need a Bearer Token for authentication 199 | }); 200 | ``` 201 | 202 | You can also enable console output by passing a `debug: true` otpion to your window.Echo intializer : 203 | ```js 204 | import Echo from 'laravel-echo'; 205 | import {broadcaster} from 'laravel-echo-api-gateway'; 206 | 207 | window.Echo = new Echo({ 208 | broadcaster, 209 | // replace the placeholders 210 | host: 'wss://{api-ip}.execute-api.{region}.amazonaws.com/{stage}', 211 | debug: true 212 | }); 213 | ``` 214 | 215 | 216 | 217 | Lastly, you have to generate your assets by running Laravel Mix. After this step, you should be up and running. 218 | -------------------------------------------------------------------------------- /js-src/Websocket.ts: -------------------------------------------------------------------------------- 1 | import { AxiosResponse } from "axios"; 2 | import { Channel } from "./Channel"; 3 | import axios from 'axios'; 4 | 5 | export type Options = { authEndpoint: string, host: string, bearerToken: string, auth: any, debug: boolean }; 6 | 7 | export type MessageBody = { event: string, channel?: string, data: object }; 8 | 9 | const LOG_PREFIX = '[LE-AG-Websocket]'; 10 | 11 | export class Websocket { 12 | buffer: Array = []; 13 | 14 | options: Options; 15 | 16 | websocket: WebSocket; 17 | 18 | private listeners: { [channelName: string]: { [eventName: string]: Function } } = {}; 19 | 20 | private internalListeners: { [eventName: string]: Function } = {}; 21 | 22 | private channelBacklog = []; 23 | 24 | private socketId: string; 25 | 26 | private closing = false; 27 | private hasConnected = false; 28 | 29 | private pingInterval: NodeJS.Timeout; 30 | 31 | constructor(options: Options) { 32 | this.options = options; 33 | 34 | this.connect(this.options.host); 35 | 36 | return this; 37 | } 38 | 39 | private connect(host: string): void { 40 | 41 | if (!host) { 42 | this.options.debug && console.error(LOG_PREFIX + `Cannont connect without host !`); 43 | 44 | return; 45 | } 46 | 47 | this.options.debug && console.log(LOG_PREFIX + `Trying to connect to ${host}...` ); 48 | 49 | this.websocket = new WebSocket(host); 50 | 51 | this.websocket.onerror = () => { 52 | 53 | if (!this.hasConnected) { 54 | 55 | setTimeout(() => { 56 | this.socketId = undefined; 57 | this.connect(host); 58 | }, 3000); 59 | } 60 | }; 61 | 62 | this.websocket.onopen = () => { 63 | this.options.debug && console.log(LOG_PREFIX + ' Connected !'); 64 | this.hasConnected = true; 65 | 66 | this.send({ 67 | event: 'whoami', 68 | }); 69 | 70 | while (this.buffer.length) { 71 | const message = this.buffer[0]; 72 | 73 | this.send(message); 74 | 75 | this.buffer.splice(0, 1); 76 | } 77 | 78 | // Register events only once connected, or they won't be registered if connection failed/lost 79 | 80 | this.websocket.onmessage = (messageEvent: MessageEvent) => { 81 | const message = this.parseMessage(messageEvent.data); 82 | this.options.debug && console.log(LOG_PREFIX + ' onmessage', messageEvent.data); 83 | 84 | if (!message) { 85 | return; 86 | } 87 | 88 | if (message.channel) { 89 | this.options.debug && console.log(`${LOG_PREFIX} Received event ${message.event} on channel ${message.channel}`); 90 | 91 | if (this.listeners[message.channel] && this.listeners[message.channel][message.event]) { 92 | this.listeners[message.channel][message.event](message.data); 93 | } 94 | 95 | return; 96 | } 97 | 98 | if (this.internalListeners[message.event]) { 99 | this.internalListeners[message.event](message.data); 100 | } 101 | } 102 | 103 | 104 | // send ping every 60 seconds to keep connection alive 105 | this.pingInterval = setInterval(() => { 106 | if (this.websocket.readyState === this.websocket.OPEN) { 107 | this.options.debug && console.log(LOG_PREFIX + ' Sending ping'); 108 | 109 | this.send({ 110 | event: 'ping', 111 | }); 112 | } 113 | }, 60 * 1000); 114 | } 115 | 116 | 117 | this.websocket.onclose = () => { 118 | this.options.debug && console.info('Connection closed.'); 119 | 120 | if (this.closing){ 121 | return; 122 | } 123 | 124 | this.hasConnected = false; 125 | this.options.debug && console.info('Connection lost, reconnecting...'); 126 | 127 | setTimeout(() => { 128 | this.socketId = undefined; 129 | this.connect(host); 130 | }, 1000); 131 | }; 132 | 133 | this.on('whoami', ({ socket_id: socketId }) => { 134 | this.socketId = socketId; 135 | 136 | this.options.debug && console.log(`${LOG_PREFIX} Just set socketId to ${socketId}`); 137 | 138 | // Handle the backlog and don't empty it, we'll need it if we lose connection 139 | let channel: Channel; 140 | 141 | for(channel of this.channelBacklog){ 142 | this.actuallySubscribe(channel); 143 | } 144 | }); 145 | } 146 | 147 | protected parseMessage(body: string): MessageBody { 148 | try { 149 | return JSON.parse(body); 150 | } catch (error) { 151 | this.options.debug && console.error(error); 152 | 153 | return undefined; 154 | } 155 | } 156 | 157 | getSocketId(): string { 158 | return this.socketId; 159 | } 160 | 161 | private socketIsReady(): boolean { 162 | return this.websocket.readyState === this.websocket.OPEN; 163 | } 164 | 165 | send(message: object): void { 166 | if (this.socketIsReady()) { 167 | this.websocket.send(JSON.stringify(message)); 168 | return; 169 | } 170 | 171 | this.buffer.push(message); 172 | } 173 | 174 | close(): void { 175 | this.closing = true; 176 | this.internalListeners = {}; 177 | 178 | clearInterval(this.pingInterval); 179 | this.pingInterval = undefined; 180 | 181 | this.websocket.close(); 182 | } 183 | 184 | subscribe(channel: Channel): void { 185 | if (this.getSocketId()) { 186 | this.actuallySubscribe(channel); 187 | } else { 188 | this.options.debug && console.log(`${LOG_PREFIX} subscribe - push channel backlog for channel ${channel.name}`); 189 | 190 | this.channelBacklog.push(channel); 191 | } 192 | } 193 | 194 | private actuallySubscribe(channel: Channel): void { 195 | if (channel.name.startsWith('private-') || channel.name.startsWith('presence-')) { 196 | this.options.debug && console.log(`${LOG_PREFIX} Sending auth request for channel ${channel.name}`); 197 | 198 | if (this.options.bearerToken) { 199 | this.options.auth.headers['Authorization'] = 'Bearer ' + this.options.bearerToken; 200 | } 201 | 202 | axios.post(this.options.authEndpoint, { 203 | socket_id: this.getSocketId(), 204 | channel_name: channel.name, 205 | }, { 206 | headers: this.options.auth.headers || {} 207 | }).then((response: AxiosResponse) => { 208 | this.options.debug && console.log(`${LOG_PREFIX} Subscribing to private channel ${channel.name}`); 209 | 210 | this.send({ 211 | event: 'subscribe', 212 | data: { 213 | channel: channel.name, 214 | ...response.data 215 | }, 216 | }); 217 | }).catch((error) => { 218 | this.options.debug && console.log(`${LOG_PREFIX} Auth request for channel ${channel.name} failed`); 219 | this.options.debug && console.error(error); 220 | }) 221 | } else { 222 | this.options.debug && console.log(`${LOG_PREFIX} Subscribing to channel ${channel.name}`); 223 | 224 | this.send({ 225 | event: 'subscribe', 226 | data: { 227 | channel: channel.name, 228 | }, 229 | }); 230 | } 231 | } 232 | 233 | unsubscribe(channel: Channel): void { 234 | this.options.debug && console.log(`${LOG_PREFIX} unsubscribe for channel ${channel.name}`); 235 | 236 | this.send({ 237 | event: 'unsubscribe', 238 | data: { 239 | channel: channel.name, 240 | }, 241 | }); 242 | 243 | if (this.listeners[channel.name]) { 244 | delete this.listeners[channel.name]; 245 | } 246 | } 247 | 248 | on(event: string, callback: Function = null): void { 249 | this.options.debug && console.log(`${LOG_PREFIX} on event ${event} ...`); 250 | 251 | this.internalListeners[event] = callback; 252 | } 253 | 254 | bind(channel: Channel, event: string, callback: Function): void { 255 | this.options.debug && console.log(`${LOG_PREFIX} bind event ${event} for channel ${channel.name} ...`); 256 | 257 | if (!this.listeners[channel.name]) { 258 | this.listeners[channel.name] = {}; 259 | } 260 | 261 | this.listeners[channel.name][event] = callback; 262 | } 263 | 264 | unbindEvent(channel: Channel, event: string, callback: Function = null): void { 265 | this.options.debug && console.log(`${LOG_PREFIX} unbind event ${event} for channel ${channel.name} ...`); 266 | 267 | if (this.internalListeners[event] && (callback === null || this.internalListeners[event] === callback)) { 268 | delete this.internalListeners[event]; 269 | } 270 | } 271 | } 272 | --------------------------------------------------------------------------------