├── .gitignore ├── .npmignore ├── ISSUE_TEMPLATE.md ├── LICENSE.txt ├── README.md ├── package-lock.json ├── package.json ├── src ├── frame.ts ├── heartbeat.ts ├── index.ts ├── model.ts ├── protocol.ts ├── session.ts ├── stream.ts ├── utils.ts └── validators.ts ├── test ├── .gitignore ├── e2eTests.ts ├── frameTests.ts ├── heartbeatTests.ts ├── helpers.ts ├── main_client.ts_ ├── main_server.ts_ ├── main_ws_server.ts_ ├── mocha.opts ├── sessionTests.ts └── tsconfig.json └── tsconfig.json /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | coverage 4 | .nyc_output 5 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | src 2 | test 3 | coverage 4 | .nyc_output 5 | tsconfig.json 6 | ISSUE_TEMPLATE.md 7 | -------------------------------------------------------------------------------- /ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ### Description 2 | 3 | [Description of the issue] 4 | 5 | ### Steps to Reproduce 6 | 7 | 1. [First Step] 8 | 2. [Second Step] 9 | 3. [and so on...] 10 | 11 | **Expected behavior:** [What you expect to happen] 12 | 13 | **Actual behavior:** [What actually happens] 14 | 15 | ### Versions 16 | 17 | Specify stomp-protocol version here and please include the OS and what version of the OS you're running. 18 | 19 | ### Additional Information 20 | 21 | Any additional information, configuration or data that might be necessary to reproduce the issue. 22 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2017 Pierantonio Cangianiello 2 | 3 | The MIT License 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # STOMP Protocol for NodeJS 2 | 3 | An implementation of the [STOMP Protocol](https://stomp.github.io/) for NodeJS, both client-side & server-side. It does not implement a fully functional broker, but it's intended as an API for building complex asynchronous applications based on the STOMP protocol. 4 | 5 | **Note: the npm package has been renamed to stomp-protocol in order to be compliant with naming conventions.** 6 | 7 | ## Ready for TypeScript 8 | 9 | I developed this module using Typescript and the npm package is already provided with `d.ts` typings. 10 | 11 | ## Installation 12 | 13 | Run npm to install the package: 14 | 15 | ```shell 16 | npm install stomp-protocol --save 17 | ``` 18 | 19 | Import the module using the standard syntax: 20 | 21 | ```typescript 22 | import * as stomp from 'stomp-protocol'; 23 | ``` 24 | 25 | ### Client example 26 | 27 | The following example shows how to connect to a STOMP server using this library. We use `net.Socket` here, but the library is ready for `WebSocket`s, too. 28 | 29 | ```TypeScript 30 | import { StompHeaders, StompError, StompServerCommandListener, createStompClientSession } from 'stomp-protocol'; 31 | import { Socket, createConnection } from 'net'; 32 | 33 | const listener: StompServerCommandListener = { // 1) define a listener for server-sent frames. 34 | connected(headers: StompHeaders) { 35 | console.log('Connected!', headers); 36 | }, 37 | message(headers: StompHeaders, body?: string) { 38 | console.log('Message!', body, headers); 39 | }, 40 | receipt(headers: StompHeaders) { 41 | console.log('Receipt!', headers); 42 | }, 43 | error(headers: StompHeaders, body?: string) { 44 | console.log('Error!', headers, body); 45 | }, 46 | onProtocolError(error: StompError) { 47 | console.log('Protocol error!', error); 48 | }, 49 | onEnd() { 50 | console.log('End!'); 51 | } 52 | }; 53 | 54 | const socket = createConnection(9999, '127.0.0.1'); // 2) Open raw TCP socket to the server. 55 | 56 | const client = createStompClientSession(socket, listener); // 3) Start a STOMP Session over the TCP socket. 57 | 58 | client.connect({login:'user', passcode:'pass'}).catch(console.error); // 4) Send the first frame! 59 | ``` 60 | 61 | You can also use a listener class constructor accepting a `StompClientSessionLayer` parameter. This decouples connection creation from protocol management: 62 | 63 | ```Typescript 64 | 65 | class MyServerListener implements StompServerCommandListener { 66 | 67 | constructor(private readonly session: StompClientSessionLayer) { } 68 | 69 | // server listeners here... 70 | } 71 | 72 | createStompClientSession(socket, MyServerListener); 73 | 74 | ``` 75 | 76 | ### Server example 77 | 78 | ```TypeScript 79 | import { StompHeaders, StompError, StompClientCommandListener, createStompServerSession } from 'stomp-protocol'; 80 | import { Socket, createServer } from 'net'; 81 | 82 | function testServer(socket: Socket) { // 1) create a listener for incoming raw TCP connections. 83 | 84 | const listener: StompClientCommandListener = { // 2) define a listener for client-sent frames. 85 | 86 | connect(headers: StompHeaders) { 87 | console.log('Connect!', headers); 88 | if (headers && headers.login === 'user' && headers.passcode === 'pass') { 89 | server.connected({ version: '1.2', server: 'MyServer/1.8.2' }).catch(console.error); 90 | } else { 91 | server.error({ message: 'Invalid login data' }, 'Invalid login data').catch(console.error); 92 | } 93 | }, 94 | send(headers: StompHeaders, body?: string) { 95 | console.log('Send!', body, headers); 96 | }, 97 | subscribe(headers: StompHeaders) { 98 | console.log('subscription done to ' + (headers && headers.destination)); 99 | }, 100 | unsubscribe(headers: StompHeaders) { 101 | console.log('unsubscribe', headers); 102 | }, 103 | begin(headers: StompHeaders) { 104 | console.log('begin', headers); 105 | }, 106 | commit(headers: StompHeaders) { 107 | console.log('commit', headers); 108 | }, 109 | abort(headers: StompHeaders) { 110 | console.log('abort', headers); 111 | }, 112 | ack(headers: StompHeaders) { 113 | console.log('ack', headers); 114 | }, 115 | nack(headers: StompHeaders) { 116 | console.log('nack', headers); 117 | }, 118 | disconnect(headers: StompHeaders) { 119 | console.log('Disconnect!', headers); 120 | }, 121 | onProtocolError(error: StompError) { 122 | console.log('Protocol error!', error); 123 | }, 124 | onEnd() { 125 | console.log('End!'); 126 | } 127 | }; 128 | 129 | const server = createStompServerSession(socket, listener); // 3) Start a STOMP Session over the TCP socket. 130 | } 131 | 132 | const server = createServer(testServer); // 4) Create a TCP server 133 | 134 | server.listen(9999, 'localhost'); // 5) Listen for incoming connections 135 | ``` 136 | 137 | As in the client example, you can also use a listener class constructor accepting a `StompServerSessionLayer` parameter: 138 | 139 | ```Typescript 140 | 141 | class MyClientListener implements StompClientCommandListener { 142 | 143 | constructor(private readonly session: StompServerSessionLayer) { } 144 | 145 | // client listeners here... 146 | } 147 | 148 | function testServer(socket: Socket) { 149 | createStompServerSession(socket, MyServerListener); 150 | } 151 | 152 | const server = createServer(testServer); 153 | 154 | server.listen(9999, 'localhost'); 155 | 156 | ``` 157 | 158 | ## Credits 159 | 160 | This project includes some code by [node-stomp-client](https://github.com/easternbloc/node-stomp-client). 161 | 162 | ## License 163 | 164 | Released with MIT License. 165 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "stomp-protocol", 3 | "version": "0.4.7", 4 | "author": "Pierantonio Cangianiello", 5 | "homepage": "https://github.com/pcan/node-stomp-protocol", 6 | "license": "MIT", 7 | "description": "STOMP Protocol for NodeJS", 8 | "main": "dist/index.js", 9 | "types": "dist/index.d.ts", 10 | "scripts": { 11 | "prepare": "tsc --sourcemap false --inlinesourcemap false", 12 | "test": "nyc mocha" 13 | }, 14 | "keywords": [ 15 | "stomp", 16 | "protocol", 17 | "websocket", 18 | "broker" 19 | ], 20 | "bugs": { 21 | "url": "https://github.com/pcan/node-stomp-protocol/issues" 22 | }, 23 | "repository": { 24 | "type": "git", 25 | "url": "https://github.com/pcan/node-stomp-protocol.git" 26 | }, 27 | "engines": { 28 | "node": ">=7.0.0" 29 | }, 30 | "nyc": { 31 | "include": [ 32 | "src/**/*.ts" 33 | ], 34 | "extension": [ 35 | ".ts" 36 | ], 37 | "require": [ 38 | "ts-node/register" 39 | ], 40 | "reporter": [ 41 | "text-summary", 42 | "html" 43 | ], 44 | "sourceMap": true, 45 | "instrument": true 46 | }, 47 | "devDependencies": { 48 | "@types/chai": "^4.1", 49 | "@types/chai-as-promised": "0.0.31", 50 | "@types/mocha": "^5.2", 51 | "@types/node": "^10", 52 | "chai": "^4.1", 53 | "chai-as-promised": "^7.1.1", 54 | "istanbul": "^0.4.5", 55 | "mocha": "^5.2", 56 | "nyc": "^12.0", 57 | "source-map-support": "^0.5", 58 | "ts-node": "^3.2.0", 59 | "typescript": "^3", 60 | "@types/ws": "^5", 61 | "ws": "^5" 62 | }, 63 | "dependencies": {} 64 | } 65 | -------------------------------------------------------------------------------- /src/frame.ts: -------------------------------------------------------------------------------- 1 | import { StompFrame, StompEventEmitter, StompError, StompConfig } from "./model"; 2 | import { StompStreamLayer } from "./stream"; 3 | import { log } from './utils'; 4 | import { Heartbeat } from "./heartbeat"; 5 | 6 | enum StompFrameStatus { 7 | COMMAND = 0, 8 | HEADERS = 1, 9 | BODY = 2, 10 | ERROR = 3 11 | } 12 | 13 | export type StompFrameEvent = 'frame' | 'error' | 'end'; 14 | 15 | const emptyFrame = new StompFrame(''); 16 | 17 | export class StompFrameLayer { 18 | 19 | public readonly emitter = new StompEventEmitter(); 20 | public maxBufferSize = 10 * 1024; 21 | private frame: StompFrame = emptyFrame; 22 | private contentLength = -1; 23 | private buffer = Buffer.alloc(0); 24 | private status = StompFrameStatus.COMMAND; 25 | private newlineFloodingResetTime = 1000; 26 | private lastNewlineTime = 0; 27 | private newlineCounter = 0; 28 | private connectTimeout?: NodeJS.Timer; 29 | public headerFilter = (headerName: string) => true; 30 | public heartbeat: Heartbeat; 31 | 32 | constructor(public readonly stream: StompStreamLayer, options?: StompConfig) { 33 | stream.emitter.on('data', (data: Buffer) => this.onData(data)); 34 | stream.emitter.on('end', () => this.onEnd()); 35 | this.init(options); 36 | this.heartbeat = new Heartbeat(this, options && options.heartbeat); 37 | } 38 | 39 | private init(options?: StompConfig) { 40 | log.debug("StompFrameLayer: initializing with options %j", options); 41 | if (options) { 42 | if (options.connectTimeout && options.connectTimeout > 0) { 43 | this.connectTimeout = setTimeout(() => this.stream.close(), options.connectTimeout); 44 | } 45 | if (options.newlineFloodingResetTime && options.newlineFloodingResetTime > 0) { 46 | this.newlineFloodingResetTime = options.newlineFloodingResetTime; 47 | } 48 | if (options.maxBufferSize && options.maxBufferSize > 0) { 49 | this.maxBufferSize = options.maxBufferSize; 50 | } 51 | } 52 | } 53 | 54 | /** 55 | * Transmit a frame using the underlying stream layer. 56 | */ 57 | public async send(frame: StompFrame) { 58 | let data = frame.command + '\n'; 59 | let body = ''; 60 | let headers = Object.keys(frame.headers).filter(this.headerFilter).sort(); 61 | for (var key of headers) { 62 | data += key + ':' + escape(frame.headers[key]) + '\n'; 63 | } 64 | if (frame.body.length > 0) { 65 | body = frame.body; 66 | if (!frame.headers.hasOwnProperty('suppress-content-length')) { 67 | data += 'content-length:' + Buffer.byteLength(body) + '\n'; 68 | } 69 | } 70 | data += '\n'; 71 | if (body.length > 0) { 72 | data += body; 73 | } 74 | data += '\0'; 75 | log.silly("StompFrameLayer: sending frame data %j", data); 76 | await this.stream.send(data); 77 | this.heartbeat.resetupOutgoingTimer(); 78 | } 79 | 80 | /** 81 | * Closes the underlying stream layer. 82 | */ 83 | public async close() { 84 | log.debug("StompFrameLayer: closing"); 85 | await this.stream.close(); 86 | } 87 | 88 | /** 89 | * Main entry point for frame parsing. It's a state machine that expects 90 | * the standard [ command - headers - body ] structure of a frame. 91 | */ 92 | private onData(data: Buffer) { 93 | if (data.length === 1 && data[0] === 0) { 94 | // Just one byte incoming: it's a null-char for heart-beat. 95 | return; 96 | } 97 | this.buffer = Buffer.concat([this.buffer, data]); 98 | if (this.buffer.length <= this.maxBufferSize) { 99 | do { 100 | try { 101 | if (this.status === StompFrameStatus.COMMAND) { 102 | this.parseCommand(); 103 | } 104 | if (this.status === StompFrameStatus.HEADERS) { 105 | this.parseHeaders(); 106 | } 107 | if (this.status === StompFrameStatus.BODY) { 108 | this.parseBody(); 109 | } 110 | if (this.status === StompFrameStatus.ERROR) { 111 | this.parseError(); 112 | } 113 | } catch (err) { 114 | log.warn("StompFrameLayer: error while parsing data %O", err); 115 | this.stream.close(); 116 | throw err; 117 | } 118 | // still waiting for command line, there is other data remaining 119 | } while (this.status === StompFrameStatus.COMMAND && this.hasLine()); 120 | } else { 121 | this.error(new StompError('Maximum buffer size exceeded.')); 122 | this.stream.close(); 123 | } 124 | } 125 | 126 | private onEnd() { 127 | this.emitter.emit('end'); 128 | } 129 | 130 | private parseCommand() { 131 | while (this.hasLine()) { 132 | var commandLine = this.popLine(); 133 | // command length security check: should be in 1 - 30 char range. 134 | if (commandLine.length > 0 && commandLine.length < 30) { 135 | this.frame = new StompFrame(commandLine.toString().replace('\r', '')); 136 | this.contentLength = -1; 137 | this.incrementStatus(); 138 | break; 139 | } 140 | } 141 | } 142 | 143 | /** 144 | * Parse and checks frame headers format. When content-length header is 145 | * detected, it can be used by the body parser. 146 | */ 147 | private parseHeaders() { 148 | var value; 149 | while (this.hasLine()) { 150 | var headerLine = this.popLine(); 151 | if (headerLine.length === 0) { 152 | this.incrementStatus(); 153 | break; 154 | } else { 155 | var kv = headerLine.toString().replace('\r', '').split(':'); 156 | if (kv.length < 2) { 157 | this.error(new StompError('Error parsing header', `No ':' in line '${headerLine}'`)); 158 | break; 159 | } 160 | value = kv.slice(1).join(':'); 161 | this.frame.setHeader(kv[0], unescape(value)); 162 | if (kv[0] === 'content-length') { 163 | this.contentLength = parseInt(value); 164 | } 165 | } 166 | } 167 | } 168 | 169 | /** 170 | * Parse frame body, using both the content-length header and null char to 171 | * determine the frame end. 172 | */ 173 | private parseBody() { 174 | var bufferBuffer = new Buffer(this.buffer); 175 | 176 | if (this.contentLength > -1) { 177 | // consume data using content-length header 178 | const remainingLength = this.contentLength - this.frame.body.length; 179 | if (remainingLength <= bufferBuffer.length) { 180 | this.appendToBody(bufferBuffer.slice(0, remainingLength)); 181 | this.buffer = bufferBuffer.slice(remainingLength, bufferBuffer.length); 182 | if (this.buffer.indexOf('\0') === 0) { 183 | this.buffer = this.buffer.slice(1); 184 | } 185 | this.contentLength = -1; 186 | this.emitFrame(); 187 | } 188 | 189 | } else { 190 | // consume data using the null-char end 191 | const index = this.buffer.indexOf('\0'); 192 | if (index == -1) { 193 | this.appendToBody(this.buffer); 194 | this.buffer = Buffer.alloc(0); 195 | } else { 196 | // The end of the frame has been identified, finish creating it 197 | this.appendToBody(this.buffer.slice(0, index)); 198 | this.buffer = this.buffer.slice(index + 1); 199 | this.emitFrame(); 200 | } 201 | } 202 | } 203 | 204 | private appendToBody(buffer: Buffer) { 205 | this.frame.body += buffer.toString(); 206 | } 207 | 208 | private emitFrame() { 209 | // Emit the frame and reset 210 | log.silly("StompFrameLayer: received frame %j", this.frame); 211 | this.emitter.emit('frame', this.frame); // Event emit to catch any frame emission 212 | 213 | if (this.connectTimeout) { // first frame received. Cancel disconnect timeout 214 | clearTimeout(this.connectTimeout); 215 | delete this.connectTimeout; 216 | } 217 | 218 | this.incrementStatus(); 219 | } 220 | 221 | /** 222 | * Parses the error 223 | */ 224 | private parseError() { 225 | var index = this.buffer.indexOf('\0'); 226 | if (index > -1) { 227 | // End of the frame is already in buffer 228 | this.buffer = this.buffer.slice(index + 1); 229 | this.incrementStatus(); 230 | } else { 231 | // End of the frame not seen yet 232 | this.buffer = Buffer.alloc(0); 233 | } 234 | } 235 | 236 | /** 237 | * Pops a new line from the stream 238 | * @return {Buffer} the new line available 239 | */ 240 | private popLine(): Buffer { 241 | const now = Date.now(); 242 | if (now - this.lastNewlineTime > this.newlineFloodingResetTime) { 243 | this.newlineCounter = 0; 244 | this.lastNewlineTime = now; 245 | } 246 | if (this.newlineCounter++ > 100) { //security check for newline char flooding 247 | throw new Error('Newline flooding detected.'); 248 | } 249 | var index = this.buffer.indexOf('\n'); 250 | var line = this.buffer.slice(0, index); 251 | this.buffer = this.buffer.slice(index + 1); 252 | return line; 253 | } 254 | 255 | /** 256 | * Check if there is a new line in the current stream chunk 257 | * @return {boolean} 258 | */ 259 | private hasLine(): boolean { 260 | return (this.buffer.indexOf('\n') > -1); 261 | } 262 | 263 | /** 264 | * Emits a new StompFrameError and sets the current status to ERROR 265 | * @param {StompFrameError} error 266 | */ 267 | public error(error: StompError) { 268 | log.debug("StompFrameLayer: stomp error %O", error); 269 | this.emitter.emit('error', error); 270 | this.status = StompFrameStatus.ERROR; 271 | } 272 | 273 | /** 274 | * Set the current status to the next available, otherwise it returns in COMMAND status. 275 | */ 276 | private incrementStatus() { 277 | if (this.status === StompFrameStatus.BODY || this.status === StompFrameStatus.ERROR) { 278 | this.status = StompFrameStatus.COMMAND; 279 | this.newlineCounter = 0; 280 | } else { 281 | this.status++; 282 | } 283 | } 284 | 285 | } 286 | 287 | function unescape(value: string): string { 288 | if (value.indexOf('\\') >= 0) { 289 | if (value.indexOf('\\t') >= 0) { 290 | throw new Error("Unsupported escape sequence detected."); 291 | } 292 | value = value 293 | .replace(/\\n/g, '\n') 294 | .replace(/\\c/g, ':') 295 | .replace(/\\\\/g, '\\') 296 | .replace(/\\r/g, '\r'); 297 | } 298 | return value; 299 | } 300 | 301 | function escape(value: string): string { 302 | if (value.match(/[\t\n\r\:\\]/g)) { 303 | if (value.indexOf('\t') >= 0) { 304 | throw new Error("Unsupported character detected."); 305 | } 306 | value = value 307 | .replace(/\\/g, '\\\\') 308 | .replace(/\n/g, '\\n') 309 | .replace(/\:/g, '\\c') 310 | .replace(/\r/g, '\\r'); 311 | } 312 | return value; 313 | } 314 | -------------------------------------------------------------------------------- /src/heartbeat.ts: -------------------------------------------------------------------------------- 1 | import { StompFrameLayer } from "./frame"; 2 | import { StompFrame, StompError } from "./model"; 3 | import { clearInterval } from "timers"; 4 | 5 | export interface HeartbeatOptions { 6 | outgoingPeriod: number; 7 | incomingPeriod: number; 8 | } 9 | 10 | export class Heartbeat { 11 | 12 | public static defaultOptions: HeartbeatOptions = { outgoingPeriod: 0, incomingPeriod: 0 }; 13 | 14 | options: HeartbeatOptions; 15 | optionsString: string; 16 | 17 | incomingPeriod?: number; 18 | outgoingPeriod?: number; 19 | 20 | lastIncoming: number = 0; 21 | 22 | incomingTimer: NodeJS.Timer | null = null; 23 | outgoingTimer: NodeJS.Timer | null = null; 24 | 25 | constructor( 26 | private readonly frameLayer: StompFrameLayer, 27 | options: HeartbeatOptions = Heartbeat.defaultOptions) { 28 | 29 | this.options = options; 30 | this.optionsString = `${this.options.outgoingPeriod},${this.options.incomingPeriod}`; 31 | 32 | this.frameLayer.emitter.on("frame", (frame) => this.onFrame(frame)); 33 | this.frameLayer.stream.emitter.on("data", (data) => this.onData(data)); 34 | 35 | this.frameLayer.emitter.on("end", () => { 36 | this.releaseTimers(); 37 | }); 38 | } 39 | 40 | onData(data: string) { 41 | this.lastIncoming = Date.now(); 42 | } 43 | 44 | onFrame(frame: StompFrame) { 45 | if (frame.command === "CONNECT" || frame.command === "CONNECTED") { 46 | const heartbeat = frame.headers["heart-beat"]; 47 | if (!heartbeat) { 48 | return; 49 | } 50 | 51 | this.init(heartbeat); 52 | } 53 | 54 | this.lastIncoming = Date.now(); 55 | } 56 | 57 | init(heartbeat: string) { 58 | const [remoteOutgoingPeriod, remoteIncomingPeriod] = heartbeat.split(",").map(s => Number(s)); 59 | 60 | const localIncomingPeriod = this.options.incomingPeriod; 61 | if (localIncomingPeriod > 0 && remoteOutgoingPeriod > 0) { 62 | this.incomingPeriod = Math.max(localIncomingPeriod, remoteOutgoingPeriod); 63 | this.setupIncomingTimer(); 64 | } 65 | 66 | const localOutgoingPeriod = this.options.outgoingPeriod; 67 | if (localOutgoingPeriod > 0 && remoteIncomingPeriod > 0) { 68 | this.outgoingPeriod = Math.max(localOutgoingPeriod, remoteIncomingPeriod); 69 | this.setupOutgoingTimer(); 70 | } 71 | } 72 | 73 | setupOutgoingTimer() { 74 | const period = this.outgoingPeriod; 75 | if (period && period > 0) { 76 | this.outgoingTimer = setInterval(() => { 77 | const eol = "\0"; 78 | this.frameLayer.stream.send(eol); 79 | }, period); 80 | } 81 | } 82 | 83 | resetupOutgoingTimer() { 84 | if (this.outgoingTimer) { 85 | this.releaseTimer(this.outgoingTimer); 86 | this.setupOutgoingTimer(); 87 | } 88 | } 89 | 90 | releaseTimer(timer: NodeJS.Timer | null) { 91 | timer && clearInterval(timer); 92 | } 93 | 94 | releaseTimers() { 95 | this.releaseTimer(this.incomingTimer); 96 | this.incomingTimer = null; 97 | this.releaseTimer(this.outgoingTimer); 98 | this.outgoingTimer = null; 99 | } 100 | 101 | setupIncomingTimer() { 102 | const period = this.incomingPeriod; 103 | if (period && period > 0) { 104 | this.incomingTimer = setInterval(() => { 105 | const delta = Date.now() - this.lastIncoming; 106 | if (delta > 2 * period && this.lastIncoming > 0) { 107 | this.frameLayer.close(); 108 | this.frameLayer.error(new StompError(`No heartbeat for the last 2*${period} ms`)); 109 | } 110 | }, period); 111 | } 112 | } 113 | 114 | } 115 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { openStream } from './stream'; 2 | import { StompServerCommandListener, StompClientCommandListener } from './protocol'; 3 | import { 4 | StompServerSessionLayer, StompClientSessionLayer, 5 | StompClientCommandListenerConstructor, StompServerCommandListenerConstructor 6 | } from './session'; 7 | import { WebSocket } from './utils'; 8 | import { StompFrameLayer } from './frame'; 9 | import { StompConfig } from './model'; 10 | import { Socket } from 'net'; 11 | export { StompServerSessionLayer, StompClientSessionLayer }; 12 | export * from './protocol' 13 | export * from './model' 14 | export { setLoggingListeners, LoggerFunction, StompProtocolLoggingListeners } from './utils' 15 | 16 | export function createStompServerSession(socket: Socket | WebSocket, listener: StompClientCommandListenerConstructor | StompClientCommandListener, config?: StompConfig): StompServerSessionLayer { 17 | const streamLayer = openStream(socket); 18 | const frameLayer = new StompFrameLayer(streamLayer, config); 19 | frameLayer.headerFilter = config && config.headersFilter || frameLayer.headerFilter; 20 | return new StompServerSessionLayer(frameLayer, listener); 21 | } 22 | 23 | export function createStompClientSession(socket: Socket | WebSocket, listener: StompServerCommandListenerConstructor | StompServerCommandListener, config?: StompConfig): StompClientSessionLayer { 24 | const streamLayer = openStream(socket); 25 | const frameLayer = new StompFrameLayer(streamLayer, config); 26 | frameLayer.headerFilter = config && config.headersFilter || frameLayer.headerFilter; 27 | return new StompClientSessionLayer(frameLayer, listener); 28 | } 29 | -------------------------------------------------------------------------------- /src/model.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from "events"; 2 | import { HeartbeatOptions } from "./heartbeat"; 3 | 4 | export type StompHeaders = { [key: string]: string }; 5 | 6 | export interface StompConfig { 7 | connectTimeout?: number; 8 | newlineFloodingResetTime?: number; 9 | headersFilter?: (headerName: string) => boolean; 10 | heartbeat?: HeartbeatOptions; 11 | maxBufferSize?: number; 12 | } 13 | 14 | export class StompSessionData { 15 | id: string | null = null; 16 | authenticated = false; 17 | subscriptions: { [key: string]: boolean } = {}; 18 | transactions: { [key: string]: boolean } = {}; 19 | } 20 | 21 | export class StompError extends Error { 22 | 23 | constructor(message?: string, public details?: string) { 24 | super(message); 25 | } 26 | 27 | } 28 | 29 | export class StompFrame { 30 | 31 | public headers: StompHeaders; 32 | public body: string; 33 | 34 | constructor(readonly command: string, headers?: StompHeaders, body?: string) { 35 | this.body = body || ''; 36 | this.headers = headers || {}; 37 | } 38 | 39 | public setHeader(key: string, value: string) { 40 | this.headers[key] = value; 41 | } 42 | 43 | public toString() { 44 | return JSON.stringify(this); 45 | } 46 | } 47 | 48 | export class StompEventEmitter { 49 | 50 | private readonly emitter = new EventEmitter(); 51 | 52 | public on(event: E, callback: (...args: any[]) => void) { 53 | this.emitter.on(event, callback); 54 | } 55 | 56 | public emit(event: E, ...args: any[]) { 57 | this.emitter.emit(event, ...args); 58 | } 59 | 60 | } 61 | 62 | /* 63 | type StompValidator = ((frame: StompFrame) => StompValidationResult); 64 | 65 | type StompCommands = { 66 | [commandName: string]: StompValidator[] 67 | }; 68 | 69 | export type StompProtocol = { 70 | version: string, 71 | serverCommands: StompCommands, 72 | clientCommands: StompCommands 73 | } 74 | 75 | */ 76 | -------------------------------------------------------------------------------- /src/protocol.ts: -------------------------------------------------------------------------------- 1 | import { StompFrame, StompHeaders, StompError } from './model'; 2 | import { StompSession } from './session' 3 | import { StompValidator, requireHeader, requireAllHeaders, requireOneHeader } from './validators' 4 | import { log } from './utils'; 5 | 6 | export interface StompServerCommands { 7 | 8 | connected(headers: StompHeaders): void; 9 | 10 | message(headers: StompHeaders, body?: string): void; 11 | receipt(headers: StompHeaders): void; 12 | error(headers: StompHeaders, body?: string): void; 13 | 14 | } 15 | 16 | export interface StompClientCommands { 17 | connect(headers: StompHeaders): void; 18 | 19 | send(headers: StompHeaders, body?: string): void; 20 | 21 | subscribe(headers: StompHeaders): void; 22 | unsubscribe(headers: StompHeaders): void; 23 | 24 | begin(headers: StompHeaders): void; 25 | commit(headers: StompHeaders): void; 26 | abort(headers: StompHeaders): void; 27 | 28 | ack(headers: StompHeaders): void; 29 | nack(headers: StompHeaders): void; 30 | 31 | disconnect(headers: StompHeaders): void; 32 | } 33 | 34 | export interface StompCommandListener { 35 | onProtocolError(error: StompError): void; 36 | onEnd(): void; 37 | } 38 | 39 | export interface StompClientCommandListener extends StompClientCommands, StompCommandListener { } 40 | 41 | export interface StompServerCommandListener extends StompServerCommands, StompCommandListener { } 42 | 43 | type ServerSession = StompSession; 44 | type ClientSession = StompSession; 45 | 46 | export type StompCommand = { 47 | validators: StompValidator[], 48 | handle: (frame: StompFrame, session: StompSession) => void 49 | } 50 | 51 | export type StompCommands = { [key: string]: StompCommand }; 52 | 53 | export type StompProtocolHandler = { 54 | version: string, 55 | client: StompCommands, // Client to server 56 | server: StompCommands // Server to client 57 | } 58 | 59 | export const StompProtocolHandlerV10: StompProtocolHandler = { 60 | version: '1.0', 61 | client: { 62 | CONNECT: { 63 | validators: [], 64 | handle(frame: StompFrame, session: ServerSession) { 65 | session.listener.connect(frame.headers); 66 | log.debug("StompProtocolHandler: session %s connected", session.data.id); 67 | } 68 | }, 69 | SEND: { 70 | validators: [requireHeader('destination')], 71 | handle(frame: StompFrame, session: ServerSession) { 72 | session.listener.send(frame.headers, frame.body); 73 | log.silly("StompProtocolHandler: session %s sent frame %j", session.data.id, frame); 74 | } 75 | }, 76 | SUBSCRIBE: { 77 | validators: [requireHeader('destination')], 78 | handle(frame: StompFrame, session: ServerSession) { 79 | session.listener.subscribe(frame.headers); 80 | const destination = getDestinationKey(frame.headers); 81 | log.debug("StompProtocolHandler: session %s subscribed to destination %s", session.data.id, destination); 82 | } 83 | }, 84 | UNSUBSCRIBE: { 85 | validators: [requireOneHeader('destination', 'id')], 86 | handle(frame: StompFrame, session: ServerSession) { 87 | const destination = getDestinationKey(frame.headers); 88 | session.listener.unsubscribe(frame.headers); 89 | log.debug("StompProtocolHandler: session %s unsubscribed from destination %s", session.data.id, destination); 90 | } 91 | }, 92 | BEGIN: { 93 | validators: [requireHeader('transaction')], 94 | handle(frame: StompFrame, session: ServerSession) { 95 | const transaction = frame.headers && frame.headers.transaction; 96 | session.listener.begin(frame.headers); 97 | log.silly("StompProtocolHandler: session %s begin transaction %s", session.data.id, transaction); 98 | } 99 | }, 100 | COMMIT: { 101 | validators: [requireHeader('transaction')], 102 | handle(frame: StompFrame, session: ServerSession) { 103 | const transaction = frame.headers && frame.headers.transaction; 104 | session.listener.commit(frame.headers); 105 | log.silly("StompProtocolHandler: session %s committed transaction %s", session.data.id, transaction); 106 | } 107 | }, 108 | ABORT: { 109 | validators: [requireHeader('transaction')], 110 | handle(frame: StompFrame, session: ServerSession) { 111 | const transaction = frame.headers && frame.headers.transaction; 112 | session.listener.abort(frame.headers); 113 | log.silly("StompProtocolHandler: session %s aborted transaction %s", session.data.id, transaction); 114 | } 115 | }, 116 | ACK: { 117 | validators: [requireHeader('message-id')], 118 | handle(frame: StompFrame, session: ServerSession) { 119 | session.listener.ack(frame.headers); 120 | log.silly("StompProtocolHandler: session %s ack %j", session.data.id, frame.headers); 121 | } 122 | }, 123 | DISCONNECT: { 124 | validators: [], 125 | handle(frame: StompFrame, session: ServerSession) { 126 | session.listener.disconnect(frame.headers); 127 | session.close(); 128 | log.debug("StompProtocolHandler: session %s disconnected", session.data.id); 129 | } 130 | } 131 | }, 132 | server: { 133 | CONNECTED: { 134 | validators: [], 135 | handle(frame: StompFrame, session: ClientSession) { 136 | session.listener.connected(frame.headers); 137 | log.debug("StompProtocolHandler: session %s connected", session.data.id); 138 | } 139 | }, 140 | MESSAGE: { 141 | validators: [requireAllHeaders('destination', 'message-id')], 142 | handle(frame: StompFrame, session: ClientSession) { 143 | session.listener.message(frame.headers, frame.body); 144 | log.silly("StompProtocolHandler: session %s received frame %j", session.data.id, frame); 145 | } 146 | }, 147 | RECEIPT: { 148 | validators: [requireHeader('receipt-id')], 149 | handle(frame: StompFrame, session: ClientSession) { 150 | session.listener.receipt(frame.headers); 151 | log.silly("StompProtocolHandler: session %s sent receipt %j", session.data.id, frame); 152 | } 153 | }, 154 | ERROR: { 155 | validators: [], 156 | handle(frame: StompFrame, session: ClientSession) { 157 | session.listener.error(frame.headers, frame.body); 158 | log.debug("StompProtocolHandler: session %s sent error %j", session.data.id, frame); 159 | } 160 | } 161 | } 162 | } 163 | 164 | export const StompProtocolHandlerV11: StompProtocolHandler = { 165 | version: '1.1', 166 | client: { 167 | CONNECT: { 168 | validators: [requireAllHeaders('accept-version', 'host')], 169 | handle: StompProtocolHandlerV10.client.CONNECT.handle, 170 | }, 171 | STOMP: { 172 | validators: [requireAllHeaders('accept-version', 'host')], 173 | handle: StompProtocolHandlerV10.client.CONNECT.handle, 174 | }, 175 | SEND: StompProtocolHandlerV10.client.SEND, 176 | SUBSCRIBE: { 177 | validators: [requireAllHeaders('destination', 'id')], 178 | handle: StompProtocolHandlerV10.client.SUBSCRIBE.handle, 179 | }, 180 | UNSUBSCRIBE: { 181 | validators: [requireHeader('id')], 182 | handle: StompProtocolHandlerV10.client.UNSUBSCRIBE.handle, 183 | }, 184 | BEGIN: StompProtocolHandlerV10.client.BEGIN, 185 | COMMIT: StompProtocolHandlerV10.client.COMMIT, 186 | ABORT: StompProtocolHandlerV10.client.ABORT, 187 | ACK: { 188 | validators: [requireAllHeaders('message-id', 'subscription')], 189 | handle: StompProtocolHandlerV10.client.ACK.handle, 190 | }, 191 | NACK: { 192 | validators: [requireAllHeaders('message-id', 'subscription')], 193 | handle(frame: StompFrame, session: ServerSession) { 194 | session.listener.nack(frame.headers); 195 | log.silly("StompProtocolHandler: session %s nack %j", session.data.id, frame.headers); 196 | } 197 | }, 198 | DISCONNECT: StompProtocolHandlerV10.client.DISCONNECT 199 | }, 200 | server: { 201 | CONNECTED: { 202 | validators: [requireHeader('version')], 203 | handle: StompProtocolHandlerV10.server.CONNECTED.handle 204 | }, 205 | MESSAGE: { 206 | validators: [requireAllHeaders('destination', 'message-id', 'subscription')], 207 | handle: StompProtocolHandlerV10.server.MESSAGE.handle 208 | }, 209 | RECEIPT: StompProtocolHandlerV10.server.RECEIPT, 210 | ERROR: StompProtocolHandlerV10.server.ERROR 211 | } 212 | } 213 | 214 | 215 | export const StompProtocolHandlerV12: StompProtocolHandler = { 216 | version: '1.2', 217 | client: { 218 | CONNECT: StompProtocolHandlerV11.client.CONNECT, 219 | STOMP: StompProtocolHandlerV11.client.STOMP, 220 | SEND: StompProtocolHandlerV11.client.SEND, 221 | SUBSCRIBE: StompProtocolHandlerV11.client.SUBSCRIBE, 222 | UNSUBSCRIBE: StompProtocolHandlerV11.client.UNSUBSCRIBE, 223 | BEGIN: StompProtocolHandlerV11.client.BEGIN, 224 | COMMIT: StompProtocolHandlerV11.client.COMMIT, 225 | ABORT: StompProtocolHandlerV11.client.ABORT, 226 | ACK: { 227 | validators: [requireHeader('id')], 228 | handle: StompProtocolHandlerV11.client.ACK.handle 229 | }, 230 | NACK: { 231 | validators: [requireHeader('id')], 232 | handle: StompProtocolHandlerV11.client.NACK.handle 233 | }, 234 | DISCONNECT: StompProtocolHandlerV11.client.DISCONNECT 235 | }, 236 | server: { 237 | CONNECTED: StompProtocolHandlerV11.server.CONNECTED, 238 | MESSAGE: StompProtocolHandlerV11.server.MESSAGE, 239 | RECEIPT: StompProtocolHandlerV11.server.RECEIPT, 240 | ERROR: StompProtocolHandlerV11.server.ERROR 241 | } 242 | } 243 | 244 | 245 | function getDestinationKey(headers: StompHeaders) { 246 | if (headers.id) { 247 | return 'id:' + headers.id; 248 | } 249 | if (headers.destination) { 250 | return 'dest:' + headers.destination; 251 | } 252 | throw new StompError('You must specify destination or id header.'); 253 | } 254 | -------------------------------------------------------------------------------- /src/session.ts: -------------------------------------------------------------------------------- 1 | import { StompFrame, StompHeaders, StompError, StompSessionData } from './model'; 2 | import { StompFrameLayer } from './frame'; 3 | import { 4 | StompCommandListener, StompClientCommandListener, StompServerCommandListener, 5 | StompCommand, StompCommands, StompProtocolHandlerV10, StompProtocolHandlerV11, 6 | StompProtocolHandlerV12 7 | } from './protocol'; 8 | import { StompValidationResult } from './validators'; 9 | import { log } from './utils'; 10 | 11 | export interface StompSession { 12 | readonly listener: L; 13 | readonly data: StompSessionData; 14 | close(): Promise; 15 | } 16 | 17 | export interface StompCommandListenerConstructor, L extends StompCommandListener,> { 18 | new(session: S): L; 19 | } 20 | 21 | export interface StompClientCommandListenerConstructor extends StompCommandListenerConstructor { } 22 | export interface StompServerCommandListenerConstructor extends StompCommandListenerConstructor { } 23 | 24 | export abstract class StompSessionLayer implements StompSession { 25 | 26 | protected abstract get inboundCommands(): StompCommands; 27 | readonly data = new StompSessionData(); 28 | public internalErrorHandler = (e: Error) => log.warn("StompSessionLayer: internal error %O", e); 29 | public readonly listener: L; 30 | 31 | constructor(public readonly frameLayer: StompFrameLayer, listener: L | (new (session: any) => L)) { 32 | log.debug("StompSessionLayer: initializing"); 33 | if (typeof listener === 'function') { 34 | this.listener = new listener(this); 35 | } else { 36 | this.listener = listener; 37 | } 38 | frameLayer.emitter.on('frame', (frame) => this.onFrame(frame)); 39 | frameLayer.emitter.on('error', (error) => this.listener.onProtocolError(error)); 40 | frameLayer.emitter.on('end', () => this.onEnd()); 41 | } 42 | 43 | private onFrame(frame: StompFrame) { 44 | log.silly("StompSessionLayer: received command %s", frame.command); 45 | if (this.isValidCommand(frame.command)) { 46 | const command = this.inboundCommands[frame.command]; 47 | const validators = command.validators; 48 | let validation: StompValidationResult; 49 | for (let validator of validators) { 50 | validation = validator(frame, this.data); 51 | if (!validation.isValid) { 52 | this.onError(new StompError(validation.message, validation.details)); 53 | return; 54 | } 55 | } 56 | this.handleFrame(command, frame); 57 | } else { 58 | this.onError(new StompError('No such command', `Unrecognized Command '${frame.command}'`)); 59 | } 60 | } 61 | 62 | protected handleFrame(command: StompCommand, frame: StompFrame) { 63 | log.silly("StompSessionLayer: handling frame %j", frame); 64 | command.handle(frame, this); 65 | } 66 | 67 | protected async sendFrame(frame: StompFrame) { 68 | await this.frameLayer.send(frame); 69 | } 70 | 71 | private onEnd() { 72 | log.debug("StompFrameLayer: end event"); 73 | this.listener.onEnd(); 74 | } 75 | 76 | async close() { 77 | log.debug("StompFrameLayer: closing"); 78 | return this.frameLayer.close(); 79 | } 80 | 81 | protected abstract onError(error: StompError): void; 82 | 83 | private isValidCommand(command: string) { 84 | return (command && command.length < 20 && this.inboundCommands[command]); 85 | } 86 | 87 | } 88 | 89 | interface MessageHeaders extends StompHeaders { 90 | destination: string; 91 | 'message-id': string; 92 | subscription: string; 93 | } 94 | 95 | export class StompServerSessionLayer extends StompSessionLayer { 96 | 97 | private protocol = StompProtocolHandlerV10; 98 | 99 | protected get inboundCommands() { 100 | return this.protocol.client; 101 | } 102 | 103 | constructor(frameLayer: StompFrameLayer, listener: StompCommandListenerConstructor | StompClientCommandListener) { 104 | super(frameLayer, listener); 105 | } 106 | 107 | protected handleFrame(command: StompCommand, frame: StompFrame) { 108 | const acceptVersion = frame.command === 'CONNECT' && frame.headers && frame.headers['accept-version']; 109 | if (this.data.authenticated || frame.command === 'CONNECT') { 110 | try { 111 | if (acceptVersion) { 112 | log.silly("StompServerSessionLayer: session %s switching protocol %s", this.data.id, acceptVersion); 113 | this.switchProtocol(acceptVersion); 114 | } 115 | super.handleFrame(command, frame); 116 | } catch (error) { 117 | const headers: StompHeaders = { message: error.message }; 118 | this.error(headers, error.details).catch(this.internalErrorHandler); 119 | } 120 | } else { 121 | this.error({ message: 'You must first issue a CONNECT command' }).catch(this.internalErrorHandler); 122 | } 123 | } 124 | 125 | private switchProtocol(acceptVersion: string) { 126 | if (acceptVersion.indexOf('1.2') >= 0) { 127 | this.protocol = StompProtocolHandlerV12; 128 | } else if (acceptVersion.indexOf('1.1') >= 0) { 129 | this.protocol = StompProtocolHandlerV11; 130 | } else if (acceptVersion.indexOf('1.0') < 0) { 131 | throw new Error('Supported protocol versions are: 1.0, 1.1, 1.2') 132 | } 133 | } 134 | 135 | protected onError(error: StompError) { 136 | this.error({ message: error.message }, error.details).catch(this.internalErrorHandler); 137 | } 138 | 139 | public async connected(headers: StompHeaders) { 140 | log.debug("StompServerSessionLayer: sending CONNECTED frame %j", headers); 141 | this.data.authenticated = true; 142 | 143 | const heartbeat = this.frameLayer.heartbeat && 144 | this.frameLayer.heartbeat.optionsString && { 145 | "heart-beat": this.frameLayer.heartbeat.optionsString 146 | }; 147 | 148 | const _headers = { 149 | ...headers, 150 | version: this.protocol.version, 151 | ...heartbeat 152 | }; 153 | 154 | await this.sendFrame(new StompFrame('CONNECTED', _headers)); 155 | } 156 | 157 | public async message(headers: StompHeaders, body?: string) { 158 | log.silly("StompServerSessionLayer: sending MESSAGE frame %j %s", headers, body); 159 | await this.sendFrame(new StompFrame('MESSAGE', headers, body)); 160 | } 161 | 162 | public async receipt(headers: StompHeaders) { 163 | log.silly("StompServerSessionLayer: sending RECEIPT frame %j", headers); 164 | await this.sendFrame(new StompFrame('RECEIPT', headers)); 165 | } 166 | 167 | public async error(headers?: StompHeaders, body?: string) { 168 | log.debug("StompServerSessionLayer: sending ERROR frame %j %s", headers, body); 169 | await this.sendFrame(new StompFrame('ERROR', headers, body)); 170 | await this.frameLayer.close(); 171 | } 172 | 173 | } 174 | 175 | export class StompClientSessionLayer extends StompSessionLayer { 176 | 177 | private protocol = StompProtocolHandlerV10; 178 | 179 | protected get inboundCommands() { 180 | return this.protocol.server; 181 | } 182 | 183 | constructor(frameLayer: StompFrameLayer, listener: StompCommandListenerConstructor | StompServerCommandListener) { 184 | super(frameLayer, listener); 185 | } 186 | 187 | protected onError(error: StompError) { 188 | this.listener.onProtocolError(error); 189 | } 190 | 191 | protected handleFrame(command: StompCommand, frame: StompFrame) { 192 | if (frame.command === 'CONNECTED') { 193 | log.debug("StompClientSessionLayer: received CONNECTED frame %j", frame.headers); 194 | if (frame.headers.version === '1.1') { 195 | this.protocol = StompProtocolHandlerV11; 196 | } 197 | if (frame.headers.version === '1.2') { 198 | this.protocol = StompProtocolHandlerV12; 199 | } 200 | } 201 | try { 202 | super.handleFrame(command, frame); 203 | } catch (error) { 204 | this.internalErrorHandler(error); 205 | } 206 | } 207 | 208 | public async connect(headers: StompHeaders): Promise { 209 | log.debug("StompClientSessionLayer: sending CONNECT frame %j", headers); 210 | 211 | const heartbeat = this.frameLayer.heartbeat && 212 | this.frameLayer.heartbeat.optionsString && { 213 | "heart-beat": this.frameLayer.heartbeat.optionsString 214 | }; 215 | 216 | const _headers = { 217 | ...headers, 218 | 'accept-version': '1.0,1.1,1.2', 219 | ...heartbeat 220 | }; 221 | 222 | await this.sendFrame(new StompFrame('CONNECT', _headers)); 223 | } 224 | 225 | public async send(headers: StompHeaders, body?: string): Promise { 226 | log.silly("StompClientSessionLayer: sending SEND frame %j %s", headers, body); 227 | await this.sendFrame(new StompFrame('SEND', headers, body)); 228 | } 229 | 230 | public async subscribe(headers: StompHeaders): Promise { 231 | log.debug("StompClientSessionLayer: sending SUBSCRIBE frame %j", headers); 232 | await this.sendFrame(new StompFrame('SUBSCRIBE', headers)); 233 | } 234 | 235 | public async unsubscribe(headers: StompHeaders): Promise { 236 | log.debug("StompClientSessionLayer: sending UNSUBSCRIBE frame %j", headers); 237 | await this.sendFrame(new StompFrame('UNSUBSCRIBE', headers)); 238 | } 239 | 240 | public async begin(headers: StompHeaders): Promise { 241 | log.silly("StompClientSessionLayer: sending BEGIN frame %j", headers); 242 | await this.sendFrame(new StompFrame('BEGIN', headers)); 243 | } 244 | 245 | public async commit(headers: StompHeaders): Promise { 246 | log.silly("StompClientSessionLayer: sending COMMIT frame %j", headers); 247 | await this.sendFrame(new StompFrame('COMMIT', headers)); 248 | } 249 | 250 | public async abort(headers: StompHeaders): Promise { 251 | log.silly("StompClientSessionLayer: sending ABORT frame %j", headers); 252 | await this.sendFrame(new StompFrame('ABORT', headers)); 253 | } 254 | 255 | public async ack(headers: StompHeaders): Promise { 256 | log.silly("StompClientSessionLayer: sending ACK frame %j", headers); 257 | await this.sendFrame(new StompFrame('ACK', headers)); 258 | } 259 | 260 | public async nack(headers: StompHeaders): Promise { 261 | log.silly("StompClientSessionLayer: sending NACK frame %j", headers); 262 | await this.sendFrame(new StompFrame('NACK', headers)); 263 | } 264 | 265 | public async disconnect(headers?: StompHeaders): Promise { 266 | log.debug("StompClientSessionLayer: sending DISCONNECT frame %j", headers); 267 | await this.sendFrame(new StompFrame('DISCONNECT', headers)); 268 | } 269 | } 270 | -------------------------------------------------------------------------------- /src/stream.ts: -------------------------------------------------------------------------------- 1 | import { StompEventEmitter } from './model'; 2 | import { log, WebSocket, WebSocketMessageHandler } from './utils'; 3 | import { Socket } from 'net'; 4 | 5 | export type StompStreamEvent = 'data' | 'end'; 6 | 7 | export interface StompStreamLayer { 8 | 9 | emitter: StompEventEmitter; 10 | 11 | send(data: string): Promise; 12 | 13 | close(): Promise; 14 | 15 | } 16 | 17 | export function openStream(socket: Socket | WebSocket): StompStreamLayer { 18 | if (socket instanceof Socket) { 19 | return new StompSocketStreamLayer(socket); 20 | } 21 | if (isWebSocket(socket)) { 22 | return new StompWebSocketStreamLayer(socket); 23 | } 24 | throw new Error('Unsupported socket type'); 25 | } 26 | 27 | function isWebSocket(socket: any): socket is WebSocket { 28 | return !!socket && 29 | typeof socket.on === "function" && 30 | typeof socket.send === "function" && 31 | typeof socket.close === "function" && 32 | socket.CLOSED === 3 && 33 | socket.CLOSING === 2 && 34 | socket.OPEN === 1 && 35 | socket.CONNECTING === 0; 36 | } 37 | 38 | class StompSocketStreamLayer implements StompStreamLayer { 39 | 40 | public emitter = new StompEventEmitter(); 41 | 42 | constructor(private readonly socket: Socket) { 43 | log.debug("StompSocketStreamLayer: new connection %s", socket.remoteAddress); 44 | this.socket.on('data', (data) => this.onSocketData(data)); 45 | this.socket.on('error', (err) => this.onSocketEnd(err)); 46 | this.socket.on('close', () => this.onSocketEnd()); 47 | } 48 | 49 | private onSocketData(data: Buffer) { 50 | log.silly("StompSocketStreamLayer: received data %j", data); 51 | if (this.emitter) { 52 | this.emitter.emit('data', data); 53 | } 54 | } 55 | 56 | private onSocketEnd(err?: Error) { 57 | try { 58 | log.debug("StompSocketStreamLayer: socket closed due to error %O", err); 59 | if (this.emitter) { 60 | this.emitter.emit('end', err); 61 | } 62 | } finally { 63 | this.socket.end(); 64 | } 65 | } 66 | 67 | public async send(data: string): Promise { 68 | log.silly("StompSocketStreamLayer: sending data %j", data); 69 | return new Promise((resolve, reject) => { 70 | try { 71 | this.socket.write(data, resolve); 72 | } catch (err) { 73 | log.debug("StompSocketStreamLayer: error while sending data %O", err); 74 | reject(err); 75 | } 76 | }); 77 | } 78 | 79 | public async close(): Promise { 80 | log.debug("StompSocketStreamLayer: closing"); 81 | return new Promise((resolve, reject) => { 82 | try { 83 | this.socket.end(resolve); 84 | } catch (err) { 85 | log.debug("StompSocketStreamLayer: error while closing %O", err); 86 | reject(err); 87 | } 88 | }); 89 | } 90 | 91 | } 92 | 93 | class StompWebSocketStreamLayer implements StompStreamLayer { 94 | 95 | public emitter = new StompEventEmitter(); 96 | private readonly messageListener: WebSocketMessageHandler = (event) => this.onWsMessage(event.data); 97 | private readonly errorListener = (event: any) => this.onWsEnd(event); 98 | private readonly closeListener = (code?: number) => this.onWsEnd({ code }); 99 | 100 | constructor(private readonly webSocket: WebSocket) { 101 | log.debug("StompWebSocketStreamLayer: new connection"); 102 | this.webSocket.addEventListener('message', this.messageListener); 103 | this.webSocket.addEventListener('error', this.errorListener); 104 | this.webSocket.addEventListener('close', this.closeListener); 105 | } 106 | 107 | private onWsMessage(data: any) { 108 | log.silly("StompWebSocketStreamLayer: received data %O", data); 109 | this.emitter.emit('data', new Buffer(data.toString())); 110 | } 111 | 112 | private removeListeners() { 113 | this.webSocket.removeEventListener('message', this.messageListener); 114 | this.webSocket.removeEventListener('error', this.errorListener); 115 | this.webSocket.removeEventListener('close', this.closeListener); 116 | } 117 | 118 | private onWsEnd(event: any) { 119 | log.debug("StompWebSocketStreamLayer: WebSocket closed %O", event); 120 | this.wsClose(); 121 | } 122 | 123 | public async send(data: string): Promise { 124 | log.silly("StompWebSocketStreamLayer: sending data %j", data); 125 | return new Promise((resolve, reject) => { 126 | try { 127 | this.webSocket.send(data); 128 | } catch (err) { 129 | log.debug("StompWebSocketStreamLayer: error while sending data %O", err); 130 | reject(err); 131 | } finally { 132 | resolve(); 133 | } 134 | }); 135 | } 136 | 137 | public async close(): Promise { 138 | log.debug("StompWebSocketStreamLayer: closing"); 139 | return new Promise((resolve, reject) => { 140 | try { 141 | this.wsClose(); 142 | } catch (err) { 143 | log.debug("StompWebSocketStreamLayer: error while closing %O", err); 144 | reject(err); 145 | } finally { 146 | resolve(); 147 | } 148 | }); 149 | } 150 | 151 | private wsClose() { 152 | this.removeListeners(); 153 | this.emitter.emit('end'); 154 | this.webSocket.close(); 155 | } 156 | 157 | } 158 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | export type LoggerFunction = (message: string, ...args: any[]) => any; 2 | 3 | export interface StompProtocolLoggingListeners { 4 | 5 | readonly error: LoggerFunction; 6 | readonly warn: LoggerFunction; 7 | readonly info: LoggerFunction; 8 | readonly debug: LoggerFunction; 9 | readonly silly: LoggerFunction; 10 | 11 | } 12 | 13 | let loggingListeners: StompProtocolLoggingListeners | null = null; 14 | 15 | export function promiseRejectionHandler(className: string, functionName: string) { 16 | const location = `${className}: promise rejection in '${functionName}'`; 17 | return (e: Error) => log.debug(location, e); 18 | } 19 | 20 | export function setLoggingListeners(listeners: StompProtocolLoggingListeners) { 21 | loggingListeners = listeners; 22 | } 23 | 24 | function noop(){ } 25 | 26 | class Logging implements StompProtocolLoggingListeners { 27 | 28 | get error() { 29 | return loggingListeners ? loggingListeners.error : noop; 30 | } 31 | 32 | get warn() { 33 | return loggingListeners ? loggingListeners.warn : noop; 34 | } 35 | 36 | get info() { 37 | return loggingListeners ? loggingListeners.info : noop; 38 | } 39 | 40 | get debug() { 41 | return loggingListeners ? loggingListeners.debug : noop; 42 | } 43 | 44 | get silly() { 45 | return loggingListeners ? loggingListeners.silly : noop; 46 | } 47 | 48 | } 49 | 50 | export const log = new Logging() as StompProtocolLoggingListeners; 51 | 52 | export type WebSocketMessageHandler = (event: { data: any; type: string; target: WebSocket }) => void; 53 | 54 | export interface WebSocket { 55 | 56 | addEventListener(method: 'message', cb?: WebSocketMessageHandler): void; 57 | addEventListener(method: 'close', cb?: (event: any) => void): void; 58 | addEventListener(method: 'error', cb?: (event: any) => void): void; 59 | removeEventListener(method: 'message', cb?: WebSocketMessageHandler): void; 60 | removeEventListener(method: 'close', cb?: (event: any) => void): void; 61 | removeEventListener(method: 'error', cb?: (event: any) => void): void; 62 | close(code?: number, data?: string): void; 63 | send(data: any, cb?: (err?: Error) => void): void; 64 | 65 | } 66 | -------------------------------------------------------------------------------- /src/validators.ts: -------------------------------------------------------------------------------- 1 | import {StompFrame, StompSessionData } from './model'; 2 | 3 | export type StompValidator = (frame: StompFrame, sessionData?: StompSessionData) => StompValidationResult; 4 | 5 | export interface StompValidationResult { 6 | isValid: boolean; 7 | message?: string; 8 | details?: string; 9 | }; 10 | 11 | const validationOk: StompValidationResult = { isValid: true }; 12 | 13 | function isPresent(value: any) { 14 | return typeof value !== 'undefined' && value !== null; 15 | } 16 | 17 | export function requireHeader(headerName: string) { 18 | return (frame: StompFrame) => { 19 | if (isPresent(frame.headers[headerName])) { 20 | return validationOk; 21 | } 22 | return { 23 | isValid: false, 24 | message: `Header '${headerName}' is required for ${frame.command}`, 25 | details: 'Frame: ' + frame.toString() 26 | };; 27 | }; 28 | } 29 | 30 | export function requireOneHeader(...headerNames: string[]) { 31 | return (frame: StompFrame) => { 32 | for (var headerName of headerNames) { 33 | if (isPresent(frame.headers[headerName])) { 34 | return validationOk; 35 | } 36 | } 37 | return { 38 | isValid: false, 39 | message: `One of the following Headers '${headerNames.join(', ')}' is \ 40 | required for ${frame.command}`, 41 | details: 'Frame: ' + frame.toString() 42 | }; 43 | }; 44 | } 45 | 46 | export function requireAllHeaders(...headerNames: string[]) { 47 | return (frame: StompFrame) => { 48 | for (var headerName of headerNames) { 49 | if (!isPresent(frame.headers[headerName])) { 50 | return { 51 | isValid: false, 52 | message: `Header '${headerName}' is required for ${frame.command}`, 53 | details: 'Frame: ' + frame.toString() 54 | }; 55 | } 56 | } 57 | return validationOk; 58 | }; 59 | } 60 | 61 | export function headerMatchesRegex(headerName: string, regex: RegExp) { 62 | return (frame: StompFrame) => { 63 | var headerValue = frame.headers[headerName]; 64 | if (typeof headerValue !== 'string' || !headerValue.match(regex)) { 65 | return { 66 | isValid: false, 67 | message: `Header '${headerName}' has value '${headerValue}' which \ 68 | does not match against the following regex: \ 69 | '${regex}'`, 70 | details: 'Frame: ' + frame.toString() 71 | }; 72 | } 73 | return validationOk; 74 | }; 75 | } 76 | -------------------------------------------------------------------------------- /test/.gitignore: -------------------------------------------------------------------------------- 1 | built/ 2 | *.js 3 | -------------------------------------------------------------------------------- /test/e2eTests.ts: -------------------------------------------------------------------------------- 1 | import 'mocha'; 2 | import { StompServerSessionLayer, StompClientSessionLayer } from '../src/session'; 3 | import { 4 | StompClientCommandListener, StompServerCommandListener 5 | } from '../src/protocol' 6 | import { createStompServerSession, createStompClientSession } from '../src/index'; 7 | import { countdownLatch, noopFn, noopAsyncFn } from './helpers'; 8 | import { createServer, Server, createConnection, Socket, AddressInfo } from 'net'; 9 | import * as WebSocket from 'ws'; 10 | 11 | describe('STOMP Client & Server over Plain Socket', () => { 12 | let serverSession: StompServerSessionLayer; 13 | let clientSession: StompClientSessionLayer; 14 | let clientListener: StompClientCommandListener; 15 | let serverListener: StompServerCommandListener; 16 | let server: Server; 17 | let clientSocket: Socket; 18 | 19 | beforeEach((done) => { 20 | clientListener = { 21 | onProtocolError: (_err) => { }, 22 | onEnd: noopFn 23 | } as StompClientCommandListener; 24 | serverListener = { 25 | onProtocolError: (_err) => { }, 26 | onEnd: noopFn 27 | } as StompServerCommandListener; 28 | server = createServer((socket) => { 29 | serverSession = createStompServerSession(socket, clientListener); 30 | }); 31 | server.listen(); 32 | server.on('listening', () => { 33 | const port = (server.address() as AddressInfo).port; 34 | clientSocket = createConnection(port, 'localhost', done); 35 | clientSession = createStompClientSession(clientSocket, serverListener); 36 | }); 37 | }); 38 | 39 | afterEach((done) => { 40 | clientSocket.end(); 41 | server.close(done); 42 | }); 43 | 44 | it(`should perform connection`, (done) => { 45 | serverListener.connected = () => done(); 46 | clientListener.connect = () => serverSession.connected({}); 47 | clientSession.connect({}); 48 | }); 49 | 50 | it(`should perform disconnection`, (done) => { 51 | serverListener.onEnd = done; 52 | clientListener.disconnect = () => serverSession.close(); 53 | serverListener.connected = () => clientSession.disconnect(); 54 | clientListener.connect = () => serverSession.connected({}); 55 | clientSession.connect({}); 56 | }); 57 | 58 | it(`should handle client-side socket end`, (done) => { 59 | clientListener.onEnd = done; 60 | clientSession.close(); 61 | }); 62 | 63 | it(`should handle server-side socket end`, (done) => { 64 | serverListener.connected = noopAsyncFn; 65 | serverListener.onEnd = done; 66 | clientListener.connect = () => serverSession.connected({}); 67 | clientSession.connect({}).then(() => clientSession.close()); 68 | }); 69 | 70 | it(`should disconnect client after error`, (done) => { 71 | const latch = countdownLatch(2, done); 72 | serverListener.onEnd = latch; 73 | clientListener.connect = () => serverSession.error(); 74 | serverListener.error = () => latch(); 75 | clientSession.connect({ 'accept-version': '350.215' }); 76 | }); 77 | }); 78 | 79 | 80 | describe('STOMP Client & Server over WebSocket', () => { 81 | let serverSession: StompServerSessionLayer; 82 | let clientSession: StompClientSessionLayer; 83 | let clientListener: StompClientCommandListener; 84 | let serverListener: StompServerCommandListener; 85 | let server: WebSocket.Server; 86 | let clientSocket: WebSocket; 87 | 88 | beforeEach((done) => { 89 | const latch = countdownLatch(2, done); 90 | clientListener = { 91 | onProtocolError: (_err) => { }, 92 | onEnd: noopFn 93 | } as StompClientCommandListener; 94 | serverListener = { 95 | onProtocolError: (_err) => { }, 96 | onEnd: noopFn 97 | } as StompServerCommandListener; 98 | 99 | server = new WebSocket.Server({ port: 59999 }, latch); 100 | 101 | server.on('connection', function connection(ws) { 102 | serverSession = createStompServerSession(ws, clientListener); 103 | }); 104 | 105 | clientSocket = new WebSocket("ws://localhost:59999/ws"); 106 | clientSocket.on('open', latch); 107 | clientSession = createStompClientSession(clientSocket, serverListener); 108 | }); 109 | 110 | it(`should perform connection`, (done) => { 111 | serverListener.connected = () => done(); 112 | clientListener.connect = () => serverSession.connected({}); 113 | clientSession.connect({}); 114 | }); 115 | 116 | it(`should call onEnd when client closes connection`, (done) => { 117 | serverListener.connected = () => clientSocket.close(); 118 | clientListener.connect = () => serverSession.connected({}); 119 | clientSession.connect({}); 120 | clientListener.onEnd = done; 121 | }); 122 | 123 | it(`should call onEnd when client listener throws error`, (done) => { 124 | serverListener.connected = () => clientSession.subscribe({ id: '1', destination: '/queue/abc' }); 125 | clientListener.connect = () => serverSession.connected({}); 126 | clientListener.subscribe = () => { throw new Error(); }; 127 | clientSession.connect({}); 128 | clientListener.onEnd = done; 129 | }); 130 | 131 | afterEach((done) => { 132 | clientSocket.close(); 133 | server.close(done); 134 | }); 135 | 136 | }); 137 | -------------------------------------------------------------------------------- /test/frameTests.ts: -------------------------------------------------------------------------------- 1 | import 'mocha'; 2 | import { assert, expect } from 'chai'; 3 | import { StompFrameLayer } from '../src/frame'; 4 | import { StompFrame, StompEventEmitter, StompError } from '../src/model'; 5 | import { StompStreamLayer } from '../src/stream'; 6 | import { check, countdownLatch } from './helpers'; 7 | 8 | describe('STOMP Frame Layer', () => { 9 | let streamLayer: StompStreamLayer; 10 | let frameLayer: StompFrameLayer; 11 | 12 | function newStreamLayer(): StompStreamLayer { 13 | return { 14 | emitter: new StompEventEmitter(), 15 | async close() { }, 16 | async send(data) { } 17 | }; 18 | } 19 | 20 | beforeEach(() => { 21 | streamLayer = newStreamLayer(); 22 | frameLayer = new StompFrameLayer(streamLayer); 23 | }); 24 | 25 | it(`should send basic CONNECT message`, (done) => { 26 | const connectFrameText = 'CONNECT\naccept-version:1.2\nhost:/myHost\n\n\0'; 27 | streamLayer.send = async (data) => check(() => assert.equal(data, connectFrameText), done); 28 | const connectFrame = new StompFrame('CONNECT', { 'accept-version': '1.2', host: '/myHost' }); 29 | frameLayer.send(connectFrame); 30 | }); 31 | 32 | it(`should receive basic CONNECT message`, (done) => { 33 | const connectFrameText = 'CONNECT\naccept-version:1.2\n\n\0'; 34 | const connectFrame = new StompFrame('CONNECT', { 'accept-version': '1.2' }); 35 | frameLayer.emitter.on('frame', (frame: StompFrame) => { 36 | check(() => assert.deepEqual(frame, connectFrame), done); 37 | }); 38 | streamLayer.emitter.emit('data', new Buffer(connectFrameText)); 39 | }); 40 | 41 | it(`should send CONNECT message with filtered headers`, (done) => { 42 | const connectFrameText = 'CONNECT\naccept-version:1.2\n\n\0'; 43 | const connectFrame = new StompFrame('CONNECT', { 'accept-version': '1.2' }); 44 | streamLayer.send = async (data) => check(() => assert.equal(data, connectFrameText), done); 45 | frameLayer.headerFilter = (headerName) => headerName !== 'X-remove-this'; 46 | connectFrame.setHeader('X-remove-this', 'dummy-value'); 47 | frameLayer.send(connectFrame); 48 | }); 49 | 50 | it(`should emit 'end' when disconnected`, (done) => { 51 | frameLayer.emitter.on('end', done); 52 | streamLayer.emitter.emit('end'); 53 | }); 54 | 55 | it(`should close stream when closing`, (done) => { 56 | streamLayer = newStreamLayer(); 57 | frameLayer = new StompFrameLayer(streamLayer, {}); 58 | streamLayer.close = async () => done(); 59 | frameLayer.close(); 60 | }); 61 | 62 | it(`should include content-length header`, (done) => { 63 | streamLayer.send = async (data) => 64 | check(() => expect(data).contains('\ncontent-length'), done); 65 | const frame = new StompFrame('MESSAGE', {}, 'hello'); 66 | frameLayer.send(frame); 67 | }); 68 | 69 | it(`should omit content-length header when suppress-content-length is truthy`, (done) => { 70 | streamLayer.send = async (data) => 71 | check(() => expect(data).not.contains('\ncontent-length'), done); 72 | const frame = new StompFrame('MESSAGE', { 'suppress-content-length': 'true' }, 'hello'); 73 | frameLayer.send(frame); 74 | }); 75 | 76 | it(`should use content-length when present`, (done) => { 77 | frameLayer.emitter.on('frame', (frame: StompFrame) => 78 | check(() => assert.deepEqual(frame, new StompFrame('SEND', { 'content-length': '11' }, 'hello\0world')), done) 79 | ); 80 | streamLayer.emitter.emit('data', new Buffer(`SEND\ncontent-length:11\n\nhello\0world`)); 81 | }); 82 | 83 | it(`should reject frames bigger than maxBufferSize`, (done) => { 84 | frameLayer.emitter.on('error', (error: StompError) => 85 | check(() => assert.equal(error.message, 'Maximum buffer size exceeded.'), done) 86 | ); 87 | frameLayer.maxBufferSize = 1024; 88 | const buf = Buffer.alloc(frameLayer.maxBufferSize + 1, 'a').toString(); 89 | streamLayer.emitter.emit('data', new Buffer(`SEND\ncontent-length:${buf.length}\n\n${buf}\0`)); 90 | }); 91 | 92 | it(`should reject frames with broken headers`, (done) => { 93 | frameLayer.emitter.on('error', (error: StompError) => 94 | check(() => assert.equal(error.message, 'Error parsing header'), done) 95 | ); 96 | streamLayer.emitter.emit('data', new Buffer(`SEND\ncontent-length:5\nbrokenHeader\n\nhello\0`)); 97 | }); 98 | 99 | it(`should reject partial received frames in case of error`, (done) => { 100 | frameLayer.emitter.on('error', (error: StompError) => 101 | check(() => assert.equal(error.message, 'Error parsing header'), done) 102 | ); 103 | streamLayer.emitter.emit('data', new Buffer(`SEND\ncontent-length:123\nbrokenHeader\n\nhel`)); 104 | }); 105 | 106 | it(`should receive a frame in multiple data events, using content-length`, (done) => { 107 | frameLayer.emitter.on('frame', (frame: StompFrame) => 108 | check(() => assert.deepEqual(frame, new StompFrame('SEND', { 'content-length': '11' }, 'hello\0world')), done) 109 | ); 110 | streamLayer.emitter.emit('data', new Buffer(`SEND\ncontent-length:11\n\nhe`)); 111 | streamLayer.emitter.emit('data', new Buffer(`llo\0`)); 112 | streamLayer.emitter.emit('data', new Buffer(`world`)); 113 | }); 114 | 115 | it(`should receive a frame in multiple data events, using null char`, (done) => { 116 | frameLayer.emitter.on('frame', (frame: StompFrame) => 117 | check(() => assert.deepEqual(frame, new StompFrame('SEND', {}, 'hello world')), done) 118 | ); 119 | streamLayer.emitter.emit('data', new Buffer(`SEND\n\nhe`)); 120 | streamLayer.emitter.emit('data', new Buffer(`llo `)); 121 | streamLayer.emitter.emit('data', new Buffer(`world\0`)); 122 | }); 123 | 124 | it(`should close stream in case of newline flooding attack`, (done) => { 125 | streamLayer.close = async () => done(); 126 | streamLayer.emitter.emit('data', Buffer.alloc(110, '\n')); 127 | }); 128 | 129 | it(`should disconnect if not receiving the first frame within a certain period of time`, (done) => { 130 | streamLayer = newStreamLayer(); 131 | frameLayer = new StompFrameLayer(streamLayer, { connectTimeout: 1 }); 132 | const timeout = 100; 133 | const id = setTimeout(() => done(`Still connected, should be disconnected after timeout.`), timeout); 134 | streamLayer.close = async () => { 135 | clearTimeout(id); 136 | done(); 137 | }; 138 | }); 139 | 140 | it(`should keep connection open if receiving the first frame within a certain period of time`, (done) => { 141 | streamLayer = newStreamLayer(); 142 | frameLayer = new StompFrameLayer(streamLayer, { connectTimeout: 2 }); 143 | const connectFrameText = 'CONNECT\naccept-version:1.2\n\n\0'; 144 | streamLayer.emitter.emit('data', new Buffer(connectFrameText)); 145 | setTimeout(() => done(), 7); 146 | streamLayer.close = async () => done(`Disconnected. Should keep connection open after first frame.`); 147 | }); 148 | 149 | it(`should reset newline flooding counter after a certain period of time`, (done) => { 150 | streamLayer = newStreamLayer(); 151 | const resetTime = 20; 152 | frameLayer = new StompFrameLayer(streamLayer, { newlineFloodingResetTime: resetTime }); 153 | const doneTimeout = setTimeout(() => done(), resetTime + 5); 154 | streamLayer.close = async () => { 155 | clearTimeout(doneTimeout); 156 | done(`Disconnected. Should reset newline flooding counter.`); 157 | }; 158 | streamLayer.emitter.emit('data', Buffer.alloc(98, '\n')); 159 | setTimeout(() => streamLayer.emitter.emit('data', Buffer.alloc(5, '\n')), resetTime + 2); 160 | }); 161 | 162 | it(`should receive multiple frames in one data event, using content-length`, (done) => { 163 | const frames: StompFrame[] = [ 164 | new StompFrame('SEND', { 'content-length': '5' }, 'hello'), 165 | new StompFrame('SEND', { 'content-length': '5' }, 'world'), 166 | new StompFrame('SEND', { 'content-length': '1' }, '!'), 167 | ]; 168 | const latch = countdownLatch(3, done); 169 | let i = 0; 170 | frameLayer.emitter.on('frame', (frame: StompFrame) => 171 | check(() => expect(frame).to.deep.include(frames[i++]), latch) 172 | ); 173 | streamLayer.emitter.emit('data', new Buffer(`SEND\ncontent-length:5\n\nhello\0SEND\ncontent-length:5\n\nworld\0SEND\ncontent-length:1\n\n!\0`)); 174 | }); 175 | 176 | it(`should decode escaped characters correctly when receiving frames`, (done) => { 177 | const frameText = 'SEND\ndestination:/queue/a\ncookie:key\\cvalue\n\ntest\\nmessage\0'; 178 | const expectedFrame = new StompFrame('SEND', { 'destination': '/queue/a', cookie: 'key:value' }, `test\\nmessage`); 179 | frameLayer.emitter.on('frame', (frame: StompFrame) => { 180 | check(() => assert.deepEqual(frame, expectedFrame), done); 181 | }); 182 | streamLayer.emitter.emit('data', new Buffer(frameText)); 183 | }); 184 | 185 | it(`should close stream when reading an unsupported escape sequence`, (done) => { 186 | const frameText = 'SEND\ndestination:/queue/a\nkey:some\\tvalue\n\ntest message\0'; 187 | streamLayer.close = async () => done(); 188 | streamLayer.emitter.emit('data', new Buffer(frameText)); 189 | }); 190 | 191 | it(`should encode escape characters correctly when sending frames`, (done) => { 192 | const frameText = 'SEND\ncookie:key\\cvalue\ndestination:/queue/a\ncontent-length:13\n\ntest\nmessage\\\0'; 193 | streamLayer.send = async (data) => check(() => assert.equal(data, frameText), done); 194 | frameLayer.send(new StompFrame('SEND', { 'destination': '/queue/a', cookie: 'key:value' }, `test\nmessage\\`)); 195 | }); 196 | 197 | it(`should throw error when sending an unsupported character`, (done) => { 198 | frameLayer.send(new StompFrame('SEND', { 'destination': '/queue/a', 'key': 'test\tvalue' }, `test message`)) 199 | .catch((error) => check(() => assert.equal(error.message, 'Unsupported character detected.'), done)); 200 | }); 201 | 202 | }); 203 | -------------------------------------------------------------------------------- /test/heartbeatTests.ts: -------------------------------------------------------------------------------- 1 | import "mocha"; 2 | import { expect } from 'chai'; 3 | import * as WebSocket from 'ws'; 4 | import { StompServerSessionLayer, StompClientSessionLayer } from '../src/session'; 5 | import { 6 | StompClientCommandListener, StompServerCommandListener 7 | } from "../src/protocol"; 8 | import { setTimeout } from "timers"; 9 | import { noopFn } from "./helpers"; 10 | import { createStompServerSession, StompConfig, createStompClientSession } from "../src"; 11 | 12 | 13 | describe("STOMP Heart beating", function() { 14 | this.timeout(15000); 15 | 16 | const heartbeatMsg = "\0"; 17 | 18 | let server: WebSocket.Server; 19 | let socket: WebSocket; 20 | let clientSocket: WebSocket; 21 | 22 | let serverSession: StompServerSessionLayer; 23 | let serverListener: StompServerCommandListener; 24 | 25 | let clientSession: StompClientSessionLayer; 26 | let clientListener: StompClientCommandListener; 27 | 28 | beforeEach((done) => { 29 | 30 | clientListener = { 31 | onProtocolError: (_err) => { }, 32 | onEnd: noopFn 33 | } as StompClientCommandListener; 34 | 35 | server = new WebSocket.Server({ port: 58999 }, () => { 36 | clientSocket = new WebSocket("ws://localhost:58999/ws"); 37 | clientSocket.on("open", done); 38 | }); 39 | server.on("connection", _socket => { 40 | socket = _socket; 41 | }); 42 | 43 | serverListener = { 44 | onProtocolError: (_err) => { }, 45 | onEnd: noopFn 46 | } as StompServerCommandListener; 47 | 48 | }); 49 | 50 | afterEach((done) => { 51 | clientSocket.close(); 52 | server.close(done); 53 | }); 54 | 55 | it("should perform duplex heart-beat every 50ms and release timers", (done) => { 56 | let clientHeartbeatIncomingCount = 0; 57 | let serverHeartbeatIncomingCount = 0; 58 | 59 | const serverConfig: StompConfig = { 60 | heartbeat: { 61 | outgoingPeriod: 50, 62 | incomingPeriod: 50 63 | } 64 | }; 65 | 66 | const clientConfig: StompConfig = { 67 | heartbeat: { 68 | outgoingPeriod: 30, 69 | incomingPeriod: 30 70 | } 71 | }; 72 | 73 | socket.on("message", (data) => { 74 | if (data.toString() === heartbeatMsg) { 75 | serverHeartbeatIncomingCount++; 76 | } 77 | }); 78 | 79 | clientSocket.on("message", (data) => { 80 | if (data.toString() === heartbeatMsg) { 81 | clientHeartbeatIncomingCount++; 82 | } 83 | }); 84 | 85 | serverListener.onEnd = () => setTimeout(() => { 86 | expect(clientHeartbeatIncomingCount).lte(3); 87 | expect(serverHeartbeatIncomingCount).lte(3); 88 | expect(clientSession.frameLayer.heartbeat.outgoingTimer).eq(null); 89 | expect(clientSession.frameLayer.heartbeat.incomingTimer).eq(null); 90 | expect(serverSession.frameLayer.heartbeat.outgoingTimer).eq(null); 91 | expect(serverSession.frameLayer.heartbeat.incomingTimer).eq(null); 92 | done(); 93 | }, 110); 94 | 95 | setTimeout(() => { 96 | clientSession.disconnect(); 97 | }, 180); 98 | 99 | clientListener.connect = () => serverSession.connected({}); 100 | clientListener.disconnect = () => serverSession.receipt({}); 101 | 102 | serverSession = createStompServerSession(socket, clientListener, serverConfig); 103 | clientSession = createStompClientSession(clientSocket, serverListener, clientConfig); 104 | 105 | clientSession.connect({}); 106 | }); 107 | 108 | 109 | it("should not perform heartbeat", (done) => { 110 | let clientHeartbeatIncomingCount = 0; 111 | let serverHeartbeatIncomingCount = 0; 112 | 113 | const serverConfig: StompConfig = { 114 | heartbeat: { 115 | outgoingPeriod: 0, 116 | incomingPeriod: 0 117 | } 118 | }; 119 | 120 | const clientConfig: StompConfig = { 121 | heartbeat: { 122 | outgoingPeriod: 0, 123 | incomingPeriod: 0 124 | } 125 | }; 126 | 127 | socket.on("message", (data) => { 128 | if (data.toString() === heartbeatMsg) { 129 | serverHeartbeatIncomingCount++; 130 | } 131 | }); 132 | 133 | clientSocket.on("message", (data) => { 134 | if (data.toString() === heartbeatMsg) { 135 | clientHeartbeatIncomingCount++; 136 | } 137 | }); 138 | 139 | serverListener.onEnd = () => { 140 | expect(clientHeartbeatIncomingCount).eq(0); 141 | expect(serverHeartbeatIncomingCount).eq(0); 142 | done(); 143 | }; 144 | 145 | setTimeout(() => { 146 | clientSession.disconnect(); 147 | }, 100); 148 | 149 | serverSession = createStompServerSession(socket, clientListener, serverConfig); 150 | clientSession = createStompClientSession(clientSocket, serverListener, clientConfig); 151 | 152 | clientListener.connect = () => serverSession.connected({}); 153 | clientSession.connect({}); 154 | }); 155 | 156 | it("should perform one-direction heartbeat", (done) => { 157 | let clientHeartbeatIncomingCount = 0; 158 | let serverHeartbeatIncomingCount = 0; 159 | 160 | const serverConfig: StompConfig = { 161 | heartbeat: { 162 | outgoingPeriod: 0, 163 | incomingPeriod: 50 164 | } 165 | }; 166 | 167 | const clientConfig: StompConfig = { 168 | heartbeat: { 169 | outgoingPeriod: 50, 170 | incomingPeriod: 50 171 | } 172 | }; 173 | 174 | socket.on("message", (data) => { 175 | if (data.toString() === heartbeatMsg) { 176 | serverHeartbeatIncomingCount++; 177 | } 178 | }); 179 | 180 | clientSocket.on("message", (data) => { 181 | if (data.toString() === heartbeatMsg) { 182 | clientHeartbeatIncomingCount++; 183 | } 184 | }); 185 | 186 | serverListener.onEnd = () => { 187 | expect(clientHeartbeatIncomingCount).eq(0); 188 | expect(serverHeartbeatIncomingCount).eq(2); 189 | done(); 190 | }; 191 | 192 | setTimeout(() => { 193 | clientSession.disconnect(); 194 | }, 140); 195 | 196 | serverSession = createStompServerSession(socket, clientListener, serverConfig); 197 | clientSession = createStompClientSession(clientSocket, serverListener, clientConfig); 198 | 199 | clientListener.connect = () => serverSession.connected({}); 200 | clientSession.connect({}); 201 | }); 202 | 203 | 204 | it("should close connection due to a error and release timers", (done) => { 205 | let clientHeartbeatIncomingCount = 0; 206 | let serverHeartbeatIncomingCount = 0; 207 | 208 | const serverConfig: StompConfig = { 209 | heartbeat: { 210 | outgoingPeriod: 50, 211 | incomingPeriod: 50 212 | } 213 | }; 214 | 215 | const clientConfig: StompConfig = { 216 | heartbeat: { 217 | outgoingPeriod: 50, 218 | incomingPeriod: 50 219 | } 220 | }; 221 | 222 | socket.on("message", (data) => { 223 | if (data.toString() === heartbeatMsg) { 224 | serverHeartbeatIncomingCount++; 225 | } 226 | }); 227 | 228 | clientSocket.on("message", (data) => { 229 | if (data.toString() === heartbeatMsg) { 230 | clientHeartbeatIncomingCount++; 231 | } 232 | }); 233 | 234 | serverListener.connected = (headers) => { 235 | setTimeout(() => { 236 | serverSession.error({}); 237 | }, 190); 238 | }; 239 | 240 | clientListener.connect = () => serverSession.connected({}); 241 | 242 | clientSession = createStompClientSession(clientSocket, serverListener, clientConfig); 243 | 244 | clientSession.frameLayer.emitter.on("end", () => { 245 | 246 | expect(clientSession.frameLayer.heartbeat.outgoingTimer).eq(null); 247 | expect(clientSession.frameLayer.heartbeat.incomingTimer).eq(null); 248 | expect(serverSession.frameLayer.heartbeat.outgoingTimer).eq(null); 249 | expect(serverSession.frameLayer.heartbeat.incomingTimer).eq(null); 250 | 251 | expect(clientHeartbeatIncomingCount).eq(3); 252 | expect(serverHeartbeatIncomingCount).eq(3); 253 | done(); 254 | }); 255 | 256 | serverSession = createStompServerSession(socket, clientListener, serverConfig); 257 | 258 | clientSession.connect({}); 259 | }); 260 | 261 | it("should close connection if no heartbeat from other side", (done) => { 262 | let clientHeartbeatIncomingCount = 0; 263 | let serverHeartbeatIncomingCount = 0; 264 | 265 | const serverConfig: StompConfig = { 266 | heartbeat: { 267 | outgoingPeriod: 50, 268 | incomingPeriod: 50 269 | } 270 | }; 271 | 272 | const clientConfig: StompConfig = { 273 | heartbeat: { 274 | outgoingPeriod: 50, 275 | incomingPeriod: 50 276 | } 277 | }; 278 | 279 | socket.on("message", (data) => { 280 | if (data.toString() === heartbeatMsg) { 281 | serverHeartbeatIncomingCount++; 282 | } 283 | }); 284 | 285 | clientSocket.on("message", (data) => { 286 | if (data.toString() === heartbeatMsg) { 287 | clientHeartbeatIncomingCount++; 288 | } 289 | }); 290 | 291 | serverListener.connected = (headers) => { 292 | setTimeout(() => { 293 | serverSession.frameLayer.heartbeat.releaseTimers(); 294 | }, 20); 295 | }; 296 | 297 | clientListener.connect = () => serverSession.connected({}); 298 | 299 | clientSession = createStompClientSession(clientSocket, serverListener, clientConfig); 300 | 301 | clientSession.frameLayer.emitter.on("error", (data) => { 302 | expect(serverHeartbeatIncomingCount).gt(clientHeartbeatIncomingCount); 303 | 304 | expect(clientSession.frameLayer.heartbeat.outgoingTimer).eq(null); 305 | expect(clientSession.frameLayer.heartbeat.incomingTimer).eq(null); 306 | expect(serverSession.frameLayer.heartbeat.outgoingTimer).eq(null); 307 | expect(serverSession.frameLayer.heartbeat.incomingTimer).eq(null); 308 | 309 | done(); 310 | }); 311 | 312 | serverSession = createStompServerSession(socket, clientListener, serverConfig); 313 | 314 | clientSession.connect({}); 315 | }); 316 | 317 | }); 318 | -------------------------------------------------------------------------------- /test/helpers.ts: -------------------------------------------------------------------------------- 1 | 2 | export function check(f: Function, done: Function) { 3 | try { 4 | f(); 5 | done(); 6 | } catch (e) { 7 | done(e); 8 | } 9 | } 10 | 11 | 12 | export function countdownLatch(count: number, done: Function) { 13 | return (e?: any) => { 14 | if(e instanceof Error) { 15 | done(e); 16 | } else if(--count <= 0) { 17 | done(); 18 | } 19 | }; 20 | } 21 | 22 | 23 | export const noopFn = () => {}; 24 | 25 | export const noopAsyncFn = async () => {}; 26 | -------------------------------------------------------------------------------- /test/main_client.ts_: -------------------------------------------------------------------------------- 1 | import { Socket, createConnection } from 'net'; 2 | import { StompFrame, StompHeaders, StompError } from './model'; 3 | import { openStream } from './stream'; 4 | import { StompFrameLayer } from './frame'; 5 | import { StompClientSessionLayer } from './session'; 6 | import { StompServerCommandListener } from './protocol'; 7 | 8 | const socket = createConnection(9999, '127.0.0.1'); 9 | 10 | const streamLayer = openStream(socket); 11 | const frameLayer = new StompFrameLayer(streamLayer); 12 | const listener: StompServerCommandListener = { 13 | async connected(headers?: StompHeaders): Promise { 14 | console.log('Connected!', headers); 15 | await sessionLayer.subscribe({ destination: 'commonQueue', id: 'sub01', receipt: 'r01' }); 16 | await sessionLayer.send({ destination: 'commonQueue', receipt: 'r02' }, 'test message'); 17 | await sessionLayer.unsubscribe({ destination: 'commonQueue', id: 'sub01', receipt: 'r03' }); 18 | //await sessionLayer.unsubscribe({ destination: 'commonQueue', receipt: 'r04' }); 19 | }, 20 | async message(headers?: StompHeaders, body?: string): Promise { 21 | console.log('Message!', body, headers); 22 | //await sessionLayer.disconnect(); 23 | //await sessionLayer.send(undefined, 'this is the message body'); 24 | }, 25 | async receipt(headers?: StompHeaders): Promise { 26 | console.log('Receipt!', headers); 27 | if (headers && headers['receipt-id'] === 'r03') { 28 | await sessionLayer.disconnect(); 29 | } 30 | }, 31 | async error(headers?: StompHeaders, body?: string): Promise { 32 | console.log('Error!', headers, body); 33 | }, 34 | onProtocolError(error: StompError) { 35 | console.log('Protocol error!', error); 36 | }, 37 | onEnd() { 38 | console.log('End!'); 39 | } 40 | }; 41 | const sessionLayer = new StompClientSessionLayer(frameLayer, listener); 42 | 43 | new Promise((resolve, reject) => { 44 | socket.on('connect', resolve); 45 | }).then(() => { 46 | sessionLayer.connect({ 47 | 'host': '/', 48 | 'login': 'rabbit_user', 49 | 'passcode': 'rabbit_user' 50 | }); 51 | }); 52 | -------------------------------------------------------------------------------- /test/main_server.ts_: -------------------------------------------------------------------------------- 1 | import { Socket, createServer } from 'net'; 2 | import { StompFrame, StompHeaders, StompError } from './model'; 3 | import { openStream } from './stream'; 4 | import { StompFrameLayer } from './frame'; 5 | import { StompServerSessionLayer } from './session'; 6 | 7 | 8 | function testServer(socket: Socket) { 9 | 10 | const streamLayer = openStream(socket); 11 | const frameLayer = new StompFrameLayer(streamLayer); 12 | const listener = { 13 | 14 | async connect(headers?: StompHeaders): Promise { 15 | console.log('Connect!', headers); 16 | if (headers && headers.login === 'rabbit_user' && headers.passcode === 'rabbit_user') { 17 | sessionLayer.connected({ version: '1.2', server: 'MyServer/1.8.2' }); 18 | } else { 19 | sessionLayer.error({ message: 'Invalid login data' }); 20 | } 21 | }, 22 | async send(headers?: StompHeaders, body?: string): Promise { 23 | console.log('Send!', body, headers); 24 | sessionLayer.message({destination: 'commonQueue', 'message-id': '123456'}, 'This is the response message!'); 25 | }, 26 | 27 | async subscribe(headers?: StompHeaders): Promise { 28 | if (headers) { 29 | if (headers.destination === 'commonQueue') { 30 | console.log('subscription done to commonQueue'); 31 | } else { 32 | throw new StompError('Cannot subscribe to' + headers.destination); 33 | } 34 | } 35 | /*console.log(''); 36 | return Promise.resolve();*/ 37 | }, 38 | async unsubscribe(headers?: StompHeaders): Promise { 39 | console.log('unsubscribe', headers); 40 | }, 41 | async begin(headers?: StompHeaders): Promise { 42 | console.log('begin', headers); 43 | }, 44 | async commit(headers?: StompHeaders): Promise { 45 | console.log('commit', headers); 46 | }, 47 | async abort(headers?: StompHeaders): Promise { 48 | console.log('abort', headers); 49 | }, 50 | 51 | async ack(headers?: StompHeaders): Promise { 52 | console.log('ack', headers); 53 | }, 54 | async nack(headers?: StompHeaders): Promise { 55 | console.log('nack', headers); 56 | }, 57 | 58 | async disconnect(headers?: StompHeaders): Promise { 59 | console.log('Disconnect!', headers); 60 | }, 61 | 62 | onProtocolError(error: StompError) { 63 | console.log('Protocol error!', error); 64 | }, 65 | onEnd() { 66 | console.log('End!'); 67 | } 68 | }; 69 | const sessionLayer = new StompServerSessionLayer(frameLayer, listener); 70 | 71 | } 72 | 73 | const server = createServer(testServer); 74 | 75 | server.listen(9999, 'localhost'); 76 | 77 | /* 78 | socket.on('connect', () => { 79 | 80 | frameLayer.send(new StompFrame('CONNECT', { 81 | 'accept-version': '1.2', 82 | 'host': '/', 83 | 'login': 'guest', 84 | 'passcode': 'guest' 85 | })); 86 | 87 | }); 88 | */ 89 | 90 | 91 | /* 92 | var server = net.createServer((socket) => { 93 | socket.on('connect', () => { 94 | //console.log('Received Unsecured Connection'); 95 | //new StompStreamHandler(stream, queueManager); 96 | var stream = new StompStream(socket); 97 | var handler = new StompProtocolHandler(stream); 98 | 99 | }); 100 | }); 101 | */ 102 | -------------------------------------------------------------------------------- /test/main_ws_server.ts_: -------------------------------------------------------------------------------- 1 | import { StompFrame, StompHeaders, StompError } from './model'; 2 | import { openStream } from './stream'; 3 | import { StompFrameLayer } from './frame'; 4 | import { StompServerSessionLayer } from './session'; 5 | import * as WebSocket from 'ws'; 6 | 7 | 8 | function testServer(webSocket: WebSocket) { 9 | 10 | const streamLayer = openStream(webSocket); 11 | const frameLayer = new StompFrameLayer(streamLayer); 12 | const listener = { 13 | 14 | async connect(headers?: StompHeaders): Promise { 15 | console.log('Connect!', headers); 16 | if (headers && headers.login === 'rabbit_user' && headers.passcode === 'rabbit_user') { 17 | sessionLayer.connected({ version: '1.2', server: 'MyServer/1.8.2' }); 18 | } else { 19 | sessionLayer.error({ message: 'Invalid login data' }, 'Invalid login data'); 20 | } 21 | }, 22 | async send(headers?: StompHeaders, body?: string): Promise { 23 | console.log('Send!', body, headers); 24 | sessionLayer.message({ destination: 'commonQueue', 'message-id': '123456' }, 'This is the response message!'); 25 | }, 26 | 27 | async subscribe(headers?: StompHeaders): Promise { 28 | if (headers) { 29 | console.log('subscription done to ' + headers.destination); 30 | await sessionLayer.message({ 31 | destination: headers.destination, 32 | subscription: headers.id, 33 | 'message-id': '123456' 34 | }, 'This is a message!'); 35 | //await sessionLayer.message({ destination: headers.destination, 'message-id': '123456' }, 'This is the response message!'); 36 | /* 37 | if (headers.destination === 'commonQueue') { 38 | console.log('subscription done to commonQueue'); 39 | } else { 40 | throw new StompError('Cannot subscribe to' + headers.destination); 41 | }*/ 42 | } 43 | /*console.log(''); 44 | return Promise.resolve();*/ 45 | }, 46 | async unsubscribe(headers?: StompHeaders): Promise { 47 | console.log('unsubscribe', headers); 48 | }, 49 | async begin(headers?: StompHeaders): Promise { 50 | console.log('begin', headers); 51 | }, 52 | async commit(headers?: StompHeaders): Promise { 53 | console.log('commit', headers); 54 | }, 55 | async abort(headers?: StompHeaders): Promise { 56 | console.log('abort', headers); 57 | }, 58 | 59 | async ack(headers?: StompHeaders): Promise { 60 | console.log('ack', headers); 61 | }, 62 | async nack(headers?: StompHeaders): Promise { 63 | console.log('nack', headers); 64 | }, 65 | 66 | async disconnect(headers?: StompHeaders): Promise { 67 | console.log('Disconnect!', headers); 68 | }, 69 | 70 | onProtocolError(error: StompError) { 71 | console.log('Protocol error!', error); 72 | }, 73 | onEnd() { 74 | console.log('End!'); 75 | } 76 | }; 77 | const sessionLayer = new StompServerSessionLayer(frameLayer, listener); 78 | 79 | } 80 | 81 | const wss = new WebSocket.Server({ port: 8080 }); 82 | 83 | wss.on('connection', function connection(ws) { 84 | testServer(ws); 85 | }); 86 | -------------------------------------------------------------------------------- /test/mocha.opts: -------------------------------------------------------------------------------- 1 | --colors 2 | --compilers ts-node/register 3 | --require source-map-support/register 4 | --full-trace 5 | --bail 6 | test/**/*Tests.ts 7 | -------------------------------------------------------------------------------- /test/sessionTests.ts: -------------------------------------------------------------------------------- 1 | import 'mocha'; 2 | import { assert, expect } from 'chai'; 3 | import { StompFrame, StompEventEmitter, StompHeaders, StompError } from '../src/model'; 4 | import { StompFrameLayer } from '../src/frame'; 5 | import { StompServerSessionLayer, StompClientSessionLayer } from '../src/session'; 6 | import { 7 | StompClientCommandListener, StompServerCommandListener, StompProtocolHandlerV10, 8 | StompProtocolHandlerV11, StompProtocolHandlerV12 9 | } from '../src/protocol' 10 | import { check, countdownLatch, noopAsyncFn, noopFn } from './helpers'; 11 | 12 | describe('STOMP Server Session Layer', () => { 13 | let frameLayer: StompFrameLayer; 14 | let sessionLayer: StompServerSessionLayer; 15 | let clientListener: StompClientCommandListener; 16 | let unhandledRejection: boolean; 17 | 18 | process.on('unhandledRejection', () => unhandledRejection = true); 19 | 20 | beforeEach(() => { 21 | unhandledRejection = false; 22 | frameLayer = { 23 | emitter: new StompEventEmitter(), 24 | close: async () => { } 25 | }; 26 | clientListener = {} as StompClientCommandListener; 27 | sessionLayer = new StompServerSessionLayer(frameLayer, clientListener); 28 | }); 29 | 30 | it(`should handle valid CONNECT frame`, (done) => { 31 | const testHeaders = { login: 'user', passcode: 'pass' }; 32 | clientListener.connect = (headers) => { 33 | check(() => assert.deepEqual(testHeaders, headers), done); 34 | }; 35 | frameLayer.emitter.emit('frame', new StompFrame('CONNECT', testHeaders)); 36 | }); 37 | 38 | it(`should use protocol v.1.0`, (done) => { 39 | const testHeaders = { login: 'user', passcode: 'pass', 'accept-version': '1.0' }; 40 | clientListener.connect = (headers) => { 41 | check(() => assert.equal((sessionLayer).protocol, StompProtocolHandlerV10), done); 42 | }; 43 | frameLayer.emitter.emit('frame', new StompFrame('CONNECT', testHeaders)); 44 | }); 45 | 46 | it(`should switch to protocol v.1.1`, (done) => { 47 | const testHeaders = { login: 'user', passcode: 'pass', 'accept-version': '1.1' }; 48 | clientListener.connect = (headers) => { 49 | check(() => assert.equal((sessionLayer).protocol, StompProtocolHandlerV11), done); 50 | }; 51 | frameLayer.emitter.emit('frame', new StompFrame('CONNECT', testHeaders)); 52 | }); 53 | 54 | it(`should switch to protocol v.1.2`, (done) => { 55 | const testHeaders = { login: 'user', passcode: 'pass', 'accept-version': '1.2' }; 56 | clientListener.connect = (headers) => { 57 | check(() => assert.equal((sessionLayer).protocol, StompProtocolHandlerV12), done); 58 | }; 59 | frameLayer.emitter.emit('frame', new StompFrame('CONNECT', testHeaders)); 60 | }); 61 | 62 | it(`should send ERROR for unhandled protocol version`, (done) => { 63 | const testHeaders = { login: 'user', passcode: 'pass', 'accept-version': '2.1,2.2' }; 64 | frameLayer.send = async (frame) => { 65 | check(() => expect(frame) 66 | .to.deep.include({ command: 'ERROR', headers: { message: 'Supported protocol versions are: 1.0, 1.1, 1.2' } }), done); 67 | }; 68 | frameLayer.emitter.emit('frame', new StompFrame('CONNECT', testHeaders)); 69 | }); 70 | 71 | it(`should send ERROR for invalid command`, (done) => { 72 | frameLayer.send = async (frame) => { 73 | check(() => expect(frame) 74 | .to.deep.include({ command: 'ERROR', headers: { message: 'No such command' } }), done); 75 | }; 76 | frameLayer.emitter.emit('frame', new StompFrame('INVALID_CMD', {}, 'test')); 77 | }); 78 | 79 | it(`should send ERROR if did not received CONNECT yet`, (done) => { 80 | const testFrame = new StompFrame('SEND', { destination: '/queue/test' }, 'test message'); 81 | const latch = countdownLatch(2, done); 82 | frameLayer.close = async () => latch(); 83 | frameLayer.send = async (frame) => { 84 | check(() => expect(frame) 85 | .to.deep.include({ command: 'ERROR', headers: { message: 'You must first issue a CONNECT command' } }), latch); 86 | }; 87 | frameLayer.emitter.emit('frame', testFrame); 88 | }); 89 | 90 | it(`should send ERROR when catching exceptions from listener`, (done) => { 91 | clientListener.connect = (headers) => { 92 | throw new Error('login error'); 93 | }; 94 | frameLayer.send = async (frame) => { 95 | check(() => expect(frame) 96 | .to.deep.include({ command: 'ERROR', headers: { message: 'login error' } }), done); 97 | }; 98 | frameLayer.emitter.emit('frame', new StompFrame('CONNECT', {})); 99 | }); 100 | 101 | it(`should send ERROR for invalid frame`, (done) => { 102 | sessionLayer.data.authenticated = true; 103 | frameLayer.send = async (frame) => { 104 | check(() => expect(frame) 105 | .to.deep.include({ command: 'ERROR', headers: { 'message': `Header 'destination' is required for SEND` } }), done); 106 | }; 107 | frameLayer.emitter.emit('frame', new StompFrame('SEND', {}, 'test message')); 108 | }); 109 | 110 | it(`should handle protocol error`, (done) => { 111 | sessionLayer.data.authenticated = true; 112 | const error = new StompError('generic error'); 113 | clientListener.onProtocolError = (error) => { 114 | check(() => expect(error).to.deep.equal(error), done); 115 | }; 116 | frameLayer.emitter.emit('error', error); 117 | }); 118 | 119 | it(`should handle errors thrown during onError execution`, (done) => { 120 | const latch = countdownLatch(2, done); 121 | sessionLayer.internalErrorHandler = () => latch(); 122 | frameLayer.send = (frame: StompFrame) => { 123 | throw new Error('Unhandled error!'); 124 | }; 125 | frameLayer.emitter.emit('frame', new StompFrame('INVALIDFRAME', {})); 126 | setTimeout(() => { 127 | check(() => assert.equal(unhandledRejection, false), latch); 128 | }, 0); 129 | }); 130 | 131 | it(`should send headers and body for SEND frames`, (done) => { 132 | sessionLayer.data.authenticated = true; 133 | clientListener.send = (headers, body) => { 134 | check(() => expect(body).exist, done); 135 | }; 136 | frameLayer.emitter.emit('frame', new StompFrame('SEND', { destination: '/queue/test' }, 'test message')); 137 | }); 138 | 139 | it(`should handle frames using listener constructor`, (done) => { 140 | const testHeaders = { login: 'user', passcode: 'pass' }; 141 | class TestClientListener implements StompClientCommandListener { 142 | connect(headers: StompHeaders) { 143 | check(() => assert.deepEqual(testHeaders, headers), done); 144 | } 145 | send() { } 146 | subscribe() { } 147 | unsubscribe() { } 148 | begin() { } 149 | commit() { } 150 | abort() { } 151 | ack() { } 152 | nack() { } 153 | disconnect() { } 154 | onProtocolError() { } 155 | onEnd() { } 156 | } 157 | sessionLayer = new StompServerSessionLayer(frameLayer, TestClientListener); 158 | frameLayer.emitter.emit('frame', new StompFrame('CONNECT', testHeaders)); 159 | }); 160 | }); 161 | 162 | 163 | describe('STOMP Client Session Layer', () => { 164 | let frameLayer: StompFrameLayer; 165 | let sessionLayer: StompClientSessionLayer; 166 | let serverListener: StompServerCommandListener; 167 | let unhandledRejection: boolean; 168 | 169 | process.on('unhandledRejection', () => unhandledRejection = true); 170 | 171 | beforeEach(() => { 172 | unhandledRejection = false; 173 | frameLayer = { 174 | emitter: new StompEventEmitter(), 175 | close: noopAsyncFn 176 | }; 177 | serverListener = {} as StompServerCommandListener; 178 | sessionLayer = new StompClientSessionLayer(frameLayer, serverListener); 179 | sessionLayer.internalErrorHandler = console.error; 180 | }); 181 | 182 | it(`should send accept-version header in CONNECT frame`, (done) => { 183 | frameLayer.send = async (frame) => { 184 | check(() => expect(frame) 185 | .to.deep.include({ 186 | command: 'CONNECT', 187 | headers: { login: 'user', passcode: 'pass', 'accept-version': '1.0,1.1,1.2' } 188 | }), done); 189 | }; 190 | sessionLayer.connect({ login: 'user', passcode: 'pass' }); 191 | }); 192 | 193 | it(`should switch to protocol v.1.1`, (done) => { 194 | serverListener.connected = (headers) => { 195 | check(() => assert.equal((sessionLayer).protocol, StompProtocolHandlerV11), done); 196 | }; 197 | frameLayer.emitter.emit('frame', new StompFrame('CONNECTED', { version: '1.1' })); 198 | }); 199 | 200 | it(`should switch to protocol v.1.2`, (done) => { 201 | serverListener.connected = (headers) => { 202 | check(() => assert.equal((sessionLayer).protocol, StompProtocolHandlerV12), done); 203 | }; 204 | frameLayer.emitter.emit('frame', new StompFrame('CONNECTED', { version: '1.2' })); 205 | }); 206 | 207 | it(`should handle ERROR frame`, (done) => { 208 | const error = new StompFrame('ERROR', { message: 'generic error' }); 209 | serverListener.error = (headers) => { 210 | check(() => expect(headers) 211 | .to.deep.equal(error.headers), done); 212 | }; 213 | frameLayer.emitter.emit('frame', error); 214 | }); 215 | 216 | it(`should handle command internal errors gracefully`, (done) => { 217 | const latch = countdownLatch(2, done); 218 | sessionLayer.internalErrorHandler = () => latch(); 219 | serverListener.message = () => { 220 | throw new Error('Unhandled error!'); 221 | } 222 | frameLayer.emitter.emit('frame', new StompFrame('MESSAGE', { 'destination': '/queue/1', 'message-id': '1', 'subscription': '1' })); 223 | setTimeout(() => { 224 | check(() => assert.equal(unhandledRejection, false), latch); 225 | }, 0); 226 | }); 227 | 228 | }); 229 | -------------------------------------------------------------------------------- /test/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "moduleResolution": "node", 4 | "module": "CommonJS", 5 | "target": "ES6", 6 | "strict": true, 7 | "outDir": "./built", 8 | "typeRoots": ["../node_modules/@types"] 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "moduleResolution": "node", 4 | "module": "CommonJS", 5 | "target": "ES6", 6 | "inlineSourceMap": true, 7 | "outDir": "./dist", 8 | "declaration": true, 9 | "strict": true, 10 | "typeRoots": ["node_modules/@types"] 11 | }, 12 | "exclude": ["node_modules", "test", "dist"], 13 | "include": ["src/**/*"], 14 | "compileOnSave": true 15 | } 16 | --------------------------------------------------------------------------------