├── .npmrc ├── tsconfig.json ├── .gitattributes ├── eslint.config.js ├── .github ├── dependabot.yml ├── workflows │ └── ci.yml └── stale.yml ├── LICENSE ├── package.json ├── .gitignore ├── test ├── inject.test.js ├── hooks.test.js ├── base.test.js └── router.test.js ├── types ├── index.d.ts └── index.test-d.ts ├── index.js └── README.md /.npmrc: -------------------------------------------------------------------------------- 1 | ignore-scripts=true 2 | package-lock=false 3 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "fastify-tsconfig" 3 | } 4 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Set default behavior to automatically convert line endings 2 | * text=auto eol=lf 3 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | module.exports = require('neostandard')({ 4 | ignores: require('neostandard').resolveIgnoresFromGitignore(), 5 | ts: true 6 | }) 7 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: "monthly" 7 | open-pull-requests-limit: 10 8 | 9 | - package-ecosystem: "npm" 10 | directory: "/" 11 | schedule: 12 | interval: "monthly" 13 | open-pull-requests-limit: 10 14 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | - next 8 | - 'v*' 9 | paths-ignore: 10 | - 'docs/**' 11 | - '*.md' 12 | pull_request: 13 | paths-ignore: 14 | - 'docs/**' 15 | - '*.md' 16 | 17 | # This allows a subsequently queued workflow run to interrupt previous runs 18 | concurrency: 19 | group: "${{ github.workflow }}-${{ github.event.pull_request.head.label || github.head_ref || github.ref }}" 20 | cancel-in-progress: true 21 | 22 | permissions: 23 | contents: read 24 | 25 | jobs: 26 | test: 27 | permissions: 28 | contents: write 29 | pull-requests: write 30 | uses: fastify/workflows/.github/workflows/plugins-ci.yml@v5 31 | with: 32 | license-check: true 33 | lint: true 34 | -------------------------------------------------------------------------------- /.github/stale.yml: -------------------------------------------------------------------------------- 1 | # Number of days of inactivity before an issue becomes stale 2 | daysUntilStale: 15 3 | # Number of days of inactivity before a stale issue is closed 4 | daysUntilClose: 7 5 | # Issues with these labels will never be considered stale 6 | exemptLabels: 7 | - "discussion" 8 | - "feature request" 9 | - "bug" 10 | - "help wanted" 11 | - "plugin suggestion" 12 | - "good first issue" 13 | # Label to use when marking an issue as stale 14 | staleLabel: stale 15 | # Comment to post when marking an issue as stale. Set to `false` to disable 16 | markComment: > 17 | This issue has been automatically marked as stale because it has not had 18 | recent activity. It will be closed if no further activity occurs. Thank you 19 | for your contributions. 20 | # Comment to post when closing a stale issue. Set to `false` to disable 21 | closeComment: false 22 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017-present The Fastify team 4 | 5 | The Fastify team members are listed at https://github.com/fastify/fastify#team. 6 | 7 | Permission is hereby granted, free of charge, to any person obtaining a copy 8 | of this software and associated documentation files (the "Software"), to deal 9 | in the Software without restriction, including without limitation the rights 10 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 11 | copies of the Software, and to permit persons to whom the Software is 12 | furnished to do so, subject to the following conditions: 13 | 14 | The above copyright notice and this permission notice shall be included in all 15 | copies or substantial portions of the Software. 16 | 17 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 19 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 20 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 21 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 23 | SOFTWARE. 24 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@fastify/websocket", 3 | "version": "11.2.0", 4 | "description": "basic websocket support for fastify", 5 | "main": "index.js", 6 | "type": "commonjs", 7 | "types": "types/index.d.ts", 8 | "scripts": { 9 | "lint": "eslint", 10 | "lint:fix": "eslint --fix", 11 | "test": "npm run test:unit && npm run test:typescript", 12 | "test:unit": "c8 --100 node --test", 13 | "test:typescript": "tsd" 14 | }, 15 | "repository": { 16 | "type": "git", 17 | "url": "git+https://github.com/fastify/fastify-websocket.git" 18 | }, 19 | "keywords": [ 20 | "fastify", 21 | "websocket" 22 | ], 23 | "author": "Matteo Collina ", 24 | "contributors": [ 25 | { 26 | "name": "Harry Brundage", 27 | "email": "harry.brundage@gmail.com" 28 | }, 29 | { 30 | "name": "Manuel Spigolon", 31 | "email": "behemoth89@gmail.com" 32 | }, 33 | { 34 | "name": "Aras Abbasi", 35 | "email": "aras.abbasi@gmail.com" 36 | }, 37 | { 38 | "name": "Frazer Smith", 39 | "email": "frazer.dev@icloud.com", 40 | "url": "https://github.com/fdawgs" 41 | } 42 | ], 43 | "license": "MIT", 44 | "bugs": { 45 | "url": "https://github.com/fastify/fastify-websocket/issues" 46 | }, 47 | "homepage": "https://github.com/fastify/fastify-websocket#readme", 48 | "funding": [ 49 | { 50 | "type": "github", 51 | "url": "https://github.com/sponsors/fastify" 52 | }, 53 | { 54 | "type": "opencollective", 55 | "url": "https://opencollective.com/fastify" 56 | } 57 | ], 58 | "devDependencies": { 59 | "@fastify/type-provider-typebox": "^5.0.0", 60 | "@types/node": "^24.0.9", 61 | "@types/ws": "^8.5.10", 62 | "c8": "^10.1.3", 63 | "eslint": "^9.17.0", 64 | "fastify": "^5.0.0", 65 | "fastify-tsconfig": "^3.0.0", 66 | "neostandard": "^0.12.0", 67 | "split2": "^4.2.0", 68 | "tsd": "^0.33.0" 69 | }, 70 | "dependencies": { 71 | "duplexify": "^4.1.3", 72 | "fastify-plugin": "^5.0.0", 73 | "ws": "^8.16.0" 74 | }, 75 | "publishConfig": { 76 | "access": "public" 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | .pnpm-debug.log* 9 | 10 | # Diagnostic reports (https://nodejs.org/api/report.html) 11 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 12 | 13 | # Runtime data 14 | pids 15 | *.pid 16 | *.seed 17 | *.pid.lock 18 | 19 | # Directory for instrumented libs generated by jscoverage/JSCover 20 | lib-cov 21 | 22 | # Coverage directory used by tools like istanbul 23 | coverage 24 | *.lcov 25 | 26 | # nyc test coverage 27 | .nyc_output 28 | 29 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 30 | .grunt 31 | 32 | # Bower dependency directory (https://bower.io/) 33 | bower_components 34 | 35 | # node-waf configuration 36 | .lock-wscript 37 | 38 | # Compiled binary addons (https://nodejs.org/api/addons.html) 39 | build/Release 40 | 41 | # Dependency directories 42 | node_modules/ 43 | jspm_packages/ 44 | 45 | # Snowpack dependency directory (https://snowpack.dev/) 46 | web_modules/ 47 | 48 | # TypeScript cache 49 | *.tsbuildinfo 50 | 51 | # Optional npm cache directory 52 | .npm 53 | 54 | # Optional eslint cache 55 | .eslintcache 56 | 57 | # Optional stylelint cache 58 | .stylelintcache 59 | 60 | # Microbundle cache 61 | .rpt2_cache/ 62 | .rts2_cache_cjs/ 63 | .rts2_cache_es/ 64 | .rts2_cache_umd/ 65 | 66 | # Optional REPL history 67 | .node_repl_history 68 | 69 | # Output of 'npm pack' 70 | *.tgz 71 | 72 | # Yarn Integrity file 73 | .yarn-integrity 74 | 75 | # dotenv environment variable files 76 | .env 77 | .env.development.local 78 | .env.test.local 79 | .env.production.local 80 | .env.local 81 | 82 | # parcel-bundler cache (https://parceljs.org/) 83 | .cache 84 | .parcel-cache 85 | 86 | # Next.js build output 87 | .next 88 | out 89 | 90 | # Nuxt.js build / generate output 91 | .nuxt 92 | dist 93 | 94 | # Gatsby files 95 | .cache/ 96 | # Comment in the public line in if your project uses Gatsby and not Next.js 97 | # https://nextjs.org/blog/next-9-1#public-directory-support 98 | # public 99 | 100 | # vuepress build output 101 | .vuepress/dist 102 | 103 | # vuepress v2.x temp and cache directory 104 | .temp 105 | .cache 106 | 107 | # Docusaurus cache and generated files 108 | .docusaurus 109 | 110 | # Serverless directories 111 | .serverless/ 112 | 113 | # FuseBox cache 114 | .fusebox/ 115 | 116 | # DynamoDB Local files 117 | .dynamodb/ 118 | 119 | # TernJS port file 120 | .tern-port 121 | 122 | # Stores VSCode versions used for testing VSCode extensions 123 | .vscode-test 124 | 125 | # yarn v2 126 | .yarn/cache 127 | .yarn/unplugged 128 | .yarn/build-state.yml 129 | .yarn/install-state.gz 130 | .pnp.* 131 | 132 | # Vim swap files 133 | *.swp 134 | 135 | # macOS files 136 | .DS_Store 137 | 138 | # Clinic 139 | .clinic 140 | 141 | # lock files 142 | bun.lockb 143 | package-lock.json 144 | pnpm-lock.yaml 145 | yarn.lock 146 | 147 | # editor files 148 | .vscode 149 | .idea 150 | 151 | #tap files 152 | .tap/ 153 | -------------------------------------------------------------------------------- /test/inject.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const { test } = require('node:test') 4 | const Fastify = require('fastify') 5 | const fastifyWebsocket = require('..') 6 | 7 | function buildFastify (t) { 8 | const fastify = Fastify() 9 | t.after(() => { fastify.close() }) 10 | fastify.register(fastifyWebsocket) 11 | return fastify 12 | } 13 | 14 | test('routes correctly the message', async (t) => { 15 | const fastify = buildFastify(t) 16 | const message = 'hi from client' 17 | 18 | let _resolve 19 | const promise = new Promise((resolve) => { _resolve = resolve }) 20 | 21 | fastify.register( 22 | async function (instance) { 23 | instance.get('/ws', { websocket: true }, function (socket) { 24 | socket.once('message', chunk => { 25 | _resolve(chunk.toString()) 26 | }) 27 | }) 28 | }) 29 | 30 | await fastify.ready() 31 | const ws = await fastify.injectWS('/ws') 32 | ws.send(message) 33 | t.assert.deepStrictEqual(await promise, message) 34 | ws.terminate() 35 | }) 36 | 37 | test('redirect on / if no path specified', async (t) => { 38 | const fastify = buildFastify(t) 39 | const message = 'hi from client' 40 | 41 | let _resolve 42 | const promise = new Promise((resolve) => { _resolve = resolve }) 43 | 44 | fastify.register( 45 | async function (instance) { 46 | instance.get('/', { websocket: true }, function (socket) { 47 | socket.once('message', chunk => { 48 | _resolve(chunk.toString()) 49 | }) 50 | }) 51 | }) 52 | 53 | await fastify.ready() 54 | const ws = await fastify.injectWS() 55 | ws.send(message) 56 | t.assert.deepStrictEqual(await promise, message) 57 | ws.terminate() 58 | }) 59 | 60 | test('routes correctly the message between two routes', async (t) => { 61 | const fastify = buildFastify(t) 62 | const message = 'hi from client' 63 | 64 | let _resolve 65 | let _reject 66 | const promise = new Promise((resolve, reject) => { _resolve = resolve; _reject = reject }) 67 | 68 | fastify.register( 69 | async function (instance) { 70 | instance.get('/ws', { websocket: true }, function (socket) { 71 | socket.once('message', () => { 72 | _reject('wrong-route') 73 | }) 74 | }) 75 | 76 | instance.get('/ws-2', { websocket: true }, function (socket) { 77 | socket.once('message', chunk => { 78 | _resolve(chunk.toString()) 79 | }) 80 | }) 81 | }) 82 | 83 | await fastify.ready() 84 | const ws = await fastify.injectWS('/ws-2') 85 | ws.send(message) 86 | t.assert.deepStrictEqual(await promise, message) 87 | ws.terminate() 88 | }) 89 | 90 | test('use the upgrade context to upgrade if there is some hook', async (t) => { 91 | const fastify = buildFastify(t) 92 | const message = 'hi from client' 93 | 94 | let _resolve 95 | const promise = new Promise((resolve) => { _resolve = resolve }) 96 | 97 | fastify.register( 98 | async function (instance) { 99 | instance.addHook('preValidation', async (request, reply) => { 100 | if (request.headers['api-key'] !== 'some-random-key') { 101 | return reply.code(401).send() 102 | } 103 | }) 104 | 105 | instance.get('/', { websocket: true }, function (socket) { 106 | socket.once('message', chunk => { 107 | _resolve(chunk.toString()) 108 | }) 109 | }) 110 | }) 111 | 112 | await fastify.ready() 113 | const ws = await fastify.injectWS('/', { headers: { 'api-key': 'some-random-key' } }) 114 | ws.send(message) 115 | t.assert.deepStrictEqual(await promise, message) 116 | ws.terminate() 117 | }) 118 | 119 | test('rejects if the websocket is not upgraded', async (t) => { 120 | const fastify = buildFastify(t) 121 | 122 | fastify.register( 123 | async function (instance) { 124 | instance.addHook('preValidation', async (_request, reply) => { 125 | return reply.code(401).send() 126 | }) 127 | 128 | instance.get('/', { websocket: true }, function () { 129 | }) 130 | }) 131 | 132 | await fastify.ready() 133 | await t.assert.rejects(fastify.injectWS('/'), new Error('Unexpected server response: 401')) 134 | }) 135 | 136 | test('inject hooks', async (t) => { 137 | const fastify = buildFastify(t) 138 | const message = 'hi from client' 139 | 140 | let _resolve 141 | const promise = new Promise((resolve) => { _resolve = resolve }) 142 | 143 | fastify.register( 144 | async function (instance) { 145 | instance.get('/ws', { websocket: true }, function (socket) { 146 | socket.once('message', chunk => { 147 | _resolve(chunk.toString()) 148 | }) 149 | }) 150 | }) 151 | 152 | await fastify.ready() 153 | 154 | let order = 0 155 | let initWS, openWS 156 | const ws = await fastify.injectWS('/ws', {}, { 157 | onInit (ws) { 158 | t.assert.strictEqual(order, 0) 159 | order++ 160 | initWS = ws 161 | }, 162 | onOpen (ws) { 163 | t.assert.strictEqual(order, 1) 164 | order++ 165 | openWS = ws 166 | } 167 | }) 168 | ws.send(message) 169 | t.assert.strictEqual(order, 2) 170 | t.assert.deepStrictEqual(ws, initWS) 171 | t.assert.deepStrictEqual(ws, openWS) 172 | t.assert.deepStrictEqual(await promise, message) 173 | ws.terminate() 174 | }) 175 | -------------------------------------------------------------------------------- /types/index.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | import * as fastify from 'fastify' 3 | import { ContextConfigDefault, FastifyBaseLogger, FastifyInstance, FastifyPluginCallback, FastifyRequest, FastifySchema, FastifyTypeProvider, FastifyTypeProviderDefault, RawReplyDefaultExpression, RawRequestDefaultExpression, RawServerBase, RawServerDefault, RequestGenericInterface } from 'fastify' 4 | import { preCloseAsyncHookHandler, preCloseHookHandler } from 'fastify/types/hooks' 5 | import { FastifyReply } from 'fastify/types/reply' 6 | import { RouteGenericInterface } from 'fastify/types/route' 7 | import { IncomingMessage, Server, ServerResponse } from 'node:http' 8 | import * as WebSocket from 'ws' 9 | 10 | interface WebsocketRouteOptions< 11 | RawServer extends RawServerBase = RawServerDefault, 12 | RawRequest extends RawRequestDefaultExpression = RawRequestDefaultExpression, 13 | RequestGeneric extends RequestGenericInterface = RequestGenericInterface, 14 | ContextConfig = ContextConfigDefault, 15 | SchemaCompiler extends FastifySchema = FastifySchema, 16 | TypeProvider extends FastifyTypeProvider = FastifyTypeProviderDefault, 17 | Logger extends FastifyBaseLogger = FastifyBaseLogger 18 | > { 19 | wsHandler?: fastifyWebsocket.WebsocketHandler; 20 | } 21 | 22 | declare module 'fastify' { 23 | interface RouteShorthandOptions< 24 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 25 | RawServer extends RawServerBase = RawServerDefault 26 | > { 27 | websocket?: boolean; 28 | } 29 | 30 | interface InjectWSOption { 31 | onInit?: (ws: WebSocket.WebSocket) => void 32 | onOpen?: (ws: WebSocket.WebSocket) => void 33 | } 34 | 35 | type InjectWSFn = 36 | ((path?: string, upgradeContext?: Partial, options?: InjectWSOption) => Promise) 37 | 38 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 39 | interface FastifyInstance { 40 | websocketServer: WebSocket.Server, 41 | injectWS: InjectWSFn 42 | } 43 | 44 | interface FastifyRequest { 45 | ws: boolean 46 | } 47 | 48 | interface RouteShorthandMethod< 49 | RawServer extends RawServerBase = RawServerDefault, 50 | RawRequest extends RawRequestDefaultExpression = RawRequestDefaultExpression, 51 | RawReply extends RawReplyDefaultExpression = RawReplyDefaultExpression, 52 | TypeProvider extends FastifyTypeProvider = FastifyTypeProviderDefault, 53 | Logger extends FastifyBaseLogger = FastifyBaseLogger 54 | > { 55 | ( 56 | path: string, 57 | opts: RouteShorthandOptions & { websocket: true }, // this creates an overload that only applies these different types if the handler is for websockets 58 | handler?: fastifyWebsocket.WebsocketHandler 59 | ): FastifyInstance; 60 | } 61 | 62 | interface RouteOptions< 63 | RawServer extends RawServerBase = RawServerDefault, 64 | RawRequest extends RawRequestDefaultExpression = RawRequestDefaultExpression, 65 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 66 | RawReply extends RawReplyDefaultExpression = RawReplyDefaultExpression, 67 | RouteGeneric extends RouteGenericInterface = RouteGenericInterface, 68 | ContextConfig = ContextConfigDefault, 69 | SchemaCompiler = fastify.FastifySchema, 70 | TypeProvider extends FastifyTypeProvider = FastifyTypeProviderDefault, 71 | Logger extends FastifyBaseLogger = FastifyBaseLogger 72 | > extends WebsocketRouteOptions { } 73 | } 74 | 75 | type FastifyWebsocket = FastifyPluginCallback 76 | 77 | declare namespace fastifyWebsocket { 78 | 79 | interface WebSocketServerOptions extends Omit { } 80 | export type WebsocketHandler< 81 | RawServer extends RawServerBase = RawServerDefault, 82 | RawRequest extends RawRequestDefaultExpression = RawRequestDefaultExpression, 83 | RequestGeneric extends RequestGenericInterface = RequestGenericInterface, 84 | ContextConfig = ContextConfigDefault, 85 | SchemaCompiler extends FastifySchema = FastifySchema, 86 | TypeProvider extends FastifyTypeProvider = FastifyTypeProviderDefault, 87 | Logger extends FastifyBaseLogger = FastifyBaseLogger 88 | > = ( 89 | this: FastifyInstance, 90 | socket: WebSocket.WebSocket, 91 | request: FastifyRequest 92 | ) => void | Promise 93 | export interface WebsocketPluginOptions { 94 | errorHandler?: (this: FastifyInstance, error: Error, socket: WebSocket.WebSocket, request: FastifyRequest, reply: FastifyReply) => void; 95 | options?: WebSocketServerOptions; 96 | preClose?: preCloseHookHandler | preCloseAsyncHookHandler; 97 | } 98 | export interface RouteOptions< 99 | RawServer extends RawServerBase = RawServerDefault, 100 | RawRequest extends RawRequestDefaultExpression = RawRequestDefaultExpression, 101 | RawReply extends RawReplyDefaultExpression = RawReplyDefaultExpression, 102 | RouteGeneric extends RouteGenericInterface = RouteGenericInterface, 103 | ContextConfig = ContextConfigDefault, 104 | SchemaCompiler extends fastify.FastifySchema = fastify.FastifySchema, 105 | TypeProvider extends FastifyTypeProvider = FastifyTypeProviderDefault, 106 | Logger extends FastifyBaseLogger = FastifyBaseLogger 107 | > extends fastify.RouteOptions, WebsocketRouteOptions { } 108 | 109 | export type WebSocket = WebSocket.WebSocket 110 | 111 | export const fastifyWebsocket: FastifyWebsocket 112 | export { fastifyWebsocket as default } 113 | } 114 | 115 | declare function fastifyWebsocket (...params: Parameters): ReturnType 116 | export = fastifyWebsocket 117 | -------------------------------------------------------------------------------- /types/index.test-d.ts: -------------------------------------------------------------------------------- 1 | import { TypeBoxTypeProvider } from '@fastify/type-provider-typebox' 2 | import { Type } from '@sinclair/typebox' 3 | import fastify, { FastifyBaseLogger, FastifyInstance, FastifyReply, FastifyRequest, FastifySchema, RawRequestDefaultExpression, RawServerDefault, RequestGenericInterface, RouteOptions } from 'fastify' 4 | import { RouteGenericInterface } from 'fastify/types/route' 5 | import type { IncomingMessage } from 'node:http' 6 | import { expectType } from 'tsd' 7 | import { Server } from 'ws' 8 | // eslint-disable-next-line import-x/no-named-default -- Test default export 9 | import fastifyWebsocket, { default as defaultFastifyWebsocket, fastifyWebsocket as namedFastifyWebsocket, WebSocket, WebsocketHandler } from '..' 10 | 11 | const app: FastifyInstance = fastify() 12 | app.register(fastifyWebsocket) 13 | app.register(fastifyWebsocket, {}) 14 | app.register(fastifyWebsocket, { options: { maxPayload: 123 } }) 15 | app.register(fastifyWebsocket, { 16 | errorHandler: function errorHandler (error: Error, socket: WebSocket, request: FastifyRequest, reply: FastifyReply): void { 17 | expectType(this) 18 | expectType(error) 19 | expectType(socket) 20 | expectType(request) 21 | expectType(reply) 22 | } 23 | }) 24 | app.register(fastifyWebsocket, { options: { perMessageDeflate: true } }) 25 | app.register(fastifyWebsocket, { preClose: function syncPreclose () { } }) 26 | app.register(fastifyWebsocket, { preClose: async function asyncPreclose () { } }) 27 | 28 | app.get('/websockets-via-inferrence', { websocket: true }, async function (socket, request) { 29 | expectType(this) 30 | expectType(socket) 31 | expectType(app.websocketServer) 32 | expectType>(request) 33 | expectType(request.ws) 34 | expectType(request.log) 35 | }) 36 | 37 | const handler: WebsocketHandler = async (socket, request) => { 38 | expectType(socket) 39 | expectType(app.websocketServer) 40 | expectType>(request) 41 | } 42 | 43 | app.get('/websockets-via-annotated-const', { websocket: true }, handler) 44 | 45 | app.get('/not-specifed', async (request, reply) => { 46 | expectType(request) 47 | expectType(reply) 48 | expectType(request.ws) 49 | }) 50 | 51 | app.get('/not-websockets', { websocket: false }, async (request, reply) => { 52 | expectType(request) 53 | expectType(reply) 54 | }) 55 | 56 | app.route({ 57 | method: 'GET', 58 | url: '/route-full-declaration-syntax', 59 | handler: (request, reply) => { 60 | expectType(request) 61 | expectType(reply) 62 | expectType(request.ws) 63 | }, 64 | wsHandler: (socket, request) => { 65 | expectType(socket) 66 | expectType>(request) 67 | expectType(request.ws) 68 | }, 69 | }) 70 | 71 | const augmentedRouteOptions: RouteOptions = { 72 | method: 'GET', 73 | url: '/route-with-exported-augmented-route-options', 74 | handler: (request, reply) => { 75 | expectType(request) 76 | expectType(reply) 77 | }, 78 | wsHandler: (socket, request) => { 79 | expectType(socket) 80 | expectType>(request) 81 | }, 82 | } 83 | app.route(augmentedRouteOptions) 84 | 85 | app.get<{ Params: { foo: string }, Body: { bar: string }, Querystring: { search: string }, Headers: { auth: string } }>('/shorthand-explicit-types', { 86 | websocket: true 87 | }, async (socket, request) => { 88 | expectType(socket) 89 | expectType<{ foo: string }>(request.params) 90 | expectType<{ bar: string }>(request.body) 91 | expectType<{ search: string }>(request.query) 92 | expectType(request.headers) 93 | }) 94 | 95 | app.route<{ Params: { foo: string }, Body: { bar: string }, Querystring: { search: string }, Headers: { auth: string } }>({ 96 | method: 'GET', 97 | url: '/longhand-explicit-types', 98 | handler: (request, _reply) => { 99 | expectType<{ foo: string }>(request.params) 100 | expectType<{ bar: string }>(request.body) 101 | expectType<{ search: string }>(request.query) 102 | expectType(request.headers) 103 | }, 104 | wsHandler: (socket, request) => { 105 | expectType(socket) 106 | expectType<{ foo: string }>(request.params) 107 | expectType<{ bar: string }>(request.body) 108 | expectType<{ search: string }>(request.query) 109 | expectType(request.headers) 110 | }, 111 | }) 112 | 113 | const schema = { 114 | params: Type.Object({ 115 | foo: Type.String() 116 | }), 117 | querystring: Type.Object({ 118 | search: Type.String() 119 | }), 120 | body: Type.Object({ 121 | bar: Type.String() 122 | }), 123 | headers: Type.Object({ 124 | auth: Type.String() 125 | }) 126 | } 127 | 128 | const server = app.withTypeProvider() 129 | 130 | server.route({ 131 | method: 'GET', 132 | url: '/longhand-type-inference', 133 | schema, 134 | handler: (request, _reply) => { 135 | expectType<{ foo: string }>(request.params) 136 | expectType<{ bar: string }>(request.body) 137 | expectType<{ search: string }>(request.query) 138 | expectType(request.headers) 139 | }, 140 | wsHandler: (socket, request) => { 141 | expectType(socket) 142 | expectType<{ foo: string }>(request.params) 143 | expectType<{ bar: string }>(request.body) 144 | expectType<{ search: string }>(request.query) 145 | expectType(request.headers) 146 | }, 147 | }) 148 | 149 | server.get('/websockets-no-type-inference', 150 | { websocket: true }, 151 | async function (socket, request) { 152 | expectType(this) 153 | expectType(socket) 154 | expectType(app.websocketServer) 155 | expectType>(request) 156 | expectType(request.ws) 157 | expectType(request.params) 158 | expectType(request.body) 159 | expectType(request.query) 160 | expectType(request.headers) 161 | }) 162 | 163 | expectType(namedFastifyWebsocket) 164 | expectType(defaultFastifyWebsocket) 165 | 166 | app.injectWS('/', {}, {}) 167 | app.injectWS('/', {}, { 168 | onInit (ws) { 169 | expectType(ws) 170 | }, 171 | }) 172 | app.injectWS('/', {}, { 173 | onOpen (ws) { 174 | expectType(ws) 175 | }, 176 | }) 177 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const { ServerResponse } = require('node:http') 4 | const { PassThrough } = require('node:stream') 5 | const { randomBytes } = require('node:crypto') 6 | const fp = require('fastify-plugin') 7 | const WebSocket = require('ws') 8 | const Duplexify = require('duplexify') 9 | 10 | const kWs = Symbol('ws-socket') 11 | const kWsHead = Symbol('ws-head') 12 | const statusCodeReg = /HTTP\/1.1 (\d+)/u 13 | 14 | function fastifyWebsocket (fastify, opts, next) { 15 | fastify.decorateRequest('ws', null) 16 | 17 | let errorHandler = defaultErrorHandler 18 | if (opts.errorHandler) { 19 | if (typeof opts.errorHandler !== 'function') { 20 | return next(new Error('invalid errorHandler function')) 21 | } 22 | 23 | errorHandler = opts.errorHandler 24 | } 25 | 26 | let preClose = defaultPreClose 27 | if (opts?.preClose) { 28 | if (typeof opts.preClose !== 'function') { 29 | return next(new Error('invalid preClose function')) 30 | } 31 | 32 | preClose = opts.preClose 33 | } 34 | 35 | if (opts.options?.noServer) { 36 | return next(new Error("fastify-websocket doesn't support the ws noServer option. If you want to create a websocket server detatched from fastify, use the ws library directly.")) 37 | } 38 | 39 | const wssOptions = Object.assign({ noServer: true }, opts.options) 40 | 41 | if (wssOptions.path) { 42 | fastify.log.warn('ws server path option shouldn\'t be provided, use a route instead') 43 | } 44 | 45 | // We always handle upgrading ourselves in this library so that we can dispatch through the fastify stack before actually upgrading 46 | // For this reason, we run the WebSocket.Server in noServer mode, and prevent the user from passing in a http.Server instance for it to attach to. 47 | // Usually, we listen to the upgrade event of the `fastify.server`, but we do still support this server option by just listening to upgrades on it if passed. 48 | const websocketListenServer = wssOptions.server || fastify.server 49 | delete wssOptions.server 50 | 51 | const wss = new WebSocket.Server(wssOptions) 52 | fastify.decorate('websocketServer', wss) 53 | 54 | // TODO: place upgrade context as options 55 | async function injectWS (path = '/', upgradeContext = {}, options = {}) { 56 | const server2Client = new PassThrough() 57 | const client2Server = new PassThrough() 58 | 59 | const serverStream = new Duplexify(server2Client, client2Server) 60 | const clientStream = new Duplexify(client2Server, server2Client) 61 | 62 | const ws = new WebSocket(null, undefined, { isServer: false }) 63 | const head = Buffer.from([]) 64 | 65 | let resolve, reject 66 | const promise = new Promise((_resolve, _reject) => { resolve = _resolve; reject = _reject }) 67 | 68 | typeof options.onInit === 'function' && options.onInit(ws) 69 | 70 | ws.on('open', () => { 71 | typeof options.onOpen === 'function' && options.onOpen(ws) 72 | clientStream.removeListener('data', onData) 73 | resolve(ws) 74 | }) 75 | 76 | const onData = (chunk) => { 77 | if (chunk.toString().includes('HTTP/1.1 101 Switching Protocols')) { 78 | ws._isServer = false 79 | ws.setSocket(clientStream, head, { maxPayload: 0 }) 80 | } else { 81 | clientStream.removeListener('data', onData) 82 | const statusCode = Number(statusCodeReg.exec(chunk.toString())[1]) 83 | reject(new Error('Unexpected server response: ' + statusCode)) 84 | } 85 | } 86 | 87 | clientStream.on('data', onData) 88 | 89 | const req = { 90 | ...upgradeContext, 91 | method: 'GET', 92 | headers: { 93 | ...upgradeContext.headers, 94 | connection: 'upgrade', 95 | upgrade: 'websocket', 96 | 'sec-websocket-version': 13, 97 | 'sec-websocket-key': randomBytes(16).toString('base64') 98 | }, 99 | httpVersion: '1.1', 100 | url: path, 101 | [kWs]: serverStream, 102 | [kWsHead]: head 103 | } 104 | 105 | websocketListenServer.emit('upgrade', req, req[kWs], req[kWsHead]) 106 | 107 | return promise 108 | } 109 | 110 | fastify.decorate('injectWS', injectWS) 111 | 112 | function onUpgrade (rawRequest, socket, head) { 113 | // Save a reference to the socket and then dispatch the request through the normal fastify router so that it will invoke hooks and then eventually a route handler that might upgrade the socket. 114 | rawRequest[kWs] = socket 115 | rawRequest[kWsHead] = head 116 | const rawResponse = new ServerResponse(rawRequest) 117 | try { 118 | rawResponse.assignSocket(socket) 119 | fastify.routing(rawRequest, rawResponse) 120 | } catch (err) { 121 | fastify.log.warn({ err }, 'websocket upgrade failed') 122 | } 123 | } 124 | websocketListenServer.on('upgrade', onUpgrade) 125 | 126 | const handleUpgrade = (rawRequest, callback) => { 127 | wss.handleUpgrade(rawRequest, rawRequest[kWs], rawRequest[kWsHead], (socket) => { 128 | wss.emit('connection', socket, rawRequest) 129 | 130 | socket.on('error', (error) => { 131 | fastify.log.error(error) 132 | }) 133 | 134 | callback(socket) 135 | }) 136 | } 137 | 138 | fastify.addHook('onRequest', (request, _reply, done) => { // this adds req.ws to the Request object 139 | if (request.raw[kWs]) { 140 | request.ws = true 141 | } else { 142 | request.ws = false 143 | } 144 | done() 145 | }) 146 | 147 | fastify.addHook('onResponse', (request, _reply, done) => { 148 | if (request.ws) { 149 | request.raw[kWs].destroy() 150 | } 151 | done() 152 | }) 153 | 154 | fastify.addHook('onRoute', routeOptions => { 155 | let isWebsocketRoute = false 156 | let wsHandler = routeOptions.wsHandler 157 | let handler = routeOptions.handler 158 | 159 | if (routeOptions.websocket || routeOptions.wsHandler) { 160 | if (routeOptions.method === 'HEAD') { 161 | return 162 | } else if (routeOptions.method !== 'GET') { 163 | throw new Error('websocket handler can only be declared in GET method') 164 | } 165 | 166 | isWebsocketRoute = true 167 | 168 | if (routeOptions.websocket) { 169 | if (!routeOptions.schema) { 170 | routeOptions.schema = {} 171 | } 172 | routeOptions.schema.hide = true 173 | 174 | wsHandler = routeOptions.handler 175 | handler = function (_, reply) { 176 | reply.code(404).send() 177 | } 178 | } 179 | 180 | if (typeof wsHandler !== 'function') { 181 | throw new TypeError('invalid wsHandler function') 182 | } 183 | } 184 | 185 | // we always override the route handler so we can close websocket connections to routes to handlers that don't support websocket connections 186 | // This is not an arrow function to fetch the encapsulated this 187 | routeOptions.handler = function (request, reply) { 188 | // within the route handler, we check if there has been a connection upgrade by looking at request.raw[kWs]. we need to dispatch the normal HTTP handler if not, and hijack to dispatch the websocket handler if so 189 | if (request.raw[kWs]) { 190 | reply.hijack() 191 | handleUpgrade(request.raw, socket => { 192 | let result 193 | try { 194 | if (isWebsocketRoute) { 195 | result = wsHandler.call(this, socket, request) 196 | } else { 197 | result = noHandle.call(this, socket, request) 198 | } 199 | } catch (err) { 200 | return errorHandler.call(this, err, socket, request, reply) 201 | } 202 | 203 | if (result && typeof result.catch === 'function') { 204 | result.catch(err => errorHandler.call(this, err, socket, request, reply)) 205 | } 206 | }) 207 | } else { 208 | return handler.call(this, request, reply) 209 | } 210 | } 211 | }) 212 | 213 | fastify.addHook('preClose', preClose) 214 | 215 | function defaultPreClose (done) { 216 | const server = this.websocketServer 217 | if (server.clients) { 218 | for (const client of server.clients) { 219 | client.close() 220 | } 221 | } 222 | 223 | fastify.server.removeListener('upgrade', onUpgrade) 224 | 225 | server.close(done) 226 | 227 | done() 228 | } 229 | 230 | function noHandle (socket, rawRequest) { 231 | this.log.info({ path: rawRequest.url }, 'closed incoming websocket connection for path with no websocket handler') 232 | socket.close() 233 | } 234 | 235 | function defaultErrorHandler (error, socket, request) { 236 | request.log.error(error) 237 | socket.terminate() 238 | } 239 | 240 | next() 241 | } 242 | 243 | module.exports = fp(fastifyWebsocket, { 244 | fastify: '5.x', 245 | name: '@fastify/websocket' 246 | }) 247 | module.exports.default = fastifyWebsocket 248 | module.exports.fastifyWebsocket = fastifyWebsocket 249 | -------------------------------------------------------------------------------- /test/hooks.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const { test } = require('node:test') 4 | const net = require('node:net') 5 | const Fastify = require('fastify') 6 | const fastifyWebsocket = require('..') 7 | const WebSocket = require('ws') 8 | const split = require('split2') 9 | 10 | test('Should run onRequest, preValidation, preHandler hooks', (t, end) => { 11 | t.plan(8) 12 | const fastify = Fastify() 13 | 14 | t.after(() => fastify.close()) 15 | 16 | fastify.register(fastifyWebsocket) 17 | 18 | fastify.register(async function (fastify) { 19 | fastify.addHook('onRequest', async ({ routeOptions: { schema: { hide } } }) => { 20 | t.assert.ok('called', 'onRequest') 21 | t.assert.strictEqual(hide, true, 'schema hide property should be set to true when route option is websocket') 22 | }) 23 | fastify.addHook('preParsing', async () => t.assert.ok('called', 'preParsing')) 24 | fastify.addHook('preValidation', async () => t.assert.ok('called', 'preValidation')) 25 | fastify.addHook('preHandler', async () => t.assert.ok('called', 'preHandler')) 26 | 27 | fastify.get('/echo', { websocket: true }, (socket) => { 28 | socket.send('hello client') 29 | t.after(() => socket.terminate()) 30 | 31 | socket.once('message', (chunk) => { 32 | t.assert.deepStrictEqual(chunk.toString(), 'hello server') 33 | end() 34 | }) 35 | }) 36 | }) 37 | 38 | fastify.listen({ port: 0 }, err => { 39 | t.assert.ifError(err) 40 | const ws = new WebSocket('ws://localhost:' + (fastify.server.address()).port + '/echo') 41 | const client = WebSocket.createWebSocketStream(ws, { encoding: 'utf8' }) 42 | t.after(() => client.destroy()) 43 | 44 | client.setEncoding('utf8') 45 | client.write('hello server') 46 | 47 | client.once('data', chunk => { 48 | t.assert.deepStrictEqual(chunk, 'hello client') 49 | client.end() 50 | }) 51 | }) 52 | }) 53 | 54 | test('Should not run onTimeout hook', (t, end) => { 55 | t.plan(2) 56 | const fastify = Fastify() 57 | 58 | t.after(() => fastify.close()) 59 | 60 | fastify.register(fastifyWebsocket) 61 | 62 | fastify.register(async function () { 63 | fastify.addHook('onTimeout', async () => t.assert.fail('called', 'onTimeout')) 64 | 65 | fastify.get('/echo', { websocket: true }, (socket, request) => { 66 | socket.send('hello client') 67 | request.raw.setTimeout(50) 68 | t.after(() => socket.terminate()) 69 | }) 70 | }) 71 | 72 | fastify.listen({ port: 0 }, err => { 73 | t.assert.ifError(err) 74 | const ws = new WebSocket('ws://localhost:' + (fastify.server.address()).port + '/echo') 75 | const client = WebSocket.createWebSocketStream(ws, { encoding: 'utf8' }) 76 | t.after(() => client.destroy()) 77 | 78 | client.once('data', chunk => { 79 | t.assert.deepStrictEqual(chunk, 'hello client') 80 | end() 81 | }) 82 | }) 83 | }) 84 | 85 | test('Should run onError hook before handler is executed (error thrown in onRequest hook)', (t, end) => { 86 | t.plan(3) 87 | const fastify = Fastify() 88 | 89 | t.after(() => fastify.close()) 90 | 91 | fastify.register(fastifyWebsocket) 92 | 93 | fastify.register(async function (fastify) { 94 | fastify.addHook('onRequest', async () => { throw new Error('Fail') }) 95 | fastify.addHook('onError', async () => t.assert.ok('called', 'onError')) 96 | 97 | fastify.get('/echo', { websocket: true }, () => { 98 | t.assert.fail() 99 | }) 100 | }) 101 | 102 | fastify.listen({ port: 0 }, function (err) { 103 | t.assert.ifError(err) 104 | const ws = new WebSocket('ws://localhost:' + (fastify.server.address()).port + '/echo') 105 | ws.on('unexpected-response', (_request, response) => { 106 | t.assert.deepStrictEqual(response.statusCode, 500) 107 | end() 108 | }) 109 | }) 110 | }) 111 | 112 | test('Should run onError hook before handler is executed (error thrown in preValidation hook)', (t, end) => { 113 | t.plan(3) 114 | const fastify = Fastify() 115 | 116 | t.after(() => fastify.close()) 117 | 118 | fastify.register(fastifyWebsocket) 119 | 120 | fastify.register(async function (fastify) { 121 | fastify.addHook('preValidation', async () => { 122 | await Promise.resolve() 123 | throw new Error('Fail') 124 | }) 125 | 126 | fastify.addHook('onError', async () => t.assert.ok('called', 'onError')) 127 | 128 | fastify.get('/echo', { websocket: true }, () => { 129 | t.assert.fail() 130 | }) 131 | }) 132 | 133 | fastify.listen({ port: 0 }, function (err) { 134 | t.assert.ifError(err) 135 | const ws = new WebSocket('ws://localhost:' + (fastify.server.address()).port + '/echo') 136 | ws.on('unexpected-response', (_request, response) => { 137 | t.assert.deepStrictEqual(response.statusCode, 500) 138 | end() 139 | }) 140 | }) 141 | }) 142 | 143 | test('onError hooks can send a reply and prevent hijacking', (t, end) => { 144 | t.plan(3) 145 | const fastify = Fastify() 146 | 147 | t.after(() => fastify.close()) 148 | 149 | fastify.register(fastifyWebsocket) 150 | 151 | fastify.register(async function (fastify) { 152 | fastify.addHook('preValidation', async () => { 153 | await Promise.resolve() 154 | throw new Error('Fail') 155 | }) 156 | 157 | fastify.addHook('onError', async (_request, reply) => { 158 | t.assert.ok('called', 'onError') 159 | await reply.code(501).send('there was an error') 160 | }) 161 | 162 | fastify.get('/echo', { websocket: true }, () => { 163 | t.assert.fail() 164 | }) 165 | }) 166 | 167 | fastify.listen({ port: 0 }, function (err) { 168 | t.assert.ifError(err) 169 | const ws = new WebSocket('ws://localhost:' + (fastify.server.address()).port + '/echo') 170 | ws.on('unexpected-response', (_request, response) => { 171 | t.assert.deepStrictEqual(response.statusCode, 501) 172 | end() 173 | }) 174 | }) 175 | }) 176 | 177 | test('setErrorHandler functions can send a reply and prevent hijacking', (t, end) => { 178 | t.plan(4) 179 | const fastify = Fastify() 180 | 181 | t.after(() => fastify.close()) 182 | 183 | fastify.register(fastifyWebsocket) 184 | 185 | fastify.register(async function (fastify) { 186 | fastify.addHook('preValidation', async () => { 187 | await Promise.resolve() 188 | throw new Error('Fail') 189 | }) 190 | 191 | fastify.setErrorHandler(async (error, _request, reply) => { 192 | t.assert.ok('called', 'onError') 193 | t.assert.ok(error) 194 | await reply.code(501).send('there was an error') 195 | }) 196 | 197 | fastify.get('/echo', { websocket: true }, () => { 198 | t.assert.fail() 199 | }) 200 | }) 201 | 202 | fastify.listen({ port: 0 }, function (err) { 203 | t.assert.ifError(err) 204 | const ws = new WebSocket('ws://localhost:' + (fastify.server.address()).port + '/echo') 205 | ws.on('unexpected-response', (_request, response) => { 206 | t.assert.deepStrictEqual(response.statusCode, 501) 207 | end() 208 | }) 209 | }) 210 | }) 211 | 212 | test('Should not run onError hook if reply was already hijacked (error thrown in websocket handler)', (t, end) => { 213 | t.plan(2) 214 | const fastify = Fastify() 215 | 216 | t.after(() => fastify.close()) 217 | 218 | fastify.register(fastifyWebsocket) 219 | 220 | fastify.register(async function (fastify) { 221 | fastify.addHook('onError', async () => t.assert.fail('called', 'onError')) 222 | 223 | fastify.get('/echo', { websocket: true }, async (socket) => { 224 | t.after(() => socket.terminate()) 225 | throw new Error('Fail') 226 | }) 227 | }) 228 | 229 | fastify.listen({ port: 0 }, function (err) { 230 | t.assert.ifError(err) 231 | const ws = new WebSocket('ws://localhost:' + (fastify.server.address()).port + '/echo') 232 | const client = WebSocket.createWebSocketStream(ws, { encoding: 'utf8' }) 233 | t.after(() => client.destroy()) 234 | ws.on('close', code => { 235 | t.assert.deepStrictEqual(code, 1006) 236 | end() 237 | }) 238 | }) 239 | }) 240 | 241 | test('Should not run preSerialization/onSend hooks', (t, end) => { 242 | t.plan(2) 243 | const fastify = Fastify() 244 | 245 | t.after(() => fastify.close()) 246 | 247 | fastify.register(fastifyWebsocket) 248 | 249 | fastify.register(async function (fastify) { 250 | fastify.addHook('onSend', async () => t.assert.fail('called', 'onSend')) 251 | fastify.addHook('preSerialization', async () => t.assert.fail('called', 'preSerialization')) 252 | 253 | fastify.get('/echo', { websocket: true }, async (socket) => { 254 | socket.send('hello client') 255 | t.after(() => socket.terminate()) 256 | }) 257 | }) 258 | 259 | fastify.listen({ port: 0 }, err => { 260 | t.assert.ifError(err) 261 | const ws = new WebSocket('ws://localhost:' + (fastify.server.address()).port + '/echo') 262 | const client = WebSocket.createWebSocketStream(ws, { encoding: 'utf8' }) 263 | t.after(() => client.destroy()) 264 | 265 | client.once('data', chunk => { 266 | t.assert.deepStrictEqual(chunk, 'hello client') 267 | client.end() 268 | end() 269 | }) 270 | }) 271 | }) 272 | 273 | test('Should not hijack reply for a normal http request in the internal onError hook', (t, end) => { 274 | t.plan(2) 275 | const fastify = Fastify() 276 | 277 | t.after(() => fastify.close()) 278 | 279 | fastify.register(fastifyWebsocket) 280 | 281 | fastify.register(async function (fastify) { 282 | fastify.get('/', async () => { 283 | throw new Error('Fail') 284 | }) 285 | }) 286 | 287 | fastify.listen({ port: 0 }, err => { 288 | t.assert.ifError(err) 289 | 290 | const port = fastify.server.address().port 291 | 292 | const httpClient = net.createConnection({ port }, () => { 293 | t.after(() => httpClient.destroy()) 294 | 295 | httpClient.write('GET / HTTP/1.1\r\nHOST: localhost\r\n\r\n') 296 | httpClient.once('data', data => { 297 | t.assert.match(data.toString(), /Fail/i) 298 | end() 299 | }) 300 | httpClient.end() 301 | }) 302 | }) 303 | }) 304 | 305 | test('Should run async hooks and still deliver quickly sent messages', (t, end) => { 306 | t.plan(3) 307 | const fastify = Fastify() 308 | 309 | t.after(() => fastify.close()) 310 | 311 | fastify.register(fastifyWebsocket) 312 | 313 | fastify.register(async function (fastify) { 314 | fastify.addHook( 315 | 'preValidation', 316 | async () => await new Promise((resolve) => setTimeout(resolve, 25)) 317 | ) 318 | 319 | fastify.get('/echo', { websocket: true }, (socket) => { 320 | socket.send('hello client') 321 | t.after(() => socket.terminate()) 322 | 323 | socket.on('message', (message) => { 324 | t.assert.deepStrictEqual(message.toString('utf-8'), 'hello server') 325 | end() 326 | }) 327 | }) 328 | }) 329 | 330 | fastify.listen({ port: 0 }, (err) => { 331 | t.assert.ifError(err) 332 | const ws = new WebSocket( 333 | 'ws://localhost:' + fastify.server.address().port + '/echo' 334 | ) 335 | const client = WebSocket.createWebSocketStream(ws, { encoding: 'utf8' }) 336 | t.after(() => client.destroy()) 337 | 338 | client.setEncoding('utf8') 339 | client.write('hello server') 340 | 341 | client.once('data', (chunk) => { 342 | t.assert.deepStrictEqual(chunk, 'hello client') 343 | client.end() 344 | }) 345 | }) 346 | }) 347 | 348 | test('Should not hijack reply for an normal request to a websocket route that is sent a normal HTTP response in a hook', (t, end) => { 349 | t.plan(2) 350 | const fastify = Fastify() 351 | t.after(() => fastify.close()) 352 | 353 | fastify.register(fastifyWebsocket) 354 | fastify.register(async function (fastify) { 355 | fastify.addHook('preValidation', async (_request, reply) => { 356 | await Promise.resolve() 357 | await reply.code(404).send('not found') 358 | }) 359 | fastify.get('/echo', { websocket: true }, () => { 360 | t.assert.fail() 361 | }) 362 | }) 363 | 364 | fastify.listen({ port: 0 }, err => { 365 | t.assert.ifError(err) 366 | 367 | const port = fastify.server.address().port 368 | 369 | const httpClient = net.createConnection({ port }, () => { 370 | t.after(() => httpClient.destroy()) 371 | httpClient.write('GET /echo HTTP/1.1\r\nHOST: localhost\r\n\r\n') 372 | httpClient.once('data', data => { 373 | t.assert.match(data.toString(), /not found/i) 374 | end() 375 | }) 376 | httpClient.end() 377 | }) 378 | }) 379 | }) 380 | 381 | test('Should not hijack reply for an WS request to a WS route that gets sent a normal HTTP response in a hook', (t, end) => { 382 | t.plan(2) 383 | const stream = split(JSON.parse) 384 | const fastify = Fastify({ logger: { stream } }) 385 | 386 | fastify.register(fastifyWebsocket) 387 | fastify.register(async function (fastify) { 388 | fastify.addHook('preValidation', async (_request, reply) => { 389 | await reply.code(404).send('not found') 390 | }) 391 | fastify.get('/echo', { websocket: true }, () => { 392 | t.assert.fail() 393 | }) 394 | }) 395 | 396 | stream.on('data', (chunk) => { 397 | if (chunk.level >= 50) { 398 | t.assert.fail() 399 | } 400 | }) 401 | 402 | fastify.listen({ port: 0 }, err => { 403 | t.assert.ifError(err) 404 | 405 | const ws = new WebSocket('ws://localhost:' + (fastify.server.address()).port + '/echo') 406 | 407 | ws.on('error', error => { 408 | t.assert.ok(error) 409 | ws.close() 410 | fastify.close() 411 | end() 412 | }) 413 | }) 414 | }) 415 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # @fastify/websocket 2 | 3 | [![CI](https://github.com/fastify/fastify-websocket/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/fastify/fastify-websocket/actions/workflows/ci.yml) 4 | [![NPM version](https://img.shields.io/npm/v/@fastify/websocket.svg?style=flat)](https://www.npmjs.com/package/@fastify/websocket) 5 | [![neostandard javascript style](https://img.shields.io/badge/code_style-neostandard-brightgreen?style=flat)](https://github.com/neostandard/neostandard) 6 | 7 | WebSocket support for [Fastify](https://github.com/fastify/fastify). 8 | Built upon [ws@8](https://www.npmjs.com/package/ws). 9 | 10 | ## Install 11 | 12 | ```shell 13 | npm i @fastify/websocket 14 | # or 15 | yarn add @fastify/websocket 16 | ``` 17 | 18 | If you're a TypeScript user, this package has its own TypeScript types built in, but you will also need to install the types for the `ws` package: 19 | 20 | ```shell 21 | npm i @types/ws -D 22 | # or 23 | yarn add -D @types/ws 24 | ``` 25 | 26 | If you use TypeScript and Yarn 2, you'll need to add a `packageExtension` to your `.yarnrc.yml` file: 27 | 28 | ```yaml 29 | packageExtensions: 30 | "@fastify/websocket@*": 31 | peerDependencies: 32 | fastify: "*" 33 | ``` 34 | 35 | ## Usage 36 | 37 | After registering this plugin, you can choose on which routes the WS server will respond. This can be achieved by adding `websocket: true` property to `routeOptions` on a fastify's `.get` route. In this case, two arguments will be passed to the handler, the socket connection, and the `fastify` request object: 38 | 39 | ```js 40 | 'use strict' 41 | 42 | const fastify = require('fastify')() 43 | fastify.register(require('@fastify/websocket')) 44 | fastify.register(async function (fastify) { 45 | fastify.get('/', { websocket: true }, (socket /* WebSocket */, req /* FastifyRequest */) => { 46 | socket.on('message', message => { 47 | // message.toString() === 'hi from client' 48 | socket.send('hi from server') 49 | }) 50 | }) 51 | }) 52 | 53 | fastify.listen({ port: 3000 }, err => { 54 | if (err) { 55 | fastify.log.error(err) 56 | process.exit(1) 57 | } 58 | }) 59 | ``` 60 | 61 | In this case, it will respond with a 404 error on every unregistered route, closing the incoming upgrade connection requests. 62 | 63 | However, you can still define a wildcard route, that will be used as the default handler: 64 | 65 | ```js 66 | 'use strict' 67 | 68 | const fastify = require('fastify')() 69 | 70 | fastify.register(require('@fastify/websocket'), { 71 | options: { maxPayload: 1048576 } 72 | }) 73 | 74 | fastify.register(async function (fastify) { 75 | fastify.get('/*', { websocket: true }, (socket /* WebSocket */, req /* FastifyRequest */) => { 76 | socket.on('message', message => { 77 | // message.toString() === 'hi from client' 78 | socket.send('hi from wildcard route') 79 | }) 80 | }) 81 | 82 | fastify.get('/', { websocket: true }, (socket /* WebSocket */, req /* FastifyRequest */) => { 83 | socket.on('message', message => { 84 | // message.toString() === 'hi from client' 85 | socket.send('hi from server') 86 | }) 87 | }) 88 | }) 89 | 90 | fastify.listen({ port: 3000 }, err => { 91 | if (err) { 92 | fastify.log.error(err) 93 | process.exit(1) 94 | } 95 | }) 96 | ``` 97 | 98 | ### Attaching event handlers 99 | Websocket route handlers must attach event handlers synchronously during handler execution to avoid accidentally dropping messages. If you want to do any async work in your websocket handler, say to authenticate a user or load data from a datastore, ensure you attach any `on('message')` handlers *before* you trigger this async work. Otherwise, messages might arrive whilst this async work is underway, and if there is no handler listening for this data it will be silently dropped. 100 | 101 | Here is an example of how to attach message handlers synchronously while still accessing asynchronous resources. We store a promise for the async thing in a local variable, attach the message handler synchronously, and then make the message handler itself asynchronous to grab the async data and do some processing: 102 | 103 | ```javascript 104 | fastify.get('/*', { websocket: true }, (socket, request) => { 105 | const sessionPromise = request.getSession() // example async session getter, called synchronously to return a promise 106 | 107 | socket.on('message', async (message) => { 108 | const session = await sessionPromise() 109 | // do something with the message and session 110 | }) 111 | }) 112 | ``` 113 | ### Using hooks 114 | 115 | Routes registered with `@fastify/websocket` respect the Fastify plugin encapsulation contexts, and so will run any hooks that have been registered. This means the same route hooks you might use for authentication or error handling of plain old HTTP handlers will apply to websocket handlers as well. 116 | 117 | ```js 118 | fastify.addHook('preValidation', async (request, reply) => { 119 | // check if the request is authenticated 120 | if (!request.isAuthenticated()) { 121 | await reply.code(401).send("not authenticated"); 122 | } 123 | }) 124 | fastify.get('/', { websocket: true }, (socket, req) => { 125 | // the connection will only be opened for authenticated incoming requests 126 | socket.on('message', message => { 127 | // ... 128 | }) 129 | }) 130 | ``` 131 | 132 | **NB** 133 | This plugin uses the same router as the `fastify` instance, this has a few implications to take into account: 134 | - Websocket route handlers follow the usual `fastify` request lifecycle, which means hooks, error handlers, and decorators all work the same way as other route handlers. 135 | - You can access the fastify server via `this` in your handlers 136 | - When using `@fastify/websocket`, it needs to be registered before all routes in order to be able to intercept websocket connections to existing routes and close the connection on non-websocket routes. 137 | 138 | ```js 139 | import Fastify from 'fastify' 140 | import websocket from '@fastify/websocket' 141 | 142 | const fastify = Fastify() 143 | await fastify.register(websocket) 144 | 145 | fastify.get('/', { websocket: true }, function wsHandler (socket, req) { 146 | // bound to fastify server 147 | this.myDecoration.someFunc() 148 | 149 | socket.on('message', message => { 150 | // message.toString() === 'hi from client' 151 | socket.send('hi from server') 152 | }) 153 | }) 154 | 155 | await fastify.listen({ port: 3000 }) 156 | ``` 157 | 158 | If you need to handle both HTTP requests and incoming socket connections on the same route, you can still do it using the [full declaration syntax](https://fastify.dev/docs/latest/Reference/Routes/#full-declaration), adding a `wsHandler` property. 159 | 160 | ```js 161 | 'use strict' 162 | 163 | const fastify = require('fastify')() 164 | 165 | function handle (socket, req) { 166 | socket.on('message', (data) => socket.send(data)) // creates an echo server 167 | } 168 | 169 | fastify.register(require('@fastify/websocket'), { 170 | handle, 171 | options: { maxPayload: 1048576 } 172 | }) 173 | 174 | fastify.register(async function () { 175 | fastify.route({ 176 | method: 'GET', 177 | url: '/hello', 178 | handler: (req, reply) => { 179 | // this will handle http requests 180 | reply.send({ hello: 'world' }) 181 | }, 182 | wsHandler: (socket, req) => { 183 | // this will handle websockets connections 184 | socket.send('hello client') 185 | 186 | socket.once('message', chunk => { 187 | socket.close() 188 | }) 189 | } 190 | }) 191 | }) 192 | 193 | fastify.listen({ port: 3000 }, err => { 194 | if (err) { 195 | fastify.log.error(err) 196 | process.exit(1) 197 | } 198 | }) 199 | ``` 200 | 201 | ### Custom error handler: 202 | 203 | You can optionally provide a custom `errorHandler` that will be used to handle any cleaning up of established websocket connections. The `errorHandler` will be called if any errors are thrown by your websocket route handler after the connection has been established. Note that neither Fastify's `onError` hook or functions registered with `fastify.setErrorHandler` will be called for errors thrown during a websocket request handler. 204 | 205 | Neither the `errorHandler` passed to this plugin or fastify's `onError` hook will be called for errors encountered during message processing for your connection. If you want to handle unexpected errors within your `message` event handlers, you'll need to use your own `try { } catch {}` statements and decide what to send back over the websocket. 206 | 207 | ```js 208 | const fastify = require('fastify')() 209 | 210 | fastify.register(require('@fastify/websocket'), { 211 | errorHandler: function (error, socket /* WebSocket */, req /* FastifyRequest */, reply /* FastifyReply */) { 212 | // Do stuff 213 | // destroy/close connection 214 | socket.terminate() 215 | }, 216 | options: { 217 | maxPayload: 1048576, // we set the maximum allowed messages size to 1 MiB (1024 bytes * 1024 bytes) 218 | verifyClient: function (info, next) { 219 | if (info.req.headers['x-fastify-header'] !== 'fastify is awesome !') { 220 | return next(false) // the connection is not allowed 221 | } 222 | next(true) // the connection is allowed 223 | } 224 | } 225 | }) 226 | 227 | fastify.get('/', { websocket: true }, (socket /* WebSocket */, req /* FastifyRequest */) => { 228 | socket.on('message', message => { 229 | // message.toString() === 'hi from client' 230 | socket.send('hi from server') 231 | }) 232 | }) 233 | 234 | fastify.listen({ port: 3000 }, err => { 235 | if (err) { 236 | fastify.log.error(err) 237 | process.exit(1) 238 | } 239 | }) 240 | ``` 241 | 242 | Note: Fastify's `onError` and error handlers registered by `setErrorHandler` will still be called for errors encountered *before* the websocket connection is established. This means errors thrown by `onRequest` hooks, `preValidation` handlers, and hooks registered by plugins will use the normal error handling mechanisms in Fastify. Once the websocket is established and your websocket route handler is called, `fastify-websocket`'s `errorHandler` takes over. 243 | 244 | ### Custom preClose hook: 245 | 246 | By default, all ws connections are closed when the server closes. If you wish to modify this behavior, you can pass your own `preClose` function. 247 | 248 | Note that `preClose` is responsible for closing all connections and closing the websocket server. 249 | 250 | ```js 251 | const fastify = require('fastify')() 252 | 253 | fastify.register(require('@fastify/websocket'), { 254 | preClose: (done) => { // Note: can also use async style, without done-callback 255 | const server = this.websocketServer 256 | 257 | for (const socket of server.clients) { 258 | socket.close(1001, 'WS server is going offline in custom manner, sending a code + message') 259 | } 260 | 261 | server.close(done) 262 | } 263 | }) 264 | ``` 265 | 266 | ### Creating a stream from the WebSocket 267 | 268 | ```js 269 | const Fastify = require('fastify') 270 | const FastifyWebSocket = require('@fastify/websocket') 271 | const ws = require('ws') 272 | 273 | const fastify = Fastify() 274 | await fastify.register(FastifyWebSocket) 275 | 276 | fastify.get('/', { websocket: true }, (socket, req) => { 277 | const stream = ws.createWebSocketStream(socket, { /* options */ }) 278 | stream.setEncoding('utf8') 279 | stream.write('hello client') 280 | 281 | stream.on('data', function (data) { 282 | // Make sure to set up a data handler or read all the incoming 283 | // data in another way, otherwise stream backpressure will cause 284 | // the underlying WebSocket object to get paused. 285 | }) 286 | }) 287 | 288 | await fastify.listen({ port: 3000 }) 289 | ``` 290 | 291 | ### Testing 292 | 293 | Testing the ws handler can be quite tricky, luckily `fastify-websocket` decorates fastify instance with `injectWS`, 294 | which allows easy testing of a websocket endpoint. 295 | 296 | The signature of injectWS is the following: `([path], [upgradeContext])`. 297 | 298 | #### App.js 299 | 300 | ```js 301 | 'use strict' 302 | 303 | const Fastify = require('fastify') 304 | const FastifyWebSocket = require('@fastify/websocket') 305 | 306 | const App = Fastify() 307 | 308 | App.register(FastifyWebSocket); 309 | 310 | App.register(async function(fastify) { 311 | fastify.addHook('preValidation', async (request, reply) => { 312 | if (request.headers['api-key'] !== 'some-random-key') { 313 | return reply.code(401).send() 314 | } 315 | }) 316 | 317 | fastify.get('/', { websocket: true }, (socket) => { 318 | socket.on('message', message => { 319 | socket.send('hi from server') 320 | }) 321 | }) 322 | }) 323 | 324 | module.exports = App 325 | ``` 326 | 327 | #### App.test.js 328 | 329 | ```js 330 | 'use strict' 331 | 332 | const { test } = require('node:test') 333 | const fastify = require('./app.js') 334 | 335 | test('connect to /', async (t) => { 336 | t.plan(1) 337 | 338 | t.after(() => fastify.close()) 339 | await fastify.ready() 340 | 341 | const ws = await fastify.injectWS('/', {headers: { "api-key" : "some-random-key" }}) 342 | 343 | let resolve; 344 | const promise = new Promise(r => { resolve = r }) 345 | 346 | ws.on('message', (data) => { 347 | resolve(data.toString()); 348 | }) 349 | ws.send('hi from client') 350 | 351 | t.assert.deepStrictEqual(await promise, 'hi from server') 352 | // Remember to close the ws at the end 353 | ws.terminate() 354 | }) 355 | ``` 356 | 357 | #### Things to know 358 | - Websocket needs to be closed manually at the end of each test. 359 | - `fastify.ready()` needs to be awaited to ensure that fastify has been decorated. 360 | - You need to register the event listener before sending the message if you need to process the server response. 361 | 362 | ## Options 363 | 364 | `@fastify/websocket` accept these options for [`ws`](https://github.com/websockets/ws/blob/master/doc/ws.md#new-websocketserveroptions-callback) : 365 | 366 | - `host` - The hostname where to bind the server. 367 | - `port` - The port where to bind the server. 368 | - `backlog` - The maximum length of the queue of pending connections. 369 | - `server` - A pre-created Node.js HTTP/S server. 370 | - `verifyClient` - A function that can be used to validate incoming connections. 371 | - `handleProtocols` - A function that can be used to handle the WebSocket subprotocols. 372 | - `clientTracking` - Specifies whether or not to track clients. 373 | - `perMessageDeflate` - Enable/disable permessage-deflate. 374 | - `maxPayload` - The maximum allowed message size in bytes. 375 | 376 | For more information, you can check [`ws` options documentation](https://github.com/websockets/ws/blob/master/doc/ws.md#new-websocketserveroptions-callback). 377 | 378 | _**NB** By default if you do not provide a `server` option `@fastify/websocket` will bind your websocket server instance to the scoped `fastify` instance._ 379 | 380 | _**NB** The `path` option from `ws` should not be provided since the routing is handled by fastify itself_ 381 | 382 | _**NB** The `noServer` option from `ws` should not be provided since the point of @fastify/websocket is to listen on the fastify server. If you want a custom server, you can use the `server` option, and if you want more control, you can use the `ws` library directly_ 383 | 384 | [ws](https://github.com/websockets/ws) does not allow you to set `objectMode` or `writableObjectMode` to true 385 | ## Acknowledgments 386 | 387 | This project is kindly sponsored by [nearForm](https://nearform.com). 388 | 389 | ## License 390 | 391 | Licensed under [MIT](./LICENSE). 392 | -------------------------------------------------------------------------------- /test/base.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const http = require('node:http') 4 | const split = require('split2') 5 | const { test } = require('node:test') 6 | const Fastify = require('fastify') 7 | const fastifyWebsocket = require('..') 8 | const WebSocket = require('ws') 9 | const { once, on } = require('node:events') 10 | let timersPromises 11 | 12 | try { 13 | timersPromises = require('node:timers/promises') 14 | } catch {} 15 | 16 | test('Should expose a websocket', async (t) => { 17 | t.plan(2) 18 | 19 | const fastify = Fastify() 20 | t.after(() => fastify.close()) 21 | 22 | await fastify.register(fastifyWebsocket) 23 | 24 | fastify.get('/', { websocket: true }, (socket) => { 25 | t.after(() => socket.terminate()) 26 | 27 | socket.once('message', (chunk) => { 28 | t.assert.deepStrictEqual(chunk.toString(), 'hello server') 29 | socket.send('hello client') 30 | }) 31 | }) 32 | 33 | await fastify.listen({ port: 0 }) 34 | 35 | const ws = new WebSocket('ws://localhost:' + fastify.server.address().port) 36 | t.after(() => { 37 | if (ws.readyState) { 38 | ws.close() 39 | } 40 | }) 41 | 42 | const chunkPromise = once(ws, 'message') 43 | await once(ws, 'open') 44 | ws.send('hello server') 45 | 46 | const [chunk] = await chunkPromise 47 | t.assert.deepStrictEqual(chunk.toString(), 'hello client') 48 | ws.close() 49 | }) 50 | 51 | test('Should fail if custom errorHandler is not a function', async (t) => { 52 | t.plan(2) 53 | 54 | const fastify = Fastify() 55 | t.after(() => fastify.close()) 56 | 57 | try { 58 | await fastify.register(fastifyWebsocket, { errorHandler: {} }) 59 | } catch (err) { 60 | t.assert.deepStrictEqual(err.message, 'invalid errorHandler function') 61 | } 62 | 63 | fastify.get('/', { websocket: true }, (socket) => { 64 | t.after(() => socket.terminate()) 65 | }) 66 | 67 | try { 68 | await fastify.listen({ port: 0 }) 69 | } catch (err) { 70 | t.assert.deepStrictEqual(err.message, 'invalid errorHandler function') 71 | } 72 | }) 73 | 74 | test('Should run custom errorHandler on wildcard route handler error', async (t) => { 75 | t.plan(1) 76 | 77 | const fastify = Fastify() 78 | t.after(() => fastify.close()) 79 | 80 | let _resolve 81 | const p = new Promise((resolve) => { 82 | _resolve = resolve 83 | }) 84 | 85 | await fastify.register(fastifyWebsocket, { 86 | errorHandler: function (error) { 87 | t.assert.deepStrictEqual(error.message, 'Fail') 88 | _resolve() 89 | } 90 | }) 91 | 92 | fastify.get('/*', { websocket: true }, (socket) => { 93 | socket.on('message', (data) => socket.send(data)) 94 | t.after(() => socket.terminate()) 95 | return Promise.reject(new Error('Fail')) 96 | }) 97 | 98 | await fastify.listen({ port: 0 }) 99 | 100 | const ws = new WebSocket('ws://localhost:' + fastify.server.address().port) 101 | t.after(() => { 102 | if (ws.readyState) { 103 | ws.close() 104 | } 105 | }) 106 | 107 | await p 108 | }) 109 | 110 | test('Should run custom errorHandler on error inside websocket handler', async (t) => { 111 | t.plan(1) 112 | 113 | const fastify = Fastify() 114 | t.after(() => fastify.close()) 115 | 116 | let _resolve 117 | const p = new Promise((resolve) => { 118 | _resolve = resolve 119 | }) 120 | 121 | const options = { 122 | errorHandler: function (error) { 123 | t.assert.deepStrictEqual(error.message, 'Fail') 124 | _resolve() 125 | } 126 | } 127 | 128 | await fastify.register(fastifyWebsocket, options) 129 | 130 | fastify.get('/', { websocket: true }, function wsHandler (socket) { 131 | socket.on('message', (data) => socket.send(data)) 132 | t.after(() => socket.terminate()) 133 | throw new Error('Fail') 134 | }) 135 | 136 | await fastify.listen({ port: 0 }) 137 | const ws = new WebSocket('ws://localhost:' + fastify.server.address().port) 138 | 139 | t.after(() => { 140 | if (ws.readyState) { 141 | ws.close() 142 | } 143 | }) 144 | 145 | await p 146 | }) 147 | 148 | test('Should run custom errorHandler on error inside async websocket handler', async (t) => { 149 | t.plan(1) 150 | 151 | const fastify = Fastify() 152 | t.after(() => fastify.close()) 153 | 154 | let _resolve 155 | const p = new Promise((resolve) => { 156 | _resolve = resolve 157 | }) 158 | 159 | const options = { 160 | errorHandler: function (error) { 161 | t.assert.deepStrictEqual(error.message, 'Fail') 162 | _resolve() 163 | } 164 | } 165 | 166 | await fastify.register(fastifyWebsocket, options) 167 | 168 | fastify.get('/', { websocket: true }, async function wsHandler (socket) { 169 | socket.on('message', (data) => socket.send(data)) 170 | t.after(() => socket.terminate()) 171 | throw new Error('Fail') 172 | }) 173 | 174 | await fastify.listen({ port: 0 }) 175 | const ws = new WebSocket('ws://localhost:' + fastify.server.address().port) 176 | t.after(() => { 177 | if (ws.readyState) { 178 | ws.close() 179 | } 180 | }) 181 | 182 | await p 183 | }) 184 | 185 | test('Should be able to pass custom options to ws', async (t) => { 186 | t.plan(2) 187 | 188 | const fastify = Fastify() 189 | t.after(() => fastify.close()) 190 | 191 | const options = { 192 | verifyClient: function (info) { 193 | t.assert.deepStrictEqual(info.req.headers['x-custom-header'], 'fastify is awesome !') 194 | 195 | return true 196 | } 197 | } 198 | 199 | await fastify.register(fastifyWebsocket, { options }) 200 | 201 | fastify.get('/*', { websocket: true }, (socket) => { 202 | socket.on('message', (data) => socket.send(data)) 203 | t.after(() => socket.terminate()) 204 | }) 205 | 206 | await fastify.listen({ port: 0 }) 207 | 208 | const clientOptions = { headers: { 'x-custom-header': 'fastify is awesome !' } } 209 | const ws = new WebSocket('ws://localhost:' + fastify.server.address().port, clientOptions) 210 | const chunkPromise = once(ws, 'message') 211 | await once(ws, 'open') 212 | t.after(() => { 213 | if (ws.readyState) { 214 | ws.close() 215 | } 216 | }) 217 | 218 | ws.send('hello') 219 | 220 | const [chunk] = await chunkPromise 221 | t.assert.deepStrictEqual(chunk.toString(), 'hello') 222 | ws.close() 223 | }) 224 | 225 | test('Should warn if path option is provided to ws', async (t) => { 226 | t.plan(3) 227 | const logStream = split(JSON.parse) 228 | const fastify = Fastify({ 229 | logger: { 230 | stream: logStream, 231 | level: 'warn' 232 | } 233 | }) 234 | 235 | logStream.once('data', line => { 236 | t.assert.deepStrictEqual(line.msg, 'ws server path option shouldn\'t be provided, use a route instead') 237 | t.assert.deepStrictEqual(line.level, 40) 238 | }) 239 | 240 | t.after(() => fastify.close()) 241 | 242 | const options = { path: '/' } 243 | await fastify.register(fastifyWebsocket, { options }) 244 | 245 | fastify.get('/*', { websocket: true }, (socket) => { 246 | socket.on('message', (data) => socket.send(data)) 247 | t.after(() => socket.terminate()) 248 | }) 249 | 250 | await fastify.listen({ port: 0 }) 251 | 252 | const clientOptions = { headers: { 'x-custom-header': 'fastify is awesome !' } } 253 | const ws = new WebSocket('ws://localhost:' + fastify.server.address().port, clientOptions) 254 | const chunkPromise = once(ws, 'message') 255 | await once(ws, 'open') 256 | t.after(() => { 257 | if (ws.readyState) { 258 | ws.close() 259 | } 260 | }) 261 | 262 | ws.send('hello') 263 | 264 | const [chunk] = await chunkPromise 265 | t.assert.deepStrictEqual(chunk.toString(), 'hello') 266 | ws.close() 267 | }) 268 | 269 | test('Should be able to pass a custom server option to ws', async (t) => { 270 | // We create an external server 271 | const externalServerPort = 3000 272 | const externalServer = http 273 | .createServer() 274 | .on('connection', (socket) => { 275 | socket.unref() 276 | }) 277 | .listen(externalServerPort, 'localhost') 278 | 279 | const fastify = Fastify() 280 | t.after(() => { 281 | externalServer.close() 282 | fastify.close() 283 | }) 284 | 285 | const options = { 286 | server: externalServer 287 | } 288 | 289 | await fastify.register(fastifyWebsocket, { options }) 290 | 291 | fastify.get('/', { websocket: true }, (socket) => { 292 | socket.on('message', (data) => socket.send(data)) 293 | t.after(() => socket.terminate()) 294 | }) 295 | 296 | await fastify.ready() 297 | 298 | const ws = new WebSocket('ws://localhost:' + externalServerPort) 299 | const chunkPromise = once(ws, 'message') 300 | await once(ws, 'open') 301 | t.after(() => { 302 | if (ws.readyState) { 303 | ws.close() 304 | } 305 | }) 306 | 307 | ws.send('hello') 308 | 309 | const [chunk] = await chunkPromise 310 | t.assert.deepStrictEqual(chunk.toString(), 'hello') 311 | ws.close() 312 | }) 313 | 314 | test('Should be able to pass clientTracking option in false to ws', async (t) => { 315 | const fastify = Fastify() 316 | 317 | const options = { 318 | clientTracking: false 319 | } 320 | 321 | fastify.register(fastifyWebsocket, { options }) 322 | 323 | fastify.get('/*', { websocket: true }, (socket) => { 324 | socket.close() 325 | }) 326 | 327 | await fastify.listen({ port: 0 }) 328 | 329 | await fastify.close() 330 | }) 331 | 332 | test('Should be able to pass preClose option to override default', async (t) => { 333 | t.plan(3) 334 | 335 | const fastify = Fastify() 336 | 337 | const preClose = (done) => { 338 | t.assert.ok('Custom preclose successfully called') 339 | 340 | for (const connection of fastify.websocketServer.clients) { 341 | connection.close() 342 | } 343 | done() 344 | } 345 | 346 | await fastify.register(fastifyWebsocket, { preClose }) 347 | 348 | fastify.get('/', { websocket: true }, (socket) => { 349 | t.after(() => socket.terminate()) 350 | 351 | socket.once('message', (chunk) => { 352 | t.assert.deepStrictEqual(chunk.toString(), 'hello server') 353 | socket.send('hello client') 354 | }) 355 | }) 356 | 357 | await fastify.listen({ port: 0 }) 358 | 359 | const ws = new WebSocket('ws://localhost:' + fastify.server.address().port) 360 | t.after(() => { 361 | if (ws.readyState) { 362 | ws.close() 363 | } 364 | }) 365 | 366 | const chunkPromise = once(ws, 'message') 367 | await once(ws, 'open') 368 | ws.send('hello server') 369 | 370 | const [chunk] = await chunkPromise 371 | t.assert.deepStrictEqual(chunk.toString(), 'hello client') 372 | ws.close() 373 | 374 | await fastify.close() 375 | }) 376 | 377 | test('Should fail if custom preClose is not a function', async (t) => { 378 | t.plan(2) 379 | 380 | const fastify = Fastify() 381 | t.after(() => fastify.close()) 382 | 383 | const preClose = 'Not a function' 384 | 385 | try { 386 | await fastify.register(fastifyWebsocket, { preClose }) 387 | } catch (err) { 388 | t.assert.deepStrictEqual(err.message, 'invalid preClose function') 389 | } 390 | 391 | fastify.get('/', { websocket: true }, (socket) => { 392 | t.after(() => socket.terminate()) 393 | }) 394 | 395 | try { 396 | await fastify.listen({ port: 0 }) 397 | } catch (err) { 398 | t.assert.deepStrictEqual(err.message, 'invalid preClose function') 399 | } 400 | }) 401 | 402 | test('Should gracefully close with a connected client', async (t) => { 403 | t.plan(2) 404 | 405 | const fastify = Fastify() 406 | 407 | await fastify.register(fastifyWebsocket) 408 | let serverConnEnded 409 | 410 | fastify.get('/', { websocket: true }, (socket) => { 411 | socket.send('hello client') 412 | 413 | socket.once('message', (chunk) => { 414 | t.assert.deepStrictEqual(chunk.toString(), 'hello server') 415 | }) 416 | 417 | serverConnEnded = once(socket, 'close') 418 | // this connection stays alive untile we close the server 419 | }) 420 | 421 | await fastify.listen({ port: 0 }) 422 | 423 | const ws = new WebSocket('ws://localhost:' + fastify.server.address().port) 424 | const chunkPromise = once(ws, 'message') 425 | await once(ws, 'open') 426 | ws.send('hello server') 427 | 428 | const ended = once(ws, 'close') 429 | const [chunk] = await chunkPromise 430 | t.assert.deepStrictEqual(chunk.toString(), 'hello client') 431 | await fastify.close() 432 | await ended 433 | await serverConnEnded 434 | }) 435 | 436 | test('Should gracefully close when clients attempt to connect after calling close', async (t) => { 437 | t.plan(3) 438 | 439 | const fastify = Fastify() 440 | 441 | const oldClose = fastify.server.close 442 | let p 443 | fastify.server.close = function (cb) { 444 | const ws = new WebSocket('ws://localhost:' + fastify.server.address().port) 445 | 446 | p = once(ws, 'close').catch((err) => { 447 | t.assert.deepStrictEqual(err.message, 'Unexpected server response: 503') 448 | oldClose.call(this, cb) 449 | }) 450 | } 451 | 452 | await fastify.register(fastifyWebsocket) 453 | 454 | fastify.get('/', { websocket: true }, (socket) => { 455 | t.assert.ok('received client connection') 456 | socket.close() 457 | // this connection stays alive until we close the server 458 | }) 459 | 460 | await fastify.listen({ port: 0 }) 461 | 462 | const ws = new WebSocket('ws://localhost:' + fastify.server.address().port) 463 | 464 | ws.on('close', () => { 465 | t.assert.ok('client 1 closed') 466 | }) 467 | 468 | await once(ws, 'open') 469 | await fastify.close() 470 | await p 471 | }) 472 | 473 | /* 474 | This test sends one message every 10 ms. 475 | After 50 messages have been sent, we check how many unhandled messages the server has. 476 | After 100 messages we check this number has not increased but rather decreased 477 | the number of unhandled messages below a threshold, which means it is still able 478 | to process message. 479 | */ 480 | test('Should keep accepting connection', { skip: !timersPromises }, async t => { 481 | t.plan(1) 482 | 483 | const fastify = Fastify() 484 | let sent = 0 485 | let unhandled = 0 486 | let threshold = 0 487 | 488 | await fastify.register(fastifyWebsocket) 489 | 490 | fastify.get('/', { websocket: true }, (socket) => { 491 | socket.on('message', () => { 492 | unhandled-- 493 | }) 494 | 495 | socket.on('error', err => { 496 | t.error(err) 497 | }) 498 | 499 | /* 500 | This is a safety check - If the socket is stuck, fastify.close will not run. 501 | Therefore after 100 messages we forcibly close the socket. 502 | */ 503 | const safetyInterval = setInterval(() => { 504 | if (sent < 100) { 505 | return 506 | } 507 | 508 | clearInterval(safetyInterval) 509 | socket.terminate() 510 | }, 100) 511 | }) 512 | 513 | await fastify.listen({ port: 0 }) 514 | 515 | // Setup a client that sends a lot of messages to the server 516 | const client = new WebSocket('ws://localhost:' + fastify.server.address().port) 517 | client.on('error', console.error) 518 | 519 | await once(client, 'open') 520 | const message = Buffer.alloc(1024, Date.now()) 521 | 522 | /* eslint-disable no-unused-vars */ 523 | for await (const _ of timersPromises.setInterval(10)) { 524 | client.send(message.toString(), 10) 525 | sent++ 526 | unhandled++ 527 | if (sent === 50) { 528 | threshold = unhandled 529 | } else if (sent === 100) { 530 | await fastify.close() 531 | t.assert.ok(unhandled <= threshold) 532 | break 533 | } 534 | } 535 | }) 536 | 537 | test('Should keep processing message when many medium sized messages are sent', async t => { 538 | t.plan(1) 539 | 540 | const fastify = Fastify() 541 | const total = 200 542 | let handled = 0 543 | 544 | await fastify.register(fastifyWebsocket) 545 | 546 | fastify.get('/', { websocket: true }, (socket) => { 547 | socket.on('message', () => { 548 | socket.send('handled') 549 | }) 550 | 551 | socket.on('error', err => { 552 | t.error(err) 553 | }) 554 | }) 555 | 556 | await fastify.listen({ port: 0 }) 557 | 558 | // Setup a client that sends a lot of messages to the server 559 | const client = new WebSocket('ws://localhost:' + fastify.server.address().port) 560 | client.on('error', console.error) 561 | 562 | await once(client, 'open') 563 | 564 | for (let i = 0; i < total; i++) { 565 | client.send(Buffer.alloc(160, `${i}`).toString('utf-8')) 566 | } 567 | 568 | /* eslint-disable no-unused-vars */ 569 | for await (const _ of on(client, 'message')) { 570 | handled++ 571 | 572 | if (handled === total) { 573 | break 574 | } 575 | } 576 | 577 | await fastify.close() 578 | t.assert.deepStrictEqual(handled, total) 579 | }) 580 | 581 | test('Should error server if the noServer option is set', (t) => { 582 | t.plan(1) 583 | const fastify = Fastify() 584 | 585 | fastify.register(fastifyWebsocket, { options: { noServer: true } }) 586 | t.assert.rejects(fastify.ready()) 587 | }) 588 | 589 | test('Should preserve the prefix in non-websocket routes', async (t) => { 590 | t.plan(2) 591 | 592 | const fastify = Fastify() 593 | t.after(() => fastify.close()) 594 | 595 | fastify.register(fastifyWebsocket) 596 | 597 | fastify.register(async function (fastify) { 598 | t.assert.deepStrictEqual(fastify.prefix, '/hello') 599 | fastify.get('/', function (_, reply) { 600 | t.assert.deepStrictEqual(this.prefix, '/hello') 601 | reply.send('hello') 602 | }) 603 | }, { prefix: '/hello' }) 604 | 605 | await fastify.inject('/hello') 606 | }) 607 | 608 | test('Should Handle WebSocket errors to avoid Node.js crashes', async t => { 609 | t.plan(1) 610 | 611 | const fastify = Fastify() 612 | await fastify.register(fastifyWebsocket) 613 | 614 | fastify.get('/', { websocket: true }, (socket) => { 615 | socket.on('error', err => { 616 | t.assert.deepStrictEqual(err.code, 'WS_ERR_UNEXPECTED_RSV_2_3') 617 | }) 618 | }) 619 | 620 | await fastify.listen({ port: 0 }) 621 | 622 | const client = new WebSocket('ws://localhost:' + fastify.server.address().port) 623 | await once(client, 'open') 624 | 625 | client._socket.write(Buffer.from([0xa2, 0x00])) 626 | 627 | await fastify.close() 628 | }) 629 | 630 | test('remove all others websocket handlers on close', async (t) => { 631 | const fastify = Fastify() 632 | 633 | await fastify.register(fastifyWebsocket) 634 | 635 | await fastify.listen({ port: 0 }) 636 | 637 | await fastify.close() 638 | 639 | t.assert.deepStrictEqual(fastify.server.listeners('upgrade').length, 0) 640 | }) 641 | 642 | test('clashing upgrade handler', async (t) => { 643 | const fastify = Fastify() 644 | t.after(() => fastify.close()) 645 | 646 | fastify.server.on('upgrade', (req, socket) => { 647 | const res = new http.ServerResponse(req) 648 | res.assignSocket(socket) 649 | res.end() 650 | socket.destroy() 651 | }) 652 | 653 | await fastify.register(fastifyWebsocket) 654 | 655 | fastify.get('/', { websocket: true }, () => { 656 | t.assert.fail('this should never be invoked') 657 | }) 658 | 659 | await fastify.listen({ port: 0 }) 660 | 661 | const ws = new WebSocket('ws://localhost:' + fastify.server.address().port) 662 | await once(ws, 'error') 663 | }) 664 | -------------------------------------------------------------------------------- /test/router.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const net = require('node:net') 4 | const { test } = require('node:test') 5 | const Fastify = require('fastify') 6 | const fastifyWebsocket = require('..') 7 | const WebSocket = require('ws') 8 | const get = require('node:http').get 9 | 10 | const withResolvers = function () { 11 | let promiseResolve, promiseReject 12 | const promise = new Promise((resolve, reject) => { 13 | promiseResolve = resolve 14 | promiseReject = reject 15 | }) 16 | return { promise, resolve: promiseResolve, reject: promiseReject } 17 | } 18 | 19 | test('Should expose a websocket on prefixed route', (t, end) => { 20 | t.plan(4) 21 | const fastify = Fastify() 22 | 23 | t.after(() => fastify.close()) 24 | 25 | fastify.register(fastifyWebsocket) 26 | fastify.register( 27 | function (instance, _opts, next) { 28 | instance.get('/echo', { websocket: true }, function (socket) { 29 | t.assert.deepStrictEqual(this.prefix, '/baz') 30 | socket.send('hello client') 31 | t.after(() => socket.terminate()) 32 | 33 | socket.once('message', (chunk) => { 34 | t.assert.deepStrictEqual(chunk.toString(), 'hello server') 35 | end() 36 | }) 37 | }) 38 | next() 39 | }, 40 | { prefix: '/baz' } 41 | ) 42 | 43 | fastify.listen({ port: 0 }, err => { 44 | t.assert.ifError(err) 45 | const ws = new WebSocket('ws://localhost:' + (fastify.server.address()).port + '/baz/echo') 46 | const client = WebSocket.createWebSocketStream(ws, { encoding: 'utf8' }) 47 | t.after(() => client.destroy()) 48 | 49 | client.setEncoding('utf8') 50 | client.write('hello server') 51 | 52 | client.once('data', chunk => { 53 | t.assert.deepStrictEqual(chunk, 'hello client') 54 | client.end() 55 | }) 56 | }) 57 | }) 58 | 59 | test('Should expose a websocket on prefixed route with /', (t, end) => { 60 | t.plan(3) 61 | const fastify = Fastify() 62 | 63 | t.after(() => fastify.close()) 64 | 65 | fastify.register(fastifyWebsocket) 66 | fastify.register( 67 | function (instance, _opts, next) { 68 | instance.get('/', { websocket: true }, (socket) => { 69 | socket.send('hello client') 70 | t.after(() => socket.terminate()) 71 | 72 | socket.once('message', (chunk) => { 73 | t.assert.deepStrictEqual(chunk.toString(), 'hello server') 74 | end() 75 | }) 76 | }) 77 | next() 78 | }, 79 | { prefix: '/baz' } 80 | ) 81 | 82 | fastify.listen({ port: 0 }, err => { 83 | t.assert.ifError(err) 84 | const ws = new WebSocket('ws://localhost:' + (fastify.server.address()).port + '/baz') 85 | const client = WebSocket.createWebSocketStream(ws, { encoding: 'utf8' }) 86 | t.after(() => client.destroy()) 87 | 88 | client.setEncoding('utf8') 89 | client.write('hello server') 90 | 91 | client.once('data', chunk => { 92 | t.assert.deepStrictEqual(chunk, 'hello client') 93 | client.end() 94 | }) 95 | }) 96 | }) 97 | 98 | test('Should expose websocket and http route', (t) => { 99 | t.plan(4) 100 | const fastify = Fastify() 101 | 102 | t.after(() => fastify.close()) 103 | 104 | const { promise: clientPromise, resolve: clientResolve } = withResolvers() 105 | const { promise: serverPromise, resolve: serverResolve } = withResolvers() 106 | 107 | fastify.register(fastifyWebsocket) 108 | fastify.register( 109 | function (instance, _opts, next) { 110 | instance.route({ 111 | method: 'GET', 112 | url: '/echo', 113 | handler: (_request, reply) => { 114 | reply.send({ hello: 'world' }) 115 | }, 116 | wsHandler: (socket) => { 117 | socket.send('hello client') 118 | t.after(() => socket.terminate()) 119 | 120 | socket.once('message', (chunk) => { 121 | t.assert.deepStrictEqual(chunk.toString(), 'hello server') 122 | clientResolve() 123 | }) 124 | } 125 | }) 126 | next() 127 | }, 128 | { prefix: '/baz' } 129 | ) 130 | 131 | fastify.listen({ port: 0 }, err => { 132 | t.assert.ifError(err) 133 | const url = '//localhost:' + (fastify.server.address()).port + '/baz/echo' 134 | const ws = new WebSocket('ws:' + url) 135 | const client = WebSocket.createWebSocketStream(ws, { encoding: 'utf8' }) 136 | t.after(() => client.destroy()) 137 | 138 | client.setEncoding('utf8') 139 | client.write('hello server') 140 | 141 | client.once('data', chunk => { 142 | t.assert.deepStrictEqual(chunk, 'hello client') 143 | client.end() 144 | }) 145 | get('http:' + url, function (response) { 146 | let data = '' 147 | 148 | // A chunk of data has been recieved. 149 | response.on('data', (chunk) => { 150 | data += chunk 151 | }) 152 | 153 | // The whole response has been received. Print out the result. 154 | response.on('end', () => { 155 | t.assert.deepStrictEqual(data, '{"hello":"world"}') 156 | serverResolve() 157 | }) 158 | }) 159 | }) 160 | 161 | return Promise.all([clientPromise, serverPromise]) 162 | }) 163 | 164 | test('Should close on unregistered path (with no wildcard route websocket handler defined)', (t, end) => { 165 | t.plan(2) 166 | const fastify = Fastify() 167 | 168 | t.after(() => fastify.close()) 169 | 170 | fastify 171 | .register(fastifyWebsocket) 172 | .register(async function () { 173 | fastify.get('/*', (_request, reply) => { 174 | reply.send('hello world') 175 | }) 176 | 177 | fastify.get('/echo', { websocket: true }, (socket) => { 178 | socket.on('message', message => { 179 | try { 180 | socket.send(message) 181 | } catch (err) { 182 | socket.send(err.message) 183 | } 184 | }) 185 | 186 | t.after(() => socket.terminate()) 187 | }) 188 | }) 189 | 190 | fastify.listen({ port: 0 }, err => { 191 | t.assert.ifError(err) 192 | const ws = new WebSocket('ws://localhost:' + (fastify.server.address()).port) 193 | const client = WebSocket.createWebSocketStream(ws, { encoding: 'utf8' }) 194 | t.after(() => client.destroy()) 195 | ws.on('close', () => { 196 | t.assert.ok(true) 197 | end() 198 | }) 199 | }) 200 | }) 201 | 202 | test('Should use wildcard websocket route when (with a normal http wildcard route defined as well)', (t, end) => { 203 | t.plan(2) 204 | const fastify = Fastify() 205 | 206 | t.after(() => fastify.close()) 207 | 208 | fastify 209 | .register(fastifyWebsocket) 210 | .register(async function (fastify) { 211 | fastify.route({ 212 | method: 'GET', 213 | url: '/*', 214 | handler: (_, reply) => { 215 | reply.send({ hello: 'world' }) 216 | }, 217 | wsHandler: (socket) => { 218 | socket.send('hello client') 219 | t.after(() => socket.terminate()) 220 | 221 | socket.once('message', () => { 222 | socket.close() 223 | }) 224 | } 225 | }) 226 | }) 227 | 228 | fastify.listen({ port: 0 }, err => { 229 | t.assert.ifError(err) 230 | const ws = new WebSocket('ws://localhost:' + (fastify.server.address()).port) 231 | const client = WebSocket.createWebSocketStream(ws, { encoding: 'utf8' }) 232 | t.after(() => client.destroy()) 233 | 234 | client.once('data', chunk => { 235 | t.assert.deepStrictEqual(chunk, 'hello client') 236 | client.end() 237 | end() 238 | }) 239 | }) 240 | }) 241 | 242 | test('Should call wildcard route handler on unregistered path', (t, end) => { 243 | t.plan(3) 244 | const fastify = Fastify() 245 | 246 | t.after(() => fastify.close()) 247 | 248 | fastify 249 | .register(fastifyWebsocket) 250 | .register(async function (fastify) { 251 | fastify.get('/*', { websocket: true }, (socket) => { 252 | socket.on('message', () => { 253 | try { 254 | socket.send('hi from wildcard route handler') 255 | } catch (err) { 256 | socket.send(err.message) 257 | } 258 | }) 259 | t.after(() => socket.terminate()) 260 | }) 261 | }) 262 | 263 | fastify.get('/echo', { websocket: true }, (socket) => { 264 | socket.on('message', () => { 265 | try { 266 | socket.send('hi from /echo handler') 267 | } catch (err) { 268 | socket.send(err.message) 269 | } 270 | }) 271 | 272 | t.after(() => socket.terminate()) 273 | }) 274 | 275 | fastify.listen({ port: 0 }, err => { 276 | t.assert.ifError(err) 277 | const ws = new WebSocket('ws://localhost:' + (fastify.server.address()).port) 278 | const client = WebSocket.createWebSocketStream(ws, { encoding: 'utf8' }) 279 | t.after(() => client.destroy()) 280 | 281 | ws.on('open', () => { 282 | ws.send('hi from client') 283 | client.end() 284 | }) 285 | 286 | ws.on('message', message => { 287 | t.assert.deepStrictEqual(message.toString(), 'hi from wildcard route handler') 288 | }) 289 | 290 | ws.on('close', () => { 291 | t.assert.ok(true) 292 | end() 293 | }) 294 | }) 295 | }) 296 | 297 | test('Should invoke the correct handler depending on the headers', (t, end) => { 298 | t.plan(4) 299 | const fastify = Fastify() 300 | 301 | t.after(() => fastify.close()) 302 | 303 | fastify.register(fastifyWebsocket) 304 | fastify.register(async function () { 305 | fastify.route({ 306 | method: 'GET', 307 | url: '/', 308 | handler: (_request, reply) => { 309 | reply.send('hi from handler') 310 | }, 311 | wsHandler: (socket) => { 312 | socket.send('hi from wsHandler') 313 | t.after(() => socket.terminate()) 314 | } 315 | }) 316 | }) 317 | 318 | fastify.listen({ port: 0 }, err => { 319 | t.assert.ifError(err) 320 | 321 | const port = fastify.server.address().port 322 | 323 | const httpClient = net.createConnection({ port }, () => { 324 | httpClient.write('GET / HTTP/1.1\r\nHOST: localhost\r\n\r\n') 325 | httpClient.once('data', data => { 326 | t.assert.match(data.toString(), /hi from handler/i) 327 | httpClient.end() 328 | }) 329 | }) 330 | 331 | const wsClient = net.createConnection({ port }, () => { 332 | wsClient.write('GET / HTTP/1.1\r\nConnection: upgrade\r\nUpgrade: websocket\r\nSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\nSec-WebSocket-Version: 13\r\n\r\n') 333 | wsClient.once('data', data => { 334 | t.assert.match(data.toString(), /hi from wsHandler/i) 335 | wsClient.end(() => { 336 | t.assert.ok(true) 337 | setTimeout(end, 100) 338 | }) 339 | }) 340 | }) 341 | }) 342 | }) 343 | 344 | test('Should call the wildcard handler if a no other non-websocket route with path exists', (t, end) => { 345 | const fastify = Fastify() 346 | t.after(() => fastify.close()) 347 | 348 | fastify.register(fastifyWebsocket) 349 | 350 | fastify.register(async function (fastify) { 351 | fastify.get('/*', { websocket: true }, (socket) => { 352 | t.assert.ok('called', 'wildcard handler') 353 | socket.close() 354 | t.after(() => socket.terminate()) 355 | }) 356 | 357 | fastify.get('/http', (_request, reply) => { 358 | t.assert.fail('Should not call http handler') 359 | reply.send('http route') 360 | }) 361 | }) 362 | 363 | fastify.listen({ port: 0 }, err => { 364 | t.assert.ifError(err) 365 | const ws = new WebSocket('ws://localhost:' + (fastify.server.address()).port + '/http2') 366 | const client = WebSocket.createWebSocketStream(ws, { encoding: 'utf8' }) 367 | t.after(() => client.destroy()) 368 | 369 | client.setEncoding('utf8') 370 | client.end(end) 371 | }) 372 | }) 373 | 374 | test('Should close the connection if a non-websocket route with path exists', (t, end) => { 375 | t.plan(2) 376 | const fastify = Fastify() 377 | t.after(() => fastify.close()) 378 | 379 | fastify.register(fastifyWebsocket) 380 | fastify.register(async function (fastify) { 381 | fastify.get('/*', { websocket: true }, (socket) => { 382 | t.assert.fail('called', 'wildcard handler') 383 | t.after(() => socket.terminate()) 384 | }) 385 | 386 | fastify.get('/http', (_request, reply) => { 387 | t.assert.fail('Should not call /http handler') 388 | reply.send('http route') 389 | }) 390 | }) 391 | 392 | fastify.listen({ port: 0 }, err => { 393 | t.assert.ifError(err) 394 | const ws = new WebSocket('ws://localhost:' + (fastify.server.address()).port + '/http') 395 | ws.on('close', (code) => { 396 | t.assert.deepStrictEqual(code, 1005, 'closed websocket') 397 | end() 398 | }) 399 | }) 400 | }) 401 | 402 | test('Should throw on wrong HTTP method', (t, end) => { 403 | t.plan(2) 404 | const fastify = Fastify() 405 | 406 | t.after(() => fastify.close()) 407 | 408 | fastify.register(fastifyWebsocket) 409 | fastify.register(async function (fastify) { 410 | fastify.post('/echo', { websocket: true }, (socket) => { 411 | socket.on('message', message => { 412 | try { 413 | socket.send(message) 414 | } catch (err) { 415 | socket.send(err.message) 416 | } 417 | }) 418 | t.after(() => socket.terminate()) 419 | }) 420 | 421 | fastify.get('/http', (_request, reply) => { 422 | t.assert.fail('Should not call /http handler') 423 | reply.send('http route') 424 | }) 425 | }) 426 | 427 | fastify.listen({ port: 0 }, (err) => { 428 | t.assert.ok(err) 429 | t.assert.deepStrictEqual(err.message, 'websocket handler can only be declared in GET method') 430 | end() 431 | }) 432 | }) 433 | 434 | test('Should throw on invalid wsHandler', async t => { 435 | t.plan(1) 436 | const fastify = Fastify() 437 | 438 | t.after(() => fastify.close()) 439 | 440 | await fastify.register(fastifyWebsocket) 441 | try { 442 | fastify.route({ 443 | method: 'GET', 444 | url: '/echo', 445 | handler: (_, reply) => { 446 | reply.send({ hello: 'world' }) 447 | }, 448 | wsHandler: 'hello' 449 | }, { prefix: '/baz' }) 450 | } catch (err) { 451 | t.assert.deepStrictEqual(err.message, 'invalid wsHandler function') 452 | } 453 | }) 454 | 455 | test('Should open on registered path', (t, end) => { 456 | t.plan(2) 457 | const fastify = Fastify() 458 | 459 | t.after(() => fastify.close()) 460 | 461 | fastify.register(fastifyWebsocket) 462 | 463 | fastify.register(async function (fastify) { 464 | fastify.get('/echo', { websocket: true }, (socket) => { 465 | socket.on('message', message => { 466 | try { 467 | socket.send(message) 468 | } catch (err) { 469 | socket.send(err.message) 470 | } 471 | }) 472 | 473 | t.after(() => socket.terminate()) 474 | }) 475 | }) 476 | 477 | fastify.listen({ port: 0 }, err => { 478 | t.assert.ifError(err) 479 | const ws = new WebSocket('ws://localhost:' + (fastify.server.address()).port + '/echo') 480 | ws.on('open', () => { 481 | t.assert.ok(true) 482 | client.end() 483 | end() 484 | }) 485 | 486 | const client = WebSocket.createWebSocketStream(ws, { encoding: 'utf8' }) 487 | t.after(() => client.destroy()) 488 | }) 489 | }) 490 | 491 | test('Should send message and close', (t) => { 492 | t.plan(5) 493 | const fastify = Fastify() 494 | 495 | t.after(() => fastify.close()) 496 | 497 | fastify.register(fastifyWebsocket) 498 | 499 | const { promise: clientPromise, resolve: clientResolve } = withResolvers() 500 | const { promise: serverPromise, resolve: serverResolve } = withResolvers() 501 | 502 | fastify.register(async function (fastify) { 503 | fastify.get('/', { websocket: true }, (socket) => { 504 | socket.on('message', message => { 505 | t.assert.deepStrictEqual(message.toString(), 'hi from client') 506 | socket.send('hi from server') 507 | }) 508 | 509 | socket.on('close', () => { 510 | t.assert.ok(true) 511 | serverResolve() 512 | }) 513 | 514 | t.after(() => socket.terminate()) 515 | }) 516 | }) 517 | 518 | fastify.listen({ port: 0 }, err => { 519 | t.assert.ifError(err) 520 | const ws = new WebSocket('ws://localhost:' + (fastify.server.address()).port + '/') 521 | const client = WebSocket.createWebSocketStream(ws, { encoding: 'utf8' }) 522 | t.after(() => client.destroy()) 523 | 524 | ws.on('message', message => { 525 | t.assert.deepStrictEqual(message.toString(), 'hi from server') 526 | }) 527 | 528 | ws.on('open', () => { 529 | ws.send('hi from client') 530 | client.end() 531 | }) 532 | 533 | ws.on('close', () => { 534 | t.assert.ok(true) 535 | clientResolve() 536 | }) 537 | }) 538 | 539 | return Promise.all([clientPromise, serverPromise]) 540 | }) 541 | 542 | test('Should return 404 on http request', (t, end) => { 543 | const fastify = Fastify() 544 | 545 | t.after(() => fastify.close()) 546 | 547 | fastify.register(fastifyWebsocket) 548 | fastify.register(async function (fastify) { 549 | fastify.get('/', { websocket: true }, (socket) => { 550 | socket.on('message', message => { 551 | t.assert.deepStrictEqual(message.toString(), 'hi from client') 552 | socket.send('hi from server') 553 | }) 554 | 555 | socket.on('close', () => { 556 | t.assert.ok(true) 557 | }) 558 | 559 | t.after(() => socket.terminate()) 560 | }) 561 | }) 562 | 563 | fastify.inject({ 564 | method: 'GET', 565 | url: '/' 566 | }).then((response) => { 567 | t.assert.deepStrictEqual(response.payload, '') 568 | t.assert.deepStrictEqual(response.statusCode, 404) 569 | end() 570 | }) 571 | }) 572 | 573 | test('Should pass route params to per-route handlers', (t, end) => { 574 | const fastify = Fastify() 575 | 576 | t.after(() => fastify.close()) 577 | 578 | fastify.register(fastifyWebsocket) 579 | fastify.register(async function (fastify) { 580 | fastify.get('/ws', { websocket: true }, (socket, request) => { 581 | const params = request.params 582 | t.assert.deepStrictEqual(Object.keys(params).length, 0, 'params are empty') 583 | socket.send('empty') 584 | socket.close() 585 | }) 586 | fastify.get('/ws/:id', { websocket: true }, (socket, request) => { 587 | const params = request.params 588 | t.assert.deepStrictEqual(params.id, 'foo', 'params are correct') 589 | socket.send(params.id) 590 | socket.close() 591 | }) 592 | }) 593 | 594 | fastify.listen({ port: 0 }, err => { 595 | let pending = 2 596 | t.assert.ifError(err) 597 | const ws = new WebSocket( 598 | 'ws://localhost:' + (fastify.server.address()).port + '/ws/foo' 599 | ) 600 | const client = WebSocket.createWebSocketStream(ws, { encoding: 'utf8' }) 601 | const ws2 = new WebSocket( 602 | 'ws://localhost:' + (fastify.server.address()).port + '/ws' 603 | ) 604 | const client2 = WebSocket.createWebSocketStream(ws2, { encoding: 'utf8' }) 605 | t.after(() => client.destroy()) 606 | t.after(() => client2.destroy()) 607 | 608 | client.setEncoding('utf8') 609 | client2.setEncoding('utf8') 610 | 611 | client.once('data', chunk => { 612 | t.assert.deepStrictEqual(chunk, 'foo') 613 | client.end() 614 | if (--pending === 0) end() 615 | }) 616 | client2.once('data', chunk => { 617 | t.assert.deepStrictEqual(chunk, 'empty') 618 | client2.end() 619 | if (--pending === 0) end() 620 | }) 621 | }) 622 | }) 623 | 624 | test('Should not throw error when register empty get with prefix', (t, end) => { 625 | const fastify = Fastify() 626 | 627 | t.after(() => fastify.close()) 628 | 629 | fastify.register(fastifyWebsocket) 630 | 631 | fastify.register( 632 | function (instance, _opts, next) { 633 | instance.get('/', { websocket: true }, (socket) => { 634 | socket.on('message', message => { 635 | t.assert.deepStrictEqual(message.toString(), 'hi from client') 636 | socket.send('hi from server') 637 | }) 638 | }) 639 | next() 640 | }, 641 | { prefix: '/baz' } 642 | ) 643 | 644 | fastify.listen({ port: 0 }, err => { 645 | if (err) t.assert.ifError(err) 646 | 647 | const ws = new WebSocket( 648 | 'ws://localhost:' + fastify.server.address().port + '/baz/' 649 | ) 650 | 651 | ws.on('open', () => { 652 | t.assert.ok('Done') 653 | ws.close() 654 | end() 655 | }) 656 | }) 657 | }) 658 | 659 | test('Should expose fastify instance to websocket per-route handler', (t, end) => { 660 | const fastify = Fastify() 661 | 662 | t.after(() => fastify.close()) 663 | 664 | fastify.register(fastifyWebsocket) 665 | fastify.register(async function (fastify) { 666 | fastify.get('/ws', { websocket: true }, function wsHandler (socket) { 667 | t.assert.deepStrictEqual(this, fastify, 'this is bound to fastify server') 668 | socket.send('empty') 669 | socket.close() 670 | }) 671 | }) 672 | 673 | fastify.listen({ port: 0 }, err => { 674 | t.assert.ifError(err) 675 | const ws = new WebSocket( 676 | 'ws://localhost:' + (fastify.server.address()).port + '/ws' 677 | ) 678 | const client = WebSocket.createWebSocketStream(ws, { encoding: 'utf8' }) 679 | t.after(() => client.destroy()) 680 | 681 | client.setEncoding('utf8') 682 | 683 | client.once('data', chunk => { 684 | t.assert.deepStrictEqual(chunk, 'empty') 685 | client.end() 686 | end() 687 | }) 688 | }) 689 | }) 690 | 691 | test('Should have access to decorators in per-route handler', (t, end) => { 692 | const fastify = Fastify() 693 | 694 | t.after(() => fastify.close()) 695 | 696 | fastify.decorateRequest('str', 'it works!') 697 | fastify.register(fastifyWebsocket) 698 | fastify.register(async function (fastify) { 699 | fastify.get('/ws', { websocket: true }, function wsHandler (socket, request) { 700 | t.assert.deepStrictEqual(request.str, 'it works!', 'decorator is accessible') 701 | socket.send('empty') 702 | socket.close() 703 | }) 704 | }) 705 | 706 | fastify.listen({ port: 0 }, err => { 707 | t.assert.ifError(err) 708 | const ws = new WebSocket('ws://localhost:' + (fastify.server.address()).port + '/ws') 709 | const client = WebSocket.createWebSocketStream(ws, { encoding: 'utf8' }) 710 | t.after(() => client.destroy()) 711 | 712 | client.once('data', chunk => { 713 | t.assert.deepStrictEqual(chunk, 'empty') 714 | client.end() 715 | end() 716 | }) 717 | }) 718 | }) 719 | 720 | test('should call `destroy` when exception is thrown inside async handler', (t, end) => { 721 | t.plan(2) 722 | const fastify = Fastify() 723 | 724 | t.after(() => fastify.close()) 725 | 726 | fastify.register(fastifyWebsocket) 727 | fastify.register(async function (fastify) { 728 | fastify.get('/ws', { websocket: true }, async function wsHandler (socket) { 729 | socket.on('close', code => { 730 | t.assert.deepStrictEqual(code, 1006) 731 | end() 732 | }) 733 | throw new Error('something wrong') 734 | }) 735 | }) 736 | 737 | fastify.listen({ port: 0 }, err => { 738 | t.assert.ifError(err) 739 | const ws = new WebSocket( 740 | 'ws://localhost:' + (fastify.server.address()).port + '/ws' 741 | ) 742 | const client = WebSocket.createWebSocketStream(ws, { encoding: 'utf8' }) 743 | 744 | client.on('error', (_) => { }) 745 | t.after(() => client.destroy()) 746 | }) 747 | }) 748 | 749 | test('should call default non websocket fastify route when no match is found', (t, end) => { 750 | t.plan(2) 751 | const fastify = Fastify() 752 | 753 | t.after(() => fastify.close()) 754 | 755 | fastify.register(fastifyWebsocket) 756 | fastify.register(async function (fastify) { 757 | fastify.get('/ws', function handler (_request, reply) { 758 | reply.send({ hello: 'world' }) 759 | }) 760 | }) 761 | 762 | fastify.listen({ port: 0 }, err => { 763 | t.assert.ifError(err) 764 | get('http://localhost:' + (fastify.server.address()).port + '/wrong-route', function (response) { 765 | t.assert.deepStrictEqual(response.statusCode, 404) 766 | end() 767 | }) 768 | }) 769 | }) 770 | 771 | test('register a non websocket route', (t, end) => { 772 | t.plan(2) 773 | const fastify = Fastify() 774 | 775 | t.after(() => fastify.close()) 776 | 777 | fastify.register(fastifyWebsocket) 778 | fastify.register(async function (fastify) { 779 | fastify.get('/ws', function handler (_request, reply) { 780 | reply.send({ hello: 'world' }) 781 | }) 782 | }) 783 | 784 | fastify.listen({ port: 0 }, err => { 785 | t.assert.ifError(err) 786 | get('http://localhost:' + (fastify.server.address()).port + '/ws', function (response) { 787 | let data = '' 788 | 789 | response.on('data', (chunk) => { 790 | data += chunk 791 | }) 792 | 793 | response.on('end', () => { 794 | t.assert.deepStrictEqual(data, '{"hello":"world"}') 795 | end() 796 | }) 797 | }) 798 | }) 799 | }) 800 | --------------------------------------------------------------------------------