├── .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 |
--------------------------------------------------------------------------------