├── .editorconfig ├── .gitignore ├── .npmrc ├── .prettierignore ├── LICENSE.md ├── README.md ├── configure.ts ├── index.ts ├── package.json ├── providers └── websocket_provider.ts ├── src ├── define_config.ts ├── types.ts └── websocket.ts ├── stubs ├── config │ └── websocket.stub └── main.ts ├── tsconfig.json └── tsnode.esm.js /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [*.json] 12 | insert_final_newline = ignore 13 | 14 | [**.min.js] 15 | indent_style = ignore 16 | insert_final_newline = ignore 17 | 18 | [MakeFile] 19 | indent_style = space 20 | 21 | [*.md] 22 | trim_trailing_whitespace = false 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | coverage 3 | .DS_STORE 4 | .nyc_output 5 | .idea 6 | .vscode/ 7 | *.sublime-project 8 | *.sublime-workspace 9 | *.log 10 | build 11 | dist 12 | yarn.lock 13 | shrinkwrap.yaml 14 | package-lock.json 15 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | package-lock=false 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | build 2 | docs 3 | coverage 4 | *.html 5 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # The MIT License 2 | 3 | Copyright (c) 2024 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. 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # AdonisJS Websocket 2 | 3 | ```bash 4 | node ace add adonisjs-websocket 5 | ``` 6 | 7 | ## Usage 8 | 9 | Simple example: 10 | 11 | ```ts 12 | // start/routes.ts 13 | router.ws('/ws', ({ ws }) => { 14 | ws.on('message', (message) => { 15 | ws.send('Received: ' + message.toString()) 16 | }) 17 | 18 | ws.on('close', () => { 19 | console.log('Connection closed') 20 | }) 21 | 22 | ws.send('Hello! Your id is ' + ws.id) 23 | }) 24 | ``` 25 | 26 | ```bash 27 | npx wscat -c "ws://localhost:3333/ws" 28 | ``` 29 | 30 | Middleware and broadcasting: 31 | 32 | ```ts 33 | // start/routes.ts 34 | router.ws( 35 | '/rooms/:roomId', 36 | ({ ws, params, auth }) => { 37 | const roomId = params.roomId 38 | const user = auth.user 39 | 40 | if (user.isBanned) { 41 | return ws.close() 42 | } 43 | 44 | ws.on('message', (message) => { 45 | ws.send('Received: ' + message.toString()) 46 | }) 47 | 48 | // broadcast to all clients of the same url path 49 | // you can enable redis in `config/websocket.ts` to broadcast on all web server instances 50 | await ws.broadcast('Hello everyone!') 51 | }, 52 | [ 53 | // you can enable them globally in `config/websocket.ts` 54 | () => import('#middleware/container_bindings_middleware'), 55 | () => import('@adonisjs/auth/initialize_auth_middleware'), 56 | 57 | middleware.auth(), 58 | ] 59 | ) 60 | ``` 61 | 62 | ```bash 63 | npx wscat -c 'ws://localhost:3333/rooms/1' -H 'Authorization: Bearer oat_MjU.Z25o...' 64 | npx wscat -c 'ws://localhost:3333/rooms/2?token=oat_MjU.Z25o...' 65 | npx wscat -c 'ws://localhost:3334/rooms/2?token=oat_MjU.Z25o...' 66 | ``` 67 | 68 | Using controllers: 69 | 70 | ```ts 71 | // start/routes.ts 72 | const WsChatController = () => import('#controllers/ws/chat_controller') 73 | 74 | router.ws('/chat', [WsChatController, 'handle']) 75 | ``` 76 | 77 | ```ts 78 | // app/controllers/ws/chat_controller.ts 79 | import type { WebSocketContext } from 'adonisjs-websocket' 80 | 81 | export default class ChatController { 82 | public async handle({ ws }: WebSocketContext) { 83 | ws.on('message', (message) => { 84 | ws.send('Received: ' + message.toString()) 85 | }) 86 | 87 | ws.send('Hello! Your id is ' + ws.id) 88 | } 89 | } 90 | ``` 91 | 92 | For browsers, it's common practice to send a small message (heartbeat) for every given time passed (e.g every 30sec) to keep the connection active. 93 | 94 | ```ts 95 | // frontend 96 | const ws = new WebSocket('wss://localhost:3333/ws') 97 | 98 | const HEARTBEAT_INTERVAL = 30000 99 | let heartbeatInterval 100 | 101 | ws.onopen = () => { 102 | heartbeatInterval = setInterval(() => { 103 | ws.send('ping') 104 | }, HEARTBEAT_INTERVAL) 105 | } 106 | 107 | ws.onclose = () => { 108 | clearInterval(heartbeatInterval) 109 | } 110 | ``` 111 | 112 | ```ts 113 | // backend 114 | router.ws('/ws', ({ ws }) => { 115 | ws.on('message', (message) => { 116 | if (message.toString() === 'ping') { 117 | ws.send('pong') 118 | } 119 | }) 120 | }) 121 | ``` 122 | -------------------------------------------------------------------------------- /configure.ts: -------------------------------------------------------------------------------- 1 | /* 2 | |-------------------------------------------------------------------------- 3 | | Configure hook 4 | |-------------------------------------------------------------------------- 5 | | 6 | | The configure hook is called when someone runs "node ace configure " 7 | | command. You are free to perform any operations inside this function to 8 | | configure the package. 9 | | 10 | | To make things easier, you have access to the underlying "ConfigureCommand" 11 | | instance and you can use codemods to modify the source files. 12 | | 13 | */ 14 | 15 | import ConfigureCommand from '@adonisjs/core/commands/configure' 16 | import { stubsRoot } from './stubs/main.js' 17 | 18 | export async function configure(command: ConfigureCommand) { 19 | const codemods = await command.createCodemods() 20 | await codemods.makeUsingStub(stubsRoot, 'config/websocket.stub', {}) 21 | 22 | await codemods.updateRcFile((rcFile) => { 23 | rcFile.addProvider('adonisjs-websocket/websocket_provider') 24 | }) 25 | } 26 | -------------------------------------------------------------------------------- /index.ts: -------------------------------------------------------------------------------- 1 | /* 2 | |-------------------------------------------------------------------------- 3 | | Package entrypoint 4 | |-------------------------------------------------------------------------- 5 | | 6 | | Export values from the package entrypoint as you see fit. 7 | | 8 | */ 9 | 10 | export { configure } from './configure.js' 11 | export { defineConfig } from './src/define_config.js' 12 | export { WebSocket } from './src/websocket.js' 13 | export * from './src/types.js' 14 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "adonisjs-websocket", 3 | "description": "Websocket provider for AdonisJS", 4 | "version": "0.2.3", 5 | "engines": { 6 | "node": ">=20.6.0" 7 | }, 8 | "type": "module", 9 | "files": [ 10 | "build", 11 | "LICENSE.md", 12 | "README.md", 13 | "package.json" 14 | ], 15 | "exports": { 16 | ".": "./build/index.js", 17 | "./types": "./build/src/types.js", 18 | "./websocket_provider": "./build/providers/websocket_provider.js" 19 | }, 20 | "scripts": { 21 | "clean": "del-cli build", 22 | "copy:templates": "copyfiles \"stubs/**/*.stub\" build", 23 | "typecheck": "tsc --noEmit", 24 | "lint": "eslint . --ext=.ts", 25 | "format": "prettier --write .", 26 | "quick:test": "node --import=./tsnode.esm.js --enable-source-maps bin/test.ts", 27 | "pretest": "npm run lint", 28 | "test": "c8 npm run quick:test", 29 | "prebuild": "npm run lint && npm run clean", 30 | "build": "tsc", 31 | "postbuild": "npm run copy:templates", 32 | "release": "np", 33 | "version": "npm run build", 34 | "prepublishOnly": "npm run build" 35 | }, 36 | "keywords": [], 37 | "author": "Georges KABBOUCHI ", 38 | "homepage": "https://github.com/KABBOUCHI/adonisjs-websocket", 39 | "license": "MIT", 40 | "devDependencies": { 41 | "@adonisjs/assembler": "^7.7.0", 42 | "@adonisjs/core": "^6.12.0", 43 | "@adonisjs/eslint-config": "^1.3.0", 44 | "@adonisjs/prettier-config": "^1.3.0", 45 | "@adonisjs/tsconfig": "^1.3.0", 46 | "@japa/assert": "^3.0.0", 47 | "@japa/runner": "^3.1.4", 48 | "@swc/core": "^1.6.3", 49 | "@types/node": "^20.14.5", 50 | "@types/ws": "^8.5.12", 51 | "c8": "^10.1.2", 52 | "copyfiles": "^2.4.1", 53 | "del-cli": "^5.1.0", 54 | "eslint": "^8.57.0", 55 | "np": "^10.0.6", 56 | "prettier": "^3.3.2", 57 | "ts-node": "^10.9.2", 58 | "typescript": "^5.4.5" 59 | }, 60 | "peerDependencies": { 61 | "@adonisjs/core": "^6.2.0", 62 | "@adonisjs/http-server": "^7.0.2" 63 | }, 64 | "publishConfig": { 65 | "access": "public", 66 | "tag": "latest" 67 | }, 68 | "np": { 69 | "message": "chore(release): %s", 70 | "tag": "latest", 71 | "branch": "main", 72 | "anyBranch": false 73 | }, 74 | "c8": { 75 | "reporter": [ 76 | "text", 77 | "html" 78 | ], 79 | "exclude": [ 80 | "tests/**" 81 | ] 82 | }, 83 | "eslintConfig": { 84 | "extends": "@adonisjs/eslint-config/package" 85 | }, 86 | "prettier": "@adonisjs/prettier-config", 87 | "dependencies": { 88 | "ioredis": "^5.4.1", 89 | "ws": "^8.18.0", 90 | "@types/ws": "^8.5.14" 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /providers/websocket_provider.ts: -------------------------------------------------------------------------------- 1 | import type { ApplicationService } from '@adonisjs/core/types' 2 | import { 3 | Constructor, 4 | OneOrMore, 5 | MiddlewareFn, 6 | ParsedNamedMiddleware, 7 | } from '@adonisjs/http-server/types' 8 | import { Router } from '@adonisjs/http-server' 9 | import { LazyImport } from '@adonisjs/http-server/types' 10 | import { QsParserFactory } from '@adonisjs/http-server/factories' 11 | import { WebSocketServer } from 'ws' 12 | import { moduleImporter } from '@adonisjs/core/container' 13 | import { ServerResponse } from 'node:http' 14 | import type { GetWsControllerHandlers, WsRouteFn } from '../src/types.js' 15 | import { defineConfig } from '../src/define_config.js' 16 | import { Redis } from 'ioredis' 17 | import { cuid } from '@adonisjs/core/helpers' 18 | import { WebSocket } from '../src/websocket.js' 19 | 20 | declare module '@adonisjs/core/http' { 21 | interface Router { 22 | ws>( 23 | pattern: string, 24 | handler: string | WsRouteFn | [LazyImport | T, GetWsControllerHandlers?], 25 | middleware?: OneOrMore 26 | ): void 27 | } 28 | } 29 | 30 | declare module '@adonisjs/core/types' { 31 | export interface ContainerBindings { 32 | websocket: { 33 | /** 34 | * @experimental This API is not stable and may change in future versions. 35 | */ 36 | broadcast: (url: string, data: string) => Promise 37 | } 38 | } 39 | } 40 | const routes = new Map< 41 | string, 42 | { 43 | pattern: string 44 | handler: any 45 | middleware?: OneOrMore 46 | } 47 | >() 48 | 49 | export default class WebsocketProvider { 50 | constructor(protected app: ApplicationService) {} 51 | 52 | /** 53 | * Register bindings to the container 54 | */ 55 | register() {} 56 | 57 | /** 58 | * The container bindings have booted 59 | */ 60 | async boot() { 61 | const router = await this.app.container.make('router') 62 | router.ws = (pattern, handler, middleware = []) => { 63 | routes.set(pattern, { 64 | pattern, 65 | handler, 66 | // middleware, 67 | // @ts-ignore 68 | middleware: (Array.isArray(middleware) ? middleware : [middleware]).map((one: any) => 69 | one.handle 70 | ? { 71 | ...one, 72 | handle: (ctx: any, next: any, args?: any) => 73 | one.handle(ctx.containerResolver, ctx, next, args), 74 | } 75 | : moduleImporter(one, 'handle').toHandleMethod(this.app.container) 76 | ), 77 | }) 78 | } 79 | } 80 | 81 | /** 82 | * The application has been booted 83 | */ 84 | async start() {} 85 | 86 | /** 87 | * The process has been started 88 | */ 89 | async ready() { 90 | const server = await this.app.container.make('server') 91 | if (!server) { 92 | return 93 | } 94 | 95 | const config = this.app.config.get>('websocket', {}) 96 | const channels = new Map>() 97 | 98 | const publisher = config.redis.enabled ? new Redis(config.redis) : null 99 | const subscriber = config.redis.enabled ? new Redis(config.redis) : null 100 | 101 | if (subscriber) { 102 | subscriber.subscribe('websocket::broadcast') 103 | subscriber.on('message', (c, message) => { 104 | if (c === 'websocket::broadcast') { 105 | const { channel, data, options, clientId } = JSON.parse(message) 106 | const clients = channels.get(channel) || new Map() 107 | 108 | for (const client of clients.values()) { 109 | if (options && options.ignoreSelf && client.id === clientId) { 110 | continue 111 | } 112 | 113 | if (client.readyState === WebSocket.OPEN) { 114 | client.send(data) 115 | } 116 | } 117 | } 118 | }) 119 | } 120 | 121 | const wss = new WebSocketServer({ 122 | noServer: true, 123 | WebSocket, 124 | }) 125 | 126 | this.app.terminating(() => { 127 | try { 128 | publisher?.disconnect() 129 | } catch {} 130 | try { 131 | subscriber?.disconnect() 132 | } catch {} 133 | 134 | try { 135 | wss.clients.forEach((client) => client.close(1000, 'Server shutting down')) 136 | } catch {} 137 | 138 | try { 139 | wss.close() 140 | } catch {} 141 | }) 142 | // this.app.terminating doesn't work when websocket is used 143 | process.on('SIGTERM', async () => { 144 | try { 145 | publisher?.disconnect() 146 | } catch {} 147 | try { 148 | subscriber?.disconnect() 149 | } catch {} 150 | try { 151 | wss.clients.forEach((client) => client.close(1000, 'Server shutting down')) 152 | } catch {} 153 | 154 | try { 155 | wss.close() 156 | } catch {} 157 | }) 158 | const wsRouter = new Router( 159 | this.app, 160 | await this.app.container.make('encryption'), 161 | new QsParserFactory().create() 162 | ) 163 | 164 | const globalMiddleware: any[] = config.middleware.map((m) => 165 | moduleImporter(m, 'handle').toHandleMethod(this.app.container) 166 | ) 167 | 168 | for (const route of routes.values()) { 169 | wsRouter 170 | .any(route.pattern, route.handler) 171 | .middleware(globalMiddleware as any) 172 | .middleware(route.middleware as any) 173 | } 174 | 175 | wsRouter.commit() 176 | 177 | this.app.container.singleton('websocket', () => ({ 178 | broadcast: async (url: string, data: string) => { 179 | if (publisher) { 180 | await publisher.publish( 181 | 'websocket::broadcast', 182 | JSON.stringify({ 183 | channel: url, 184 | data, 185 | clientId: null, 186 | }) 187 | ) 188 | } else { 189 | const clients = channels.get(url) || new Map() 190 | 191 | for (const client of clients.values()) { 192 | if (client.readyState === WebSocket.OPEN) { 193 | client.send(data) 194 | } 195 | } 196 | } 197 | }, 198 | })) 199 | 200 | const nodeServer = server.getNodeServer() 201 | if (!nodeServer) { 202 | return 203 | } 204 | nodeServer.on('upgrade', async (req, socket, head) => { 205 | if (!req.url) { 206 | return socket.end() 207 | } 208 | 209 | const url = req.url.split('?')[0] 210 | const wsRoute = wsRouter.match(url, 'GET') 211 | 212 | if (!wsRoute) { 213 | return socket.end() 214 | } 215 | 216 | try { 217 | const containerResolver = this.app.container.createResolver() 218 | 219 | const serverResponse = new ServerResponse(req) 220 | const request = server.createRequest(req, serverResponse) 221 | const response = server.createResponse(req, serverResponse) 222 | const ctx = server.createHttpContext(request, response, containerResolver) 223 | ctx.params = wsRoute.params 224 | 225 | await wsRoute.route.middleware.runner().run((handler: any, next) => { 226 | return handler.handle(ctx, next, handler.args) 227 | }) 228 | 229 | const clientId = cuid() 230 | 231 | wss.handleUpgrade(req, socket, head, async (ws) => { 232 | if (!channels.has(url)) { 233 | channels.set(url, new Map()) 234 | } 235 | 236 | ws.id = clientId 237 | channels.get(url)!.set(clientId, ws as any) 238 | 239 | ws.on('close', () => { 240 | channels.get(url)!.delete(clientId) 241 | }) 242 | 243 | ws.broadcast = async (data: string, options: any) => { 244 | if (publisher) { 245 | await publisher.publish( 246 | 'websocket::broadcast', 247 | JSON.stringify({ 248 | channel: url, 249 | data, 250 | clientId, 251 | options, 252 | }) 253 | ) 254 | } else { 255 | const clients = channels.get(url) || new Map() 256 | 257 | for (const client of clients.values()) { 258 | if (options && options.ignoreSelf && client.id === clientId) { 259 | continue 260 | } 261 | 262 | if (client.readyState === WebSocket.OPEN) { 263 | client.send(data) 264 | } 265 | } 266 | } 267 | } 268 | 269 | try { 270 | if (typeof wsRoute.route.handler === 'function') { 271 | await wsRoute.route.handler({ 272 | ...ctx, 273 | ws, 274 | } as any) 275 | } else { 276 | await wsRoute.route.handler.handle(ctx.containerResolver, { 277 | ...ctx, 278 | ws, 279 | } as any) 280 | } 281 | } catch (error) { 282 | ws.close(1000, error.message) 283 | channels.get(url)!.delete(clientId) 284 | socket.end() 285 | } 286 | }) 287 | } catch (error) { 288 | console.error(error) 289 | socket.end() 290 | } 291 | }) 292 | } 293 | 294 | /** 295 | * Preparing to shutdown the app 296 | */ 297 | async shutdown() {} 298 | } 299 | -------------------------------------------------------------------------------- /src/define_config.ts: -------------------------------------------------------------------------------- 1 | import type * as IORedis from 'ioredis' 2 | 3 | type WebsocketConfig = { 4 | middleware: any[] 5 | redis: { 6 | enabled: boolean 7 | } & IORedis.RedisOptions 8 | } 9 | 10 | export function defineConfig(config: WebsocketConfig) { 11 | return config 12 | } 13 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import type { HttpContext } from '@adonisjs/core/http' 2 | import type { Constructor } from '@adonisjs/http-server/types' 3 | import type { WebSocket } from './websocket.js' 4 | 5 | export type WebSocketContext = { 6 | ws: WebSocket 7 | } & Omit 8 | 9 | export type WsRouteFn = (ctx: WebSocketContext) => void 10 | export type GetWsControllerHandlers> = { 11 | [K in keyof InstanceType]: InstanceType[K] extends ( 12 | ctx: WebSocketContext, 13 | ...args: any[] 14 | ) => any 15 | ? K 16 | : never 17 | }[keyof InstanceType] 18 | -------------------------------------------------------------------------------- /src/websocket.ts: -------------------------------------------------------------------------------- 1 | import { WebSocket as WebSocketBase } from 'ws' 2 | 3 | export class WebSocket extends WebSocketBase { 4 | declare id: string 5 | declare broadcast: (data: string, options?: { ignoreSelf?: boolean }) => Promise 6 | } 7 | -------------------------------------------------------------------------------- /stubs/config/websocket.stub: -------------------------------------------------------------------------------- 1 | {{{ exports({ to: app.configPath('websocket.ts') }) }}} 2 | import env from '#start/env' 3 | import { defineConfig } from 'adonisjs-websocket' 4 | 5 | const websocketConfig = defineConfig({ 6 | middleware: [ 7 | // () => import('#middleware/container_bindings_middleware'), 8 | // () => import('@adonisjs/auth/initialize_auth_middleware'), 9 | ], 10 | redis: { 11 | enabled: false, 12 | host: env.get('REDIS_HOST', 'localhost'), 13 | port: env.get('REDIS_PORT', 6379), 14 | password: env.get('REDIS_PASSWORD'), 15 | }, 16 | }) 17 | 18 | export default websocketConfig 19 | -------------------------------------------------------------------------------- /stubs/main.ts: -------------------------------------------------------------------------------- 1 | import { dirname } from 'node:path' 2 | import { fileURLToPath } from 'node:url' 3 | 4 | /** 5 | * Path to the root directory where the stubs are stored. We use 6 | * this path within commands and the configure hook 7 | */ 8 | export const stubsRoot = dirname(fileURLToPath(import.meta.url)) 9 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@adonisjs/tsconfig/tsconfig.package.json", 3 | "compilerOptions": { 4 | "rootDir": "./", 5 | "outDir": "./build" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /tsnode.esm.js: -------------------------------------------------------------------------------- 1 | /* 2 | |-------------------------------------------------------------------------- 3 | | TS-Node ESM hook 4 | |-------------------------------------------------------------------------- 5 | | 6 | | Importing this file before any other file will allow you to run TypeScript 7 | | code directly using TS-Node + SWC. For example 8 | | 9 | | node --import="./tsnode.esm.js" bin/test.ts 10 | | node --import="./tsnode.esm.js" index.ts 11 | | 12 | | 13 | | Why not use "--loader=ts-node/esm"? 14 | | Because, loaders have been deprecated. 15 | */ 16 | 17 | import { register } from 'node:module' 18 | register('ts-node/esm', import.meta.url) 19 | --------------------------------------------------------------------------------