├── .editorconfig ├── .gitignore ├── .npmrc ├── LICENSE.md ├── README.md ├── adonis-typings └── index.ts ├── instructions.md ├── instructions.ts ├── package.json ├── providers └── MercureProvider.ts ├── src ├── Token.ts └── Update.ts ├── templates └── config.txt └── tsconfig.json /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_size = 2 6 | indent_style = tab 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | 12 | [{package.json, .eslintrc, tsconfig.json, *.yml, *.md, *.txt}] 13 | indent_style = space 14 | insert_final_newline = false 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | node_modules 3 | build 4 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | package-lock=false 2 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # The MIT License 2 | 3 | Copyright 2022 Setten, contributors 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the 'Software'), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | @setten/mercure 3 |

4 | 5 |

6 | Download 7 | Version 8 | License 9 |

10 | 11 | `@setten/mercure` is a [Mercure](https://mercure.rocks) client for [AdonisJS](https://adonisjs.com/). 12 | 13 | Mercure allows you to use [Server Sent Events](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events) to push data to your clients using Http. 14 | 15 | > **Note** 16 | > 17 | > You must have a [Mercure Hub instance running](https://mercure.rocks/docs/hub/install). 18 | --- 19 | 20 | ## Getting Started 21 | 22 | This package is available in the npm registry. 23 | 24 | ```bash 25 | npm install @setten/mercure 26 | ``` 27 | 28 | Next, configure the package by running the following command. 29 | 30 | ```bash 31 | node ace configure @setten/mercure 32 | ``` 33 | 34 | and... Voilà! 35 | 36 | ### Configuration 37 | 38 | You have to configure the package before you can use it. 39 | The configuration is stored in the `config/mercure.ts` file. 40 | 41 | * `endpoint`: The endpoint of the Mercure Hub. 42 | * `adminToken`: The JWT created to authenticate as an admin of the Mercure Hub. 43 | * `jwt.alg`: The algorithm used to sign the JWT - should correlate to Mercure Hub configuration. 44 | * `jwt.secret`: The secret used to sign the JWT - should correlate to Mercure Hub configuration. 45 | 46 | Note that the `adminToken` must be generated using the same `alg` and `secret` used in the Mercure Hub with the following payload. 47 | 48 | ```json 49 | { 50 | "mercure": { 51 | "publish": [ 52 | "*" 53 | ] 54 | } 55 | } 56 | ``` 57 | 58 | For example, given the secret of `ChangeMe` and the algorithm of `HS256`, the JWT would be: 59 | 60 | ``` 61 | eyJhbGciOiJIUzI1NiJ9.eyJtZXJjdXJlIjp7InB1Ymxpc2giOlsiKiJdfX0.mx2ROlYlE1rp7udoDy-WCdnpLdPuKWzDxoBJXGMK4OE 62 | ``` 63 | 64 | ## Usage 65 | 66 | The Mercure Provider gives you access to two classes. 67 | 68 | * `Update`: This class is used to publish updates to the hub. 69 | * `Token`: This class is used to generate a token for authentication. 70 | 71 | You can easily send an update to the hub using the `Update.send` method. 72 | 73 | ```ts 74 | import { Update } from '@ioc:Setten/Mercure'; 75 | 76 | Update.send('/users/1', { ... }); 77 | ``` 78 | 79 | The `send` method takes three arguments. 80 | 81 | * `topic`: The topic to publish the update to. 82 | * `data`: The data to publish. 83 | * `isPrivate`: Whether the update is private or not. 84 | 85 | More information on the topic and data arguments can be found in the [Mercure documentation](https://mercure.rocks/spec#publication). 86 | 87 | ### Frontend 88 | 89 | The frontend can listen to changes using the standard [`EventSource` web interface](https://developer.mozilla.org/en-US/docs/Web/API/EventSource). 90 | 91 | ```js 92 | const url = new URL(/* Mercure Endpoint */) 93 | url.searchParams.append('topic', '/users/1') 94 | 95 | const eventSource = new EventSource(url) 96 | eventSource.onmessage = (event) => { 97 | console.log(event.data) 98 | } 99 | ``` 100 | 101 | More information on the topic can be found in the [Mercure documentation](https://mercure.rocks/docs/getting-started). 102 | 103 | ### Authentication 104 | 105 | You may want to send private messages. To do so, you need to set the update as private using the third argument of the `Update.send` method, and authenticate the client using a JWT stored in a cookie. 106 | 107 | You can generate the JWT using the `Token` class. 108 | 109 | ```ts 110 | import { Token } from '@ioc:Setten/Mercure'; 111 | 112 | // Generating the token allowing the user to listen on private events 113 | // send to `/users/1`. 114 | const token = await Token.generate({ 115 | subscribe: ['/users/1'], 116 | }) 117 | 118 | // Adding the token in a cookie name `mercureAuthorization`. 119 | response.append( 120 | 'set-cookie', 121 | `mercureAuthorization=${token}; Domain=.setten.io; Path=/.well-known/mercure; HttpOnly` 122 | ) 123 | ``` 124 | 125 | The cookie must be named `mercureAuthorization` and must be not encoded by AdonisJS (you cannot use `response.cookie()` at the moment for that reason). 126 | 127 | Note that the Mercure Hub must run on the same domain as the client since cookies cannot be shared cross-domain. 128 | 129 | Once done, you have to change your client's connection to use cookies. 130 | 131 | ```js 132 | const eventSource = new EventSource(url, { withCredentials: true }) 133 | ``` 134 | 135 | More information on the topic can be found in the [Mercure documentation](https://mercure.rocks/spec#authorization). -------------------------------------------------------------------------------- /adonis-typings/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @setten/mercure 3 | * 4 | * @license MIT 5 | * @copyright Setten - Romain Lanz 6 | */ 7 | 8 | declare module '@ioc:Setten/Mercure' { 9 | import type { Algorithm } from 'jws'; 10 | 11 | export type MercureConfig = { 12 | endpoint: string; 13 | adminToken: string; 14 | 15 | jwt: { 16 | alg: Algorithm; 17 | secret: string; 18 | }; 19 | }; 20 | 21 | interface TokenContract { 22 | generate(payload: any): Promise; 23 | } 24 | 25 | interface UpdateContract { 26 | send( 27 | topics: string | string[], 28 | data?: Record, 29 | isPrivate?: boolean 30 | ): Promise; 31 | } 32 | 33 | export const Token: TokenContract; 34 | export const Update: UpdateContract; 35 | } 36 | -------------------------------------------------------------------------------- /instructions.md: -------------------------------------------------------------------------------- 1 | The package has been configured successfully. The mercure configuration stored inside `config/mercure.ts` file relies on the following environment variables and hence we recommend validating them. 2 | 3 | **Open the `env.ts` file and paste the following code inside the `Env.rules` object.** 4 | 5 | ```ts 6 | MERCURE_ENDPOINT: Env.schema.string(), 7 | MERCURE_ADMIN_JWT: Env.schema.string(), 8 | MERCURE_JWT_SECRET: Env.schema.string(), 9 | ``` -------------------------------------------------------------------------------- /instructions.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @setten/mercure 3 | * 4 | * @license MIT 5 | * @copyright Setten - Romain Lanz 6 | */ 7 | 8 | import { join } from 'node:path'; 9 | import * as sinkStatic from '@adonisjs/sink'; 10 | import type { ApplicationContract } from '@ioc:Adonis/Core/Application'; 11 | 12 | function getStub(...relativePaths: string[]) { 13 | return join(__dirname, 'templates', ...relativePaths); 14 | } 15 | 16 | export default async function instructions( 17 | projectRoot: string, 18 | app: ApplicationContract, 19 | sink: typeof sinkStatic 20 | ) { 21 | // Setup config 22 | const configPath = app.configPath('mercure.ts'); 23 | new sink.files.MustacheFile(projectRoot, configPath, getStub('config.txt')).commit(); 24 | const configDir = app.directoriesMap.get('config') || 'config'; 25 | sink.logger.action('create').succeeded(`${configDir}/mercure.ts`); 26 | 27 | // Setup environment 28 | const env = new sink.files.EnvFile(projectRoot); 29 | env.set('MERCURE_ENDPOINT', 'http://localhost:XXXX/.well-known/mercure'); 30 | env.set( 31 | 'MERCURE_ADMIN_JWT', 32 | 'eyJhbGciOiJIUzI1NiJ9.eyJtZXJjdXJlIjp7InB1Ymxpc2giOlsiKiJdfX0.mx2ROlYlE1rp7udoDy-WCdnpLdPuKWzDxoBJXGMK4OE' 33 | ); 34 | env.set('MERCURE_JWT_SECRET', 'ChangeMe'); 35 | env.commit(); 36 | sink.logger.action('update').succeeded('.env,.env.example'); 37 | } 38 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@setten/mercure", 3 | "version": "0.1.1", 4 | "description": "Mercure Client for AdonisJS", 5 | "homepage": "https://github.com/setten-io/adonis-mercure#readme", 6 | "license": "MIT", 7 | "keywords": [ 8 | "adonisjs", 9 | "mercure", 10 | "sse", 11 | "server sent events" 12 | ], 13 | "author": "Romain Lanz ", 14 | "main": "build/providers/MercureProvider.js", 15 | "files": [ 16 | "build/adonis-typings", 17 | "build/commands", 18 | "build/providers", 19 | "build/src", 20 | "build/templates", 21 | "build/instructions.js", 22 | "build/instructions.md" 23 | ], 24 | "typings": "./build/adonis-typings/index.d.ts", 25 | "scripts": { 26 | "build": "npm run clean && npm run build-only && npm run copyfiles", 27 | "build-only": "tsc", 28 | "clean": "del-cli build", 29 | "copyfiles": "copyfiles \"templates/**/*.txt\" \"instructions.md\" build", 30 | "prepublishOnly": "npm run build" 31 | }, 32 | "dependencies": { 33 | "got": "^11.8.5", 34 | "jws": "^4.0.0" 35 | }, 36 | "devDependencies": { 37 | "@adonisjs/application": "^5.2.5", 38 | "@adonisjs/core": "^5.8.4", 39 | "@adonisjs/sink": "^5.3.2", 40 | "@types/jws": "^3.2.4", 41 | "copyfiles": "^2.4.1", 42 | "del-cli": "^4.0.1", 43 | "prettier": "^2.7.1", 44 | "typescript": "^4.7.4" 45 | }, 46 | "repository": { 47 | "type": "git", 48 | "url": "git+https://github.com/setten-io/adonis-mercure.git" 49 | }, 50 | "bugs": { 51 | "url": "https://github.com/setten-io/adonis-mercure/issues" 52 | }, 53 | "adonisjs": { 54 | "instructions": "./build/instructions.js", 55 | "instructionsMd": "./build/instructions.md", 56 | "types": "@setten/mercure", 57 | "providers": [ 58 | "@setten/mercure" 59 | ] 60 | }, 61 | "prettier": { 62 | "arrowParens": "always", 63 | "printWidth": 100, 64 | "quoteProps": "consistent", 65 | "semi": true, 66 | "singleQuote": true, 67 | "trailingComma": "es5", 68 | "useTabs": true 69 | } 70 | } -------------------------------------------------------------------------------- /providers/MercureProvider.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @setten/mercure 3 | * 4 | * @license MIT 5 | * @copyright Setten - Romain Lanz 6 | */ 7 | 8 | import Token from '../src/Token'; 9 | import Update from '../src/Update'; 10 | import type { ApplicationContract } from '@ioc:Adonis/Core/Application'; 11 | 12 | export default class MercureProvider { 13 | constructor(protected app: ApplicationContract) {} 14 | 15 | public async boot() { 16 | this.app.container.bind('Setten/Mercure', () => { 17 | const config = this.app.container.resolveBinding('Adonis/Core/Config').get('mercure'); 18 | 19 | return { 20 | Update: new Update(config), 21 | Token: new Token(config), 22 | }; 23 | }); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/Token.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @setten/mercure 3 | * 4 | * @license MIT 5 | * @copyright Setten - Romain Lanz 6 | */ 7 | 8 | import jws from 'jws'; 9 | import type { MercureConfig } from '@ioc:Setten/Mercure'; 10 | 11 | export default class Token { 12 | constructor(private config: MercureConfig) {} 13 | 14 | public generate(payload: any) { 15 | return new Promise((resolve, reject) => { 16 | jws 17 | .createSign({ 18 | payload: { mercure: payload }, 19 | secret: this.config.jwt.secret, 20 | header: { alg: this.config.jwt.alg }, 21 | }) 22 | .on('error', reject) 23 | .on('done', resolve); 24 | }); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/Update.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @setten/mercure 3 | * 4 | * @license MIT 5 | * @copyright Setten - Romain Lanz 6 | */ 7 | 8 | import got from 'got'; 9 | import type { MercureConfig } from '@ioc:Setten/Mercure'; 10 | 11 | export default class Update { 12 | constructor(private config: MercureConfig) {} 13 | 14 | public send( 15 | topics: string | string[], 16 | data: Record = {}, 17 | isPrivate: boolean = false 18 | ) { 19 | topics = Array.isArray(topics) ? topics : [topics]; 20 | 21 | return got.post(this.config.endpoint, { 22 | headers: { 23 | Authorization: `Bearer ${this.config.adminToken}`, 24 | }, 25 | form: [...topics.map((topic) => ['topic', topic]), ['data', JSON.stringify(data)]].concat( 26 | isPrivate ? [['private', 'on']] : [] 27 | ), 28 | }); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /templates/config.txt: -------------------------------------------------------------------------------- 1 | import Env from '@ioc:Adonis/Core/Env'; 2 | import type { MercureConfig } from '@ioc:Setten/Mercure'; 3 | 4 | const mercureConfig: MercureConfig = { 5 | endpoint: Env.get('MERCURE_ENDPOINT'), 6 | adminToken: Env.get('MERCURE_ADMIN_JWT'), 7 | 8 | jwt: { 9 | alg: 'HS256', 10 | secret: Env.get('MERCURE_JWT_SECRET'), 11 | }, 12 | }; 13 | 14 | export default mercureConfig; 15 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": [ 3 | "./**/*" 4 | ], 5 | "exclude": [ 6 | "./node_modules", 7 | "./build" 8 | ], 9 | "compilerOptions": { 10 | "allowSyntheticDefaultImports": true, 11 | "declaration": true, 12 | "esModuleInterop": true, 13 | "module": "CommonJS", 14 | "moduleResolution": "Node", 15 | "outDir": "build", 16 | "useDefineForClassFields": true, 17 | "skipLibCheck": true, 18 | "strict": true, 19 | "target": "ES2020", 20 | "types": [ 21 | "@adonisjs/core" 22 | ] 23 | } 24 | } --------------------------------------------------------------------------------