├── .gitignore ├── README.md ├── eslint.yaml ├── hapi-plugin-websocket.d.ts ├── hapi-plugin-websocket.js ├── package.json └── sample-server.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | hapi-plugin-websocket 3 | ===================== 4 | 5 | [HAPI](http://hapijs.com/) plugin for seamless WebSocket integration. 6 | 7 |

8 | 9 | 10 |

11 | 12 | 13 | Installation 14 | ------------ 15 | 16 | ```shell 17 | $ npm install hapi hapi-plugin-websocket 18 | ``` 19 | 20 | About 21 | ----- 22 | 23 | This is a small plugin for the [HAPI](http://hapijs.com/) server 24 | framework of [Node.js](https://nodejs.org/) for seamless 25 | [WebSocket](https://tools.ietf.org/html/rfc6455) protocol integration. 26 | It accepts WebSocket connections and transforms between incoming/outgoing 27 | WebSocket messages and injected HTTP request/response messages. 28 | 29 | Usage 30 | ----- 31 | 32 | The following sample server shows all features at once: 33 | 34 | ```js 35 | const Boom = require("@hapi/boom") 36 | const HAPI = require("@hapi/hapi") 37 | const HAPIAuthBasic = require("@hapi/basic") 38 | const HAPIWebSocket = require("hapi-plugin-websocket") 39 | const WebSocket = require("ws") 40 | 41 | ;(async () => { 42 | /* create new HAPI service */ 43 | const server = new HAPI.Server({ address: "127.0.0.1", port: 12345 }) 44 | 45 | /* register HAPI plugins */ 46 | await server.register(HAPIWebSocket) 47 | await server.register(HAPIAuthBasic) 48 | 49 | /* register Basic authentication strategy */ 50 | server.auth.strategy("basic", "basic", { 51 | validate: async (request, username, password, h) => { 52 | let isValid = false 53 | let credentials = null 54 | if (username === "foo" && password === "bar") { 55 | isValid = true 56 | credentials = { username } 57 | } 58 | return { isValid, credentials } 59 | } 60 | }) 61 | 62 | /* provide plain REST route */ 63 | server.route({ 64 | method: "POST", path: "/foo", 65 | config: { 66 | payload: { output: "data", parse: true, allow: "application/json" } 67 | }, 68 | handler: (request, h) => { 69 | return { at: "foo", seen: request.payload } 70 | } 71 | }) 72 | 73 | /* provide combined REST/WebSocket route */ 74 | server.route({ 75 | method: "POST", path: "/bar", 76 | config: { 77 | payload: { output: "data", parse: true, allow: "application/json" }, 78 | plugins: { websocket: true } 79 | }, 80 | handler: (request, h) => { 81 | let { mode } = request.websocket() 82 | return { at: "bar", mode: mode, seen: request.payload } 83 | } 84 | }) 85 | 86 | /* provide exclusive WebSocket route */ 87 | server.route({ 88 | method: "POST", path: "/baz", 89 | config: { 90 | plugins: { websocket: { only: true, autoping: 30 * 1000 } } 91 | }, 92 | handler: (request, h) => { 93 | return { at: "baz", seen: request.payload } 94 | } 95 | }) 96 | 97 | /* provide full-featured exclusive WebSocket route */ 98 | server.route({ 99 | method: "POST", path: "/quux", 100 | config: { 101 | response: { emptyStatusCode: 204 }, 102 | payload: { output: "data", parse: true, allow: "application/json" }, 103 | auth: { mode: "required", strategy: "basic" }, 104 | plugins: { 105 | websocket: { 106 | only: true, 107 | initially: true, 108 | subprotocol: "quux.example.com", 109 | connect: ({ ctx, ws }) => { 110 | ctx.to = setInterval(() => { 111 | if (ws.readyState === WebSocket.OPEN) 112 | ws.send(JSON.stringify({ cmd: "PING" })) 113 | }, 5000) 114 | }, 115 | disconnect: ({ ctx }) => { 116 | if (ctx.to !== null) { 117 | clearTimeout(this.ctx) 118 | ctx.to = null 119 | } 120 | } 121 | } 122 | } 123 | }, 124 | handler: (request, h) => { 125 | let { initially, ws } = request.websocket() 126 | if (initially) { 127 | ws.send(JSON.stringify({ cmd: "HELLO", arg: request.auth.credentials.username })) 128 | return "" 129 | } 130 | if (typeof request.payload !== "object" || request.payload === null) 131 | return Boom.badRequest("invalid request") 132 | if (typeof request.payload.cmd !== "string") 133 | return Boom.badRequest("invalid request") 134 | if (request.payload.cmd === "PING") 135 | return { result: "PONG" } 136 | else if (request.payload.cmd === "AWAKE-ALL") { 137 | var peers = request.websocket().peers 138 | peers.forEach((peer) => { 139 | peer.send(JSON.stringify({ cmd: "AWAKE" })) 140 | }) 141 | return "" 142 | } 143 | else 144 | return Boom.badRequest("unknown command") 145 | } 146 | }) 147 | 148 | /* provide exclusive framed WebSocket route */ 149 | server.route({ 150 | method: "POST", path: "/framed", 151 | config: { 152 | plugins: { 153 | websocket: { 154 | only: true, 155 | autoping: 30 * 1000, 156 | frame: true, 157 | frameEncoding: "json", 158 | frameRequest: "REQUEST", 159 | frameResponse: "RESPONSE" 160 | } 161 | } 162 | }, 163 | handler: (request, h) => { 164 | return { at: "framed", seen: request.payload } 165 | } 166 | }) 167 | 168 | /* start the HAPI service */ 169 | await server.start() 170 | })().catch((err) => { 171 | console.log(`ERROR: ${err}`) 172 | }) 173 | 174 | ``` 175 | 176 | You can test-drive this the following way (with the help 177 | of [curl](https://curl.haxx.se/) and [wscat](https://www.npmjs.com/package/wscat)): 178 | 179 | ```shell 180 | # start the sample server implementation (see source code above) 181 | $ node sample-server.js & 182 | 183 | # access the plain REST route via REST 184 | $ curl -X POST --header 'Content-type: application/json' \ 185 | --data '{ "foo": 42 }' http://127.0.0.1:12345/foo 186 | {"at":"foo","seen":{"foo":42}} 187 | 188 | # access the combined REST/WebSocket route via REST 189 | $ curl -X POST --header 'Content-type: application/json' \ 190 | --data '{ "foo": 42 }' http://127.0.0.1:12345/bar 191 | {"at":"bar","mode":"http","seen":{"foo":42}} 192 | 193 | # access the exclusive WebSocket route via REST 194 | $ curl -X POST --header 'Content-type: application/json' \ 195 | --data '{ "foo": 42 }' http://127.0.0.1:12345/baz 196 | {"statusCode":400,"error":"Bad Request","message":"Plain HTTP request to a WebSocket-only route not allowed"} 197 | 198 | # access the combined REST/WebSocket route via WebSocket 199 | $ wscat --connect ws://127.0.0.1:12345/bar 200 | > { "foo": 42 } 201 | < {"at":"bar","mode":"websocket","seen":{"foo":42}} 202 | > { "foo": 7 } 203 | < {"at":"bar","mode":"websocket","seen":{"foo":7}} 204 | 205 | # access the exclusive WebSocket route via WebSocket 206 | $ wscat --connect ws://127.0.0.1:12345/baz 207 | > { "foo": 42 } 208 | < {"at":"baz","seen":{"foo":42}} 209 | > { "foo": 7 } 210 | < {"at":"baz","seen":{"foo":7}} 211 | 212 | # access the full-featured exclusive WebSocket route via WebSockets 213 | $ wscat --subprotocol "quux.example.com" --auth foo:bar --connect ws://127.0.0.1:12345/quux 214 | < {"cmd":"HELLO",arg:"foo"} 215 | > {"cmd":"PING"} 216 | < {"result":"PONG"} 217 | > {"cmd":"AWAKE-ALL"} 218 | < {"cmd":"AWAKE"} 219 | < {"cmd":"PING"} 220 | < {"cmd":"PING"} 221 | < {"cmd":"PING"} 222 | < {"cmd":"PING"} 223 | 224 | # access framed exclusive WebSocket route 225 | $ wscat --connect ws://127.0.0.1:12345/framed 226 | < [ 42, 0, "REQUEST", { "foo": 7 } ] 227 | > [1,42,"RESPONSE",{"at":"framed","seen":{"foo":7}}] 228 | ``` 229 | 230 | Application Programming Interface 231 | --------------------------------- 232 | 233 | - **Import Module**: 234 | 235 | ```js 236 | const HAPIWebSocket = require("hapi-plugin-websocket") 237 | ``` 238 | 239 | - **Register Module in HAPI** (simple variant): 240 | 241 | ```js 242 | await server.register(HAPIWebSocket) 243 | ``` 244 | 245 | - **Register Module in HAPI** (complex variant): 246 | 247 | ```js 248 | server.register({ 249 | plugin: HAPIWebSocket, 250 | options: { 251 | create: (wss) => { 252 | ... 253 | } 254 | } 255 | }) 256 | ``` 257 | 258 | - **Register WebSocket-enabled Route** (simple variant): 259 | 260 | ```js 261 | server.route({ 262 | method: "POST", 263 | path: "/foo", 264 | options: { 265 | plugins: { websocket: true } 266 | }, 267 | handler: async (request, h) => { 268 | ... 269 | } 270 | }) 271 | ``` 272 | 273 | - **Register WebSocket-enabled Route** (complex variant): 274 | 275 | ```js 276 | server.route({ 277 | method: "POST", 278 | path: "/foo", 279 | options: { 280 | plugins: { 281 | websocket: { 282 | only: true, 283 | autoping: 10 * 1000, 284 | subprotocol: "quux.example.com", 285 | initially: true, 286 | connect: ({ ctx, wss, ws, req, peers }) => { 287 | ... 288 | ws.send(...) 289 | ... 290 | }, 291 | disconnect: ({ ctx, wss, ws, req, peers }) => { 292 | ... 293 | } 294 | } 295 | } 296 | }, 297 | handler: async (request, h) => { 298 | let { mode, ctx, wss, ws, req, peers, initially } = request.websocket() 299 | ... 300 | } 301 | }) 302 | ``` 303 | 304 | - **Register WebSocket-enabled Framed Route**: 305 | 306 | ```js 307 | server.route({ 308 | method: "POST", 309 | path: "/foo", 310 | options: { 311 | plugins: { 312 | websocket: { 313 | only: true, 314 | frame: true, 315 | frameEncoding: "json", 316 | frameRequest: "REQUEST", 317 | frameResponse: "RESPONSE" 318 | } 319 | } 320 | }, 321 | handler: async (request, h) => { 322 | let { mode, ctx, wss, ws, wsf, req, peers, initially } = request.websocket() 323 | ... 324 | } 325 | }) 326 | ``` 327 | 328 | Notice 329 | ------ 330 | 331 | With [NES](https://github.com/hapijs/nes) there is a popular and elaborated alternative 332 | HAPI plugin for WebSocket integration. The `hapi-plugin-websocket` 333 | plugin in contrast is a light-weight solution and was developed 334 | with especially six distinct features in mind: 335 | 336 | 1. everything is handled through the regular HAPI route API 337 | (i.e. no additional APIs like `server.subscribe()`), 338 | 339 | 2. one can use HAPI route paths with arbitrary parameters, 340 | 341 | 3. one can restrict a HAPI route to a particular WebSocket subprotocol, 342 | 343 | 4. HTTP replies with status code 204 ("No Content") are explicitly taken 344 | into account (i.e. no WebSocket response message is sent at all in 345 | this case), 346 | 347 | 5. HAPI routes can be controlled to be plain REST, combined REST+WebSocket 348 | or WebSocket-only routes, and 349 | 350 | 6. optionally, WebSocket PING/PONG messages can be exchanged 351 | in an interval to automatically keep the connection alive (e.g. over 352 | stateful firewalls) and to better recognize dead connections (e.g. in 353 | case of network partitions). 354 | 355 | If you want a more elaborate solution, [NES](https://github.com/hapijs/nes) 356 | should be your choice, of course. 357 | 358 | License 359 | ------- 360 | 361 | Copyright (c) 2016-2023 Dr. Ralf S. Engelschall (http://engelschall.com/) 362 | 363 | Permission is hereby granted, free of charge, to any person obtaining 364 | a copy of this software and associated documentation files (the 365 | "Software"), to deal in the Software without restriction, including 366 | without limitation the rights to use, copy, modify, merge, publish, 367 | distribute, sublicense, and/or sell copies of the Software, and to 368 | permit persons to whom the Software is furnished to do so, subject to 369 | the following conditions: 370 | 371 | The above copyright notice and this permission notice shall be included 372 | in all copies or substantial portions of the Software. 373 | 374 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 375 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 376 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 377 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 378 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 379 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 380 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 381 | 382 | -------------------------------------------------------------------------------- /eslint.yaml: -------------------------------------------------------------------------------- 1 | ## 2 | ## hapi-plugin-websocket -- HAPI plugin for seamless WebSocket integration 3 | ## Copyright (c) 2016-2023 Dr. Ralf S. Engelschall 4 | ## 5 | ## Permission is hereby granted, free of charge, to any person obtaining 6 | ## a copy of this software and associated documentation files (the 7 | ## "Software"), to deal in the Software without restriction, including 8 | ## without limitation the rights to use, copy, modify, merge, publish, 9 | ## distribute, sublicense, and/or sell copies of the Software, and to 10 | ## permit persons to whom the Software is furnished to do so, subject to 11 | ## the following conditions: 12 | ## 13 | ## The above copyright notice and this permission notice shall be included 14 | ## in all copies or substantial portions of the Software. 15 | ## 16 | ## THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | ## EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | ## MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | ## IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 20 | ## CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 21 | ## TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 22 | ## SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | ## 24 | 25 | --- 26 | 27 | extends: 28 | - eslint:recommended 29 | - eslint-config-standard 30 | 31 | parserOptions: 32 | ecmaVersion: 12 33 | sourceType: module 34 | ecmaFeatures: 35 | jsx: false 36 | 37 | env: 38 | browser: true 39 | node: false 40 | commonjs: true 41 | worker: true 42 | serviceworker: true 43 | 44 | globals: 45 | process: true 46 | 47 | rules: 48 | # modified rules 49 | indent: [ "error", 4, { "SwitchCase": 1 } ] 50 | linebreak-style: [ "error", "unix" ] 51 | semi: [ "error", "never" ] 52 | operator-linebreak: [ "error", "after", { "overrides": { "&&": "before", "||": "before", ":": "before" } } ] 53 | brace-style: [ "error", "stroustrup", { "allowSingleLine": true } ] 54 | quotes: [ "error", "double" ] 55 | 56 | # disabled rules 57 | no-multi-spaces: off 58 | no-multiple-empty-lines: off 59 | key-spacing: off 60 | object-property-newline: off 61 | curly: off 62 | space-in-parens: off 63 | object-curly-newline: off 64 | object-shorthand: off 65 | 66 | -------------------------------------------------------------------------------- /hapi-plugin-websocket.d.ts: -------------------------------------------------------------------------------- 1 | /* 2 | ** hapi-plugin-websocket -- HAPI plugin for seamless WebSocket integration 3 | ** Copyright (c) 2016-2023 Dr. Ralf S. Engelschall 4 | ** 5 | ** Permission is hereby granted, free of charge, to any person obtaining 6 | ** a copy of this software and associated documentation files (the 7 | ** "Software"), to deal in the Software without restriction, including 8 | ** without limitation the rights to use, copy, modify, merge, publish, 9 | ** distribute, sublicense, and/or sell copies of the Software, and to 10 | ** permit persons to whom the Software is furnished to do so, subject to 11 | ** the following conditions: 12 | ** 13 | ** The above copyright notice and this permission notice shall be included 14 | ** in all copies or substantial portions of the Software. 15 | ** 16 | ** THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | ** EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | ** MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | ** IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 20 | ** CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 21 | ** TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 22 | ** SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | */ 24 | 25 | import { Plugin, Request, ReqRef, ReqRefDefaults } from "@hapi/hapi" 26 | import { Podium } from "@hapi/podium" 27 | import * as ws from "ws" 28 | import * as http from "node:http" 29 | 30 | declare namespace HAPIPluginWebsocket { 31 | interface PluginState { 32 | mode: "websocket" 33 | ctx: Record 34 | wss: ws.Server 35 | ws: ws.WebSocket 36 | wsf: any 37 | req: http.IncomingMessage 38 | peers: ws.WebSocket[] 39 | initially: boolean 40 | } 41 | 42 | interface PluginSpecificConfiguration { 43 | only?: boolean 44 | subprotocol?: string 45 | error?: ( 46 | this: PluginState["ctx"], 47 | pluginState: PluginState, 48 | error: Error 49 | ) => void 50 | connect?: ( 51 | this: PluginState["ctx"], 52 | pluginState: PluginState 53 | ) => void 54 | disconnect?: ( 55 | this: PluginState["ctx"], 56 | pluginState: PluginState 57 | ) => void 58 | request?: ( 59 | ctx: any, 60 | request: any, 61 | h?: any 62 | ) => void 63 | response?: ( 64 | ctx: any, 65 | request: any, 66 | h?: any 67 | ) => void 68 | frame?: boolean 69 | frameEncoding?: "json" | string 70 | frameRequest?: "REQUEST" | string 71 | frameResponse?: "RESPONSE" | string 72 | frameMessage?: ( 73 | this: PluginState["ctx"], 74 | pluginState: PluginState, 75 | frame: any 76 | ) => void 77 | autoping?: number 78 | initially?: boolean 79 | } 80 | 81 | interface OptionalRegistrationOptions { 82 | } 83 | } 84 | 85 | declare const HAPIPluginWebsocket: Plugin 86 | 87 | export = HAPIPluginWebsocket 88 | 89 | declare module "@hapi/hapi" { 90 | export interface Request extends Podium { 91 | websocket(): HAPIPluginWebsocket.PluginState 92 | } 93 | export interface PluginsStates { 94 | websocket: HAPIPluginWebsocket.PluginState 95 | } 96 | export interface PluginSpecificConfiguration { 97 | websocket?: HAPIPluginWebsocket.PluginSpecificConfiguration 98 | } 99 | } 100 | 101 | -------------------------------------------------------------------------------- /hapi-plugin-websocket.js: -------------------------------------------------------------------------------- 1 | /* 2 | ** hapi-plugin-websocket -- HAPI plugin for seamless WebSocket integration 3 | ** Copyright (c) 2016-2023 Dr. Ralf S. Engelschall 4 | ** 5 | ** Permission is hereby granted, free of charge, to any person obtaining 6 | ** a copy of this software and associated documentation files (the 7 | ** "Software"), to deal in the Software without restriction, including 8 | ** without limitation the rights to use, copy, modify, merge, publish, 9 | ** distribute, sublicense, and/or sell copies of the Software, and to 10 | ** permit persons to whom the Software is furnished to do so, subject to 11 | ** the following conditions: 12 | ** 13 | ** The above copyright notice and this permission notice shall be included 14 | ** in all copies or substantial portions of the Software. 15 | ** 16 | ** THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | ** EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | ** MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | ** IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 20 | ** CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 21 | ** TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 22 | ** SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | */ 24 | 25 | /* external dependencies */ 26 | const URI = require("urijs") 27 | const hoek = require("@hapi/hoek") 28 | const Boom = require("@hapi/boom") 29 | const WS = require("ws") 30 | const WSF = require("websocket-framed") 31 | 32 | /* internal dependencies */ 33 | const pkg = require("./package.json") 34 | 35 | /* the HAPI plugin registration function */ 36 | const register = async (server, pluginOptions) => { 37 | /* determine plugin registration options */ 38 | pluginOptions = hoek.applyToDefaults({ 39 | create: function () {} 40 | }, pluginOptions, { nullOverride: true }) 41 | 42 | /* check whether a HAPI route has WebSocket enabled */ 43 | const isRouteWebSocketEnabled = (route) => { 44 | return ( 45 | typeof route === "object" 46 | && typeof route.settings === "object" 47 | && typeof route.settings.plugins === "object" 48 | && typeof route.settings.plugins.websocket !== "undefined" 49 | ) 50 | } 51 | 52 | /* check whether a HAPI request is WebSocket driven */ 53 | const isRequestWebSocketDriven = (request) => { 54 | return ( 55 | typeof request === "object" 56 | && typeof request.plugins === "object" 57 | && typeof request.plugins.websocket === "object" 58 | && request.plugins.websocket.mode === "websocket" 59 | ) 60 | } 61 | 62 | /* determine the route-specific options of WebSocket-enabled route */ 63 | const fetchRouteOptions = (route) => { 64 | let routeOptions = route.settings.plugins.websocket 65 | if (typeof routeOptions !== "object") 66 | routeOptions = {} 67 | routeOptions = hoek.applyToDefaults({ 68 | only: false, 69 | subprotocol: null, 70 | error: function () {}, 71 | connect: function () {}, 72 | disconnect: function () {}, 73 | request: function (ctx, request, h) { return h.continue }, 74 | response: function (ctx, request, h) { return h.continue }, 75 | frame: false, 76 | frameEncoding: "json", 77 | frameRequest: "REQUEST", 78 | frameResponse: "RESPONSE", 79 | frameMessage: function () {}, 80 | autoping: 0, 81 | initially: false 82 | }, routeOptions, { nullOverride: true }) 83 | return routeOptions 84 | } 85 | 86 | /* find a particular route for an HTTP request */ 87 | const findRoute = (req) => { 88 | let route = null 89 | 90 | /* determine request parameters */ 91 | const url = URI.parse(req.url) 92 | const host = typeof req.headers.host === "string" ? req.headers.host : undefined 93 | const path = url.path 94 | const protos = (req.headers["sec-websocket-protocol"] || "").split(/, */) 95 | 96 | /* find a matching route */ 97 | const matched = server.match("POST", path, host) 98 | if (matched) { 99 | /* we accept only WebSocket-enabled ones */ 100 | if (isRouteWebSocketEnabled(matched)) { 101 | /* optionally, we accept only the correct WebSocket subprotocol */ 102 | const routeOptions = fetchRouteOptions(matched) 103 | if (!( routeOptions.subprotocol !== null 104 | && protos.indexOf(routeOptions.subprotocol) === -1)) { 105 | /* take this route */ 106 | route = matched 107 | } 108 | } 109 | } 110 | 111 | return route 112 | } 113 | 114 | /* the global WebSocket server instance */ 115 | let wss = null 116 | 117 | /* per-route timers */ 118 | const routeTimers = {} 119 | 120 | /* perform WebSocket handling on HAPI start */ 121 | server.ext({ type: "onPostStart", method: (server) => { 122 | /* sanity check all HAPI route definitions */ 123 | server.table().forEach((route) => { 124 | /* for all WebSocket-enabled routes... */ 125 | if (isRouteWebSocketEnabled(route)) { 126 | /* make sure it is defined for POST method */ 127 | if (route.method.toUpperCase() !== "POST") 128 | throw new Error("WebSocket protocol can be enabled on POST routes only") 129 | } 130 | }) 131 | 132 | /* establish a WebSocket server and attach it to the 133 | Node HTTP server underlying the HAPI server */ 134 | wss = new WS.Server({ 135 | /* the underlying HTTP server */ 136 | server: server.listener, 137 | 138 | /* disable per-server client tracking, as we have to perform it per-route */ 139 | clientTracking: false, 140 | 141 | /* ensure that incoming WebSocket requests have a corresponding HAPI route */ 142 | verifyClient: ({ req }, result) => { 143 | const route = findRoute(req) 144 | if (route) 145 | result(true) 146 | else 147 | result(false, 404, "No suitable WebSocket-enabled HAPI route found") 148 | } 149 | }) 150 | pluginOptions.create(wss) 151 | 152 | /* per-route peer (aka client) tracking */ 153 | const routePeers = {} 154 | 155 | /* on WebSocket connection (actually HTTP upgrade events)... */ 156 | wss.on("connection", async (ws, req) => { 157 | /* find the (previously already successfully matched) HAPI route */ 158 | const route = findRoute(req) 159 | 160 | /* fetch the per-route options */ 161 | const routeOptions = fetchRouteOptions(route) 162 | 163 | /* determine a route-specific identifier */ 164 | let routeId = `${route.method}:${route.path}` 165 | if (route.vhost) 166 | routeId += `:${route.vhost}` 167 | if (routeOptions.subprotocol !== null) 168 | routeId += `:${routeOptions.subprotocol}` 169 | 170 | /* track the peer per-route */ 171 | if (routePeers[routeId] === undefined) 172 | routePeers[routeId] = [] 173 | const peers = routePeers[routeId] 174 | peers.push(ws) 175 | 176 | /* optionally enable automatic WebSocket PING messages */ 177 | if (routeOptions.autoping > 0) { 178 | /* lazy setup of route-specific interval timer */ 179 | if (routeTimers[routeId] === undefined) { 180 | routeTimers[routeId] = setInterval(() => { 181 | peers.forEach((ws) => { 182 | if (ws.isAlive === false) 183 | ws.terminate() 184 | else { 185 | ws.isAlive = false 186 | if (ws.readyState === WS.OPEN) 187 | ws.ping("", false) 188 | } 189 | }) 190 | }, routeOptions.autoping) 191 | } 192 | 193 | /* mark peer alive initially and on WebSocket PONG messages */ 194 | ws.isAlive = true 195 | ws.on("pong", () => { 196 | ws.isAlive = true 197 | }) 198 | } 199 | 200 | /* optionally create WebSocket-Framed context */ 201 | let wsf = null 202 | if (routeOptions.frame === true) 203 | wsf = new WSF(ws, routeOptions.frameEncoding) 204 | 205 | /* provide a local context */ 206 | const ctx = {} 207 | 208 | /* allow application to hook into WebSocket connection */ 209 | routeOptions.connect.call(ctx, { ctx, wss, ws, wsf, req, peers }) 210 | 211 | /* determine HTTP headers for simulated HTTP request: 212 | take headers of initial HTTP upgrade request, but explicitly remove Accept-Encoding, 213 | because it could lead HAPI to compress the payload (which we cannot post-process) */ 214 | const headers = Object.assign({}, req.headers) 215 | delete headers["accept-encoding"] 216 | 217 | /* optionally inject an empty initial message */ 218 | if (routeOptions.initially) { 219 | /* inject incoming WebSocket message as a simulated HTTP request */ 220 | const response = await server.inject({ 221 | /* simulate the hard-coded POST request */ 222 | method: "POST", 223 | 224 | /* pass-through initial HTTP request information */ 225 | url: req.url, 226 | headers: headers, 227 | remoteAddress: req.socket.remoteAddress, 228 | 229 | /* provide an empty HTTP POST payload */ 230 | payload: null, 231 | 232 | /* provide WebSocket plugin context information */ 233 | plugins: { 234 | websocket: { mode: "websocket", ctx, wss, ws, wsf, req, peers, initially: true } 235 | } 236 | }) 237 | 238 | /* any HTTP redirection, client error or server error response 239 | leads to an immediate WebSocket connection drop */ 240 | if (response.statusCode >= 300) { 241 | const annotation = `(HAPI handler responded with HTTP status ${response.statusCode})` 242 | if (response.statusCode < 400) 243 | ws.close(1002, `Protocol Error ${annotation}`) 244 | else if (response.statusCode < 500) 245 | ws.close(1008, `Policy Violation ${annotation}`) 246 | else 247 | ws.close(1011, `Server Error ${annotation}`) 248 | } 249 | } 250 | 251 | /* hook into WebSocket message retrieval */ 252 | if (routeOptions.frame === true) { 253 | /* framed WebSocket communication (correlated request/reply) */ 254 | wsf.on("message", async (ev) => { 255 | /* allow application to hook into raw WebSocket frame processing */ 256 | routeOptions.frameMessage.call(ctx, { ctx, wss, ws, wsf, req, peers }, ev.frame) 257 | 258 | /* process frame of expected type only */ 259 | if (ev.frame.type === routeOptions.frameRequest) { 260 | /* re-encode data as JSON as HAPI want to decode it */ 261 | const message = JSON.stringify(ev.frame.data) 262 | 263 | /* inject incoming WebSocket message as a simulated HTTP request */ 264 | const response = await server.inject({ 265 | /* simulate the hard-coded POST request */ 266 | method: "POST", 267 | 268 | /* pass-through initial HTTP request information */ 269 | url: req.url, 270 | headers: headers, 271 | remoteAddress: req.socket.remoteAddress, 272 | 273 | /* provide WebSocket message as HTTP POST payload */ 274 | payload: message, 275 | 276 | /* provide WebSocket plugin context information */ 277 | plugins: { 278 | websocket: { mode: "websocket", ctx, wss, ws, wsf, req, peers } 279 | } 280 | }) 281 | 282 | /* transform simulated HTTP response into an outgoing WebSocket message */ 283 | if (response.statusCode !== 204 && ws.readyState === WS.OPEN) { 284 | /* decode data from JSON as HAPI has already encoded it */ 285 | const type = routeOptions.frameResponse 286 | const data = JSON.parse(response.payload) 287 | 288 | /* send as framed data */ 289 | wsf.send({ type, data }, ev.frame) 290 | } 291 | } 292 | }) 293 | } 294 | else { 295 | /* plain WebSocket communication (uncorrelated request/response) */ 296 | ws.on("message", async (message) => { 297 | /* inject incoming WebSocket message as a simulated HTTP request */ 298 | const response = await server.inject({ 299 | /* simulate the hard-coded POST request */ 300 | method: "POST", 301 | 302 | /* pass-through initial HTTP request information */ 303 | url: req.url, 304 | headers: headers, 305 | remoteAddress: req.socket.remoteAddress, 306 | 307 | /* provide WebSocket message as HTTP POST payload */ 308 | payload: message, 309 | 310 | /* provide WebSocket plugin context information */ 311 | plugins: { 312 | websocket: { mode: "websocket", ctx, wss, ws, wsf, req, peers } 313 | } 314 | }) 315 | 316 | /* transform simulated HTTP response into an outgoing WebSocket message */ 317 | if (response.statusCode !== 204 && ws.readyState === WS.OPEN) 318 | ws.send(response.payload) 319 | }) 320 | } 321 | 322 | /* hook into WebSocket disconnection */ 323 | ws.on("close", () => { 324 | /* allow application to hook into WebSocket disconnection */ 325 | routeOptions.disconnect.call(ctx, { ctx, wss, ws, wsf, req, peers }) 326 | 327 | /* stop tracking the peer */ 328 | const idx = routePeers[routeId].indexOf(ws) 329 | routePeers[routeId].splice(idx, 1) 330 | }) 331 | 332 | /* allow application to hook into WebSocket error processing */ 333 | ws.on("error", (error) => { 334 | routeOptions.error.call(ctx, { ctx, wss, ws, wsf, req, peers }, error) 335 | }) 336 | if (routeOptions.frame === true) { 337 | wsf.on("error", (error) => { 338 | routeOptions.error.call(ctx, { ctx, wss, ws, wsf, req, peers }, error) 339 | }) 340 | } 341 | }) 342 | } }) 343 | 344 | /* perform WebSocket handling on HAPI stop */ 345 | server.ext({ type: "onPreStop", method: (server, h) => { 346 | return new Promise((resolve /*, reject */) => { 347 | /* stop all keepalive interval timers */ 348 | for (const routeId of Object.keys(routeTimers)) { 349 | clearInterval(routeTimers[routeId]) 350 | delete routeTimers[routeId] 351 | } 352 | 353 | /* close WebSocket server instance */ 354 | if (wss !== null) { 355 | /* trigger the WebSocket server to close everything */ 356 | wss.close(() => { 357 | /* give WebSocket server's callback a chance to execute 358 | (this indirectly calls our "close" subscription above) */ 359 | setTimeout(() => { 360 | /* continue processing inside HAPI */ 361 | wss = null 362 | resolve() 363 | }, 0) 364 | }) 365 | } 366 | else 367 | resolve() 368 | }) 369 | } }) 370 | 371 | /* make available to HAPI request the remote WebSocket information */ 372 | server.ext({ type: "onRequest", method: (request, h) => { 373 | if (isRequestWebSocketDriven(request)) { 374 | /* RequestInfo's remoteAddress and remotePort use getters and are not 375 | settable, so we have to replace them. */ 376 | Object.defineProperties(request.info, { 377 | remoteAddress: { 378 | value: request.plugins.websocket.req.socket.remoteAddress 379 | }, 380 | remotePort: { 381 | value: request.plugins.websocket.req.socket.remotePort 382 | } 383 | }) 384 | } 385 | return h.continue 386 | } }) 387 | 388 | /* allow WebSocket information to be easily retrieved */ 389 | server.decorate("request", "websocket", (request) => { 390 | return () => { 391 | return (isRequestWebSocketDriven(request) ? 392 | request.plugins.websocket 393 | : { mode: "http", ctx: null, wss: null, ws: null, wsf: null, req: null, peers: null }) 394 | } 395 | }, { apply: true }) 396 | 397 | /* handle WebSocket exclusive routes */ 398 | server.ext({ type: "onPreAuth", method: (request, h) => { 399 | /* if WebSocket is enabled with "only" flag on the selected route... */ 400 | if ( isRouteWebSocketEnabled(request.route) 401 | && request.route.settings.plugins.websocket.only === true) { 402 | /* ...but this is not a WebSocket originated request */ 403 | if (!isRequestWebSocketDriven(request)) 404 | return Boom.badRequest("Plain HTTP request to a WebSocket-only route not allowed") 405 | } 406 | return h.continue 407 | } }) 408 | 409 | /* handle request/response hooks */ 410 | server.ext({ type: "onPostAuth", method: (request, h) => { 411 | if (isRouteWebSocketEnabled(request.route) && isRequestWebSocketDriven(request)) { 412 | const routeOptions = fetchRouteOptions(request.route) 413 | return routeOptions.request.call(request.plugins.websocket.ctx, 414 | request.plugins.websocket, request, h) 415 | } 416 | return h.continue 417 | } }) 418 | server.ext({ type: "onPostHandler", method: (request, h) => { 419 | if (isRouteWebSocketEnabled(request.route) && isRequestWebSocketDriven(request)) { 420 | const routeOptions = fetchRouteOptions(request.route) 421 | return routeOptions.response.call(request.plugins.websocket.ctx, 422 | request.plugins.websocket, request, h) 423 | } 424 | return h.continue 425 | } }) 426 | } 427 | 428 | /* export register function, wrapped in a plugin object */ 429 | module.exports = { 430 | plugin: { 431 | register: register, 432 | pkg: pkg, 433 | once: true 434 | } 435 | } 436 | 437 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hapi-plugin-websocket", 3 | "version": "2.4.11", 4 | "description": "HAPI plugin for seamless WebSocket integration", 5 | "keywords": [ "hapi", "plugin", "websocket" ], 6 | "main": "./hapi-plugin-websocket.js", 7 | "types": "./hapi-plugin-websocket.d.ts", 8 | "license": "MIT", 9 | "repository": { 10 | "type": "git", 11 | "url": "git+https://github.com/rse/hapi-plugin-websocket.git" 12 | }, 13 | "author": { 14 | "name": "Dr. Ralf S. Engelschall", 15 | "email": "rse@engelschall.com", 16 | "url": "http://engelschall.com" 17 | }, 18 | "homepage": "https://github.com/rse/hapi-plugin-websocket", 19 | "bugs": "https://github.com/rse/hapi-plugin-websocket/issues", 20 | "peerDependencies": { 21 | "@hapi/hapi": ">=18.0.0" 22 | }, 23 | "dependencies": { 24 | "urijs": "1.19.11", 25 | "@hapi/hoek": "11.0.7", 26 | "@hapi/boom": "10.0.1", 27 | "@hapi/podium": "5.0.2", 28 | "ws": "8.18.1", 29 | "websocket-framed": "1.2.9", 30 | "@types/node": "22.14.1", 31 | "@types/ws": "8.18.1" 32 | }, 33 | "devDependencies": { 34 | "@hapi/hapi": "21.4.0", 35 | "@hapi/basic": "7.0.2", 36 | "eslint": "8.57.0", 37 | "eslint-config-standard": "17.1.0", 38 | "eslint-plugin-import": "2.31.0", 39 | "eslint-plugin-node": "11.1.0" 40 | }, 41 | "engines": { 42 | "node": ">=12.0.0" 43 | }, 44 | "scripts": { 45 | "prepublishOnly": "eslint --config eslint.yaml hapi-plugin-websocket.js sample-server.js", 46 | "test": "node sample-server.js" 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /sample-server.js: -------------------------------------------------------------------------------- 1 | 2 | const Boom = require("@hapi/boom") 3 | const HAPI = require("@hapi/hapi") 4 | const HAPIAuthBasic = require("@hapi/basic") 5 | const HAPIWebSocket = require("./hapi-plugin-websocket") 6 | const WebSocket = require("ws") 7 | 8 | ;(async () => { 9 | /* create new HAPI service */ 10 | const server = new HAPI.Server({ address: "127.0.0.1", port: 12345 }) 11 | 12 | /* register HAPI plugins */ 13 | await server.register(HAPIWebSocket) 14 | await server.register(HAPIAuthBasic) 15 | 16 | /* register Basic authentication strategy */ 17 | server.auth.strategy("basic", "basic", { 18 | validate: async (request, username, password, h) => { 19 | let isValid = false 20 | let credentials = null 21 | if (username === "foo" && password === "bar") { 22 | isValid = true 23 | credentials = { username } 24 | } 25 | return { isValid, credentials } 26 | } 27 | }) 28 | 29 | /* provide plain REST route */ 30 | server.route({ 31 | method: "POST", path: "/foo", 32 | options: { 33 | payload: { output: "data", parse: true, allow: "application/json" } 34 | }, 35 | handler: (request, h) => { 36 | return { at: "foo", seen: request.payload } 37 | } 38 | }) 39 | 40 | /* provide combined REST/WebSocket route */ 41 | server.route({ 42 | method: "POST", path: "/bar", 43 | options: { 44 | payload: { output: "data", parse: true, allow: "application/json" }, 45 | plugins: { websocket: true } 46 | }, 47 | handler: (request, h) => { 48 | const { mode } = request.websocket() 49 | return { at: "bar", mode: mode, seen: request.payload } 50 | } 51 | }) 52 | 53 | /* provide exclusive WebSocket route */ 54 | server.route({ 55 | method: "POST", path: "/baz", 56 | options: { 57 | plugins: { websocket: { only: true, autoping: 30 * 1000 } } 58 | }, 59 | handler: (request, h) => { 60 | return { at: "baz", seen: request.payload } 61 | } 62 | }) 63 | 64 | /* provide full-featured exclusive WebSocket route */ 65 | server.route({ 66 | method: "POST", path: "/quux", 67 | options: { 68 | response: { emptyStatusCode: 204 }, 69 | payload: { output: "data", parse: true, allow: "application/json" }, 70 | auth: { mode: "required", strategy: "basic" }, 71 | plugins: { 72 | websocket: { 73 | only: true, 74 | initially: true, 75 | subprotocol: "quux.example.com", 76 | connect: ({ ctx, ws }) => { 77 | ctx.to = setInterval(() => { 78 | if (ws.readyState === WebSocket.OPEN) 79 | ws.send(JSON.stringify({ cmd: "PING" })) 80 | }, 5000) 81 | }, 82 | disconnect: ({ ctx }) => { 83 | if (ctx.to !== null) { 84 | clearTimeout(this.ctx) 85 | ctx.to = null 86 | } 87 | } 88 | } 89 | } 90 | }, 91 | handler: (request, h) => { 92 | const { initially, ws } = request.websocket() 93 | if (initially) { 94 | ws.send(JSON.stringify({ cmd: "HELLO", arg: request.auth.credentials.username })) 95 | return "" 96 | } 97 | if (typeof request.payload !== "object" || request.payload === null) 98 | return Boom.badRequest("invalid request") 99 | if (typeof request.payload.cmd !== "string") 100 | return Boom.badRequest("invalid request") 101 | if (request.payload.cmd === "PING") 102 | return { result: "PONG" } 103 | else if (request.payload.cmd === "AWAKE-ALL") { 104 | const peers = request.websocket().peers 105 | peers.forEach((peer) => { 106 | peer.send(JSON.stringify({ cmd: "AWAKE" })) 107 | }) 108 | return "" 109 | } 110 | else 111 | return Boom.badRequest("unknown command") 112 | } 113 | }) 114 | 115 | /* provide exclusive framed WebSocket route */ 116 | server.route({ 117 | method: "POST", path: "/framed", 118 | options: { 119 | plugins: { 120 | websocket: { 121 | only: true, 122 | autoping: 30 * 1000, 123 | frame: true, 124 | frameEncoding: "json", 125 | frameRequest: "REQUEST", 126 | frameResponse: "RESPONSE" 127 | } 128 | } 129 | }, 130 | handler: (request, h) => { 131 | return { at: "framed", seen: request.payload } 132 | } 133 | }) 134 | 135 | /* start the HAPI service */ 136 | await server.start() 137 | })().catch((err) => { 138 | /* eslint no-console: off */ 139 | console.log(`ERROR: ${err}`) 140 | }) 141 | 142 | --------------------------------------------------------------------------------