ws-stomp-server 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2023 mivui
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,103 @@
1
+ # ws-stomp-server
2
+
3
+ [![npm version](https://img.shields.io/npm/v/ws-stomp-server.svg?style=flat-square)](https://www.npmjs.com/package/ws-stomp-server)
4
+ [![Alt](https://img.shields.io/npm/dt/ws-stomp-server?style=flat-square)](https://npmcharts.com/compare/ws-stomp-server?minimal=true)
5
+ ![Alt](https://img.shields.io/github/license/mivui/ws-stomp-server?style=flat-square)
6
+
7
+ ## ws-stomp-server is a simple ws stomp server
8
+
9
+ ### install
10
+
11
+ ```shell
12
+ npm i ws-stomp-server
13
+ ```
14
+
15
+ ### http
16
+
17
+ ```ts
18
+ import { createServer } from 'http';
19
+ import { Stomp } from 'ws-stomp-server';
20
+
21
+ const server = createServer();
22
+ Stomp.server(server, '/ws');
23
+ // ws://lcalhost/ws
24
+ ```
25
+
26
+ ### express
27
+
28
+ ```ts
29
+ import express from 'express';
30
+ import { Stomp } from 'ws-stomp-server';
31
+
32
+ const app = express();
33
+ const server = app.listen();
34
+ Stomp.server(server, '/ws');
35
+ // ws://lcalhost/ws
36
+ ```
37
+
38
+ ### publish
39
+
40
+ ```ts
41
+ import { Stomp } from 'ws-stomp-server';
42
+
43
+ function publish() {
44
+ Stomp.publish('/example', JSON.stringify({ name: 'example' }), { token: 'example' });
45
+ }
46
+ ```
47
+
48
+ ### subscribe
49
+
50
+ ```ts
51
+ import { Stomp } from 'ws-stomp-server';
52
+
53
+ function subscribe() {
54
+ Stomp.subscribe('/example', (e) => {
55
+ const body = e.body;
56
+ });
57
+ }
58
+ ```
59
+
60
+ ### subscribe
61
+
62
+ ```ts
63
+ import { Stomp } from 'ws-stomp-server';
64
+
65
+ function unsubscribe() {
66
+ Stomp.unsubscribe('/example');
67
+ }
68
+ ```
69
+
70
+ ### server and client
71
+
72
+ ### server.js
73
+
74
+ ```ts
75
+ import express from 'express';
76
+ import { Stomp } from 'ws-stomp-server';
77
+
78
+ const app = express();
79
+ app.get('/send', (req, res) => {
80
+ Stomp.publish('/topic/test', JSON.stringify({ name: 'hello word!' }));
81
+ res.status(200);
82
+ });
83
+ const server = app.listen(8080);
84
+ Stomp.server(server, '/ws');
85
+ Stomp.subscribe('/topic/test', (e) => {
86
+ const body = e.body;
87
+ });
88
+ ```
89
+
90
+ ### browser.js
91
+
92
+ ```ts
93
+ import { Client } from '@stomp/stompjs';
94
+
95
+ const client = new Client({
96
+ brokerURL: 'ws://localhost:8080/ws',
97
+ onConnect: () => {
98
+ client.subscribe('/topic/test', (message) => console.log(message.body);
99
+ client.publish({ destination: '/topic/test', body: 'First Message' });
100
+ },
101
+ });
102
+ client.activate();
103
+ ```
@@ -0,0 +1,43 @@
1
+ import { Server } from 'http';
2
+ import { Server as Server$1 } from 'https';
3
+
4
+ declare enum StompCommand {
5
+ PING = "PING",
6
+ CONNECT = "CONNECT",
7
+ CONNECTED = "CONNECTED",
8
+ SEND = "SEND",
9
+ SUBSCRIBE = "SUBSCRIBE",
10
+ UNSUBSCRIBE = "UNSUBSCRIBE",
11
+ ACK = "ACK",
12
+ NACK = "NACK",
13
+ BEGIN = "BEGIN",
14
+ COMMIT = "COMMIT",
15
+ ABORT = "ABORT",
16
+ DISCONNECT = "DISCONNECT",
17
+ MESSAGE = "MESSAGE",
18
+ RECEIPT = "RECEIPT",
19
+ ERROR = "ERROR"
20
+ }
21
+ declare class StompFrame {
22
+ readonly command: StompCommand;
23
+ readonly headers: Record<string, string>;
24
+ readonly body: string;
25
+ constructor(command: StompCommand, headers?: Record<string, string>, body?: string);
26
+ serialize(): string;
27
+ static parse(data: Buffer): StompFrame;
28
+ }
29
+
30
+ interface AuthProvider {
31
+ authenticate: (frame: StompFrame) => Promise<boolean> | boolean;
32
+ }
33
+
34
+ declare class Stomp {
35
+ private constructor();
36
+ static server(server: Server | Server$1, path: string, authProvider?: AuthProvider): void;
37
+ private static get;
38
+ static publish(destination: string, body: string, headers?: Record<string, string>): void;
39
+ static subscribe(destination: string, callback: (frame: StompFrame) => void): void;
40
+ static unsubscribe(destination: string): void;
41
+ }
42
+
43
+ export { Stomp };
package/dist/index.js ADDED
@@ -0,0 +1,263 @@
1
+ import { v4 } from 'uuid';
2
+ import WebSocket, { WebSocketServer } from 'ws';
3
+
4
+ class Autowired {
5
+ constructor() { }
6
+ static container = {};
7
+ static register(name, instance) {
8
+ this.container[name] = instance;
9
+ }
10
+ static get(name) {
11
+ return this.container[name];
12
+ }
13
+ }
14
+
15
+ var StompCommand;
16
+ (function (StompCommand) {
17
+ StompCommand["PING"] = "PING";
18
+ StompCommand["CONNECT"] = "CONNECT";
19
+ StompCommand["CONNECTED"] = "CONNECTED";
20
+ StompCommand["SEND"] = "SEND";
21
+ StompCommand["SUBSCRIBE"] = "SUBSCRIBE";
22
+ StompCommand["UNSUBSCRIBE"] = "UNSUBSCRIBE";
23
+ StompCommand["ACK"] = "ACK";
24
+ StompCommand["NACK"] = "NACK";
25
+ StompCommand["BEGIN"] = "BEGIN";
26
+ StompCommand["COMMIT"] = "COMMIT";
27
+ StompCommand["ABORT"] = "ABORT";
28
+ StompCommand["DISCONNECT"] = "DISCONNECT";
29
+ StompCommand["MESSAGE"] = "MESSAGE";
30
+ StompCommand["RECEIPT"] = "RECEIPT";
31
+ StompCommand["ERROR"] = "ERROR";
32
+ })(StompCommand || (StompCommand = {}));
33
+ const BYTE = {
34
+ // LINEFEED byte (octet 10)
35
+ LF: '\x0A',
36
+ // NULL byte (octet 0)
37
+ NULL: '\x00',
38
+ };
39
+ class StompFrame {
40
+ command;
41
+ headers = {};
42
+ body = '';
43
+ constructor(command, headers = {}, body = '') {
44
+ this.command = command;
45
+ this.headers = headers;
46
+ this.body = body;
47
+ }
48
+ // 序列化为STOMP协议字符串
49
+ serialize() {
50
+ let output = `${this.command}${BYTE.LF}`;
51
+ for (const [key, value] of Object.entries(this.headers)) {
52
+ output += `${key}:${value}${BYTE.LF}`;
53
+ }
54
+ output += `${BYTE.LF}${this.body}${BYTE.NULL}`;
55
+ return output;
56
+ }
57
+ // 解析原始数据为STOMP帧
58
+ static parse(data) {
59
+ const frame = data.toString();
60
+ if (frame === BYTE.LF) {
61
+ return new StompFrame(StompCommand.PING);
62
+ }
63
+ const lines = frame.split(BYTE.LF);
64
+ let command = '';
65
+ const headers = {};
66
+ let body = '';
67
+ let inHeaderSection = true;
68
+ for (let i = 0; i < lines.length; i++) {
69
+ const line = lines[i].trim();
70
+ if (i === 0 && !line.endsWith(BYTE.NULL)) {
71
+ // 首行为命令行
72
+ command = line;
73
+ }
74
+ else if (!line || line === '') {
75
+ inHeaderSection = false; // 开始进入 body
76
+ }
77
+ else if (inHeaderSection) {
78
+ const [key, value] = line.split(':', 2);
79
+ if (key !== undefined && value !== undefined) {
80
+ headers[key.trim()] = value.trim();
81
+ }
82
+ }
83
+ else {
84
+ body += `${line}\n`; // 注意这里可能需要处理最后多出的一个换行
85
+ }
86
+ // 如果遇到 \0,则说明是最后一个字符
87
+ if (line.includes(BYTE.NULL)) {
88
+ break;
89
+ }
90
+ }
91
+ body = body ? body.slice(0, -1).replace(/\x00/g, '') : ''; //处理body数据
92
+ return new StompFrame(command, headers, body);
93
+ }
94
+ }
95
+
96
+ class StompServer {
97
+ ws;
98
+ subscribeClients = []; //已订阅的客户端
99
+ subscribehandler = new Map(); //服务端订阅处理
100
+ authProvider;
101
+ constructor(options) {
102
+ const { server, path, authProvider } = options;
103
+ this.authProvider = authProvider;
104
+ this.ws = new WebSocketServer({ server, path });
105
+ this.ws.on('connection', (ws) => {
106
+ ws.on('message', (data) => {
107
+ this.handleMessage(data, ws);
108
+ });
109
+ ws.on('close', () => {
110
+ this.cleanupSubscriptions(ws);
111
+ });
112
+ });
113
+ }
114
+ static server(options) {
115
+ return new StompServer(options);
116
+ }
117
+ async handleMessage(data, ws) {
118
+ try {
119
+ const frame = StompFrame.parse(data);
120
+ // console.log(frame);
121
+ switch (frame.command) {
122
+ case StompCommand.CONNECT:
123
+ await this.handleConnect(frame, ws);
124
+ break;
125
+ case StompCommand.SEND:
126
+ this.handleSend(frame, ws);
127
+ break;
128
+ case StompCommand.SUBSCRIBE:
129
+ this.handleSubscribe(frame, ws);
130
+ break;
131
+ case StompCommand.UNSUBSCRIBE:
132
+ this.handleUnsubscribe(frame, ws);
133
+ break;
134
+ case StompCommand.DISCONNECT:
135
+ this.handleDisconnect(ws);
136
+ break;
137
+ case StompCommand.PING: {
138
+ this.cleanupWebsockets();
139
+ const timer = setTimeout(() => {
140
+ clearTimeout(timer);
141
+ ws.ping();
142
+ ws.send(BYTE.LF);
143
+ }, 1000);
144
+ // console.log('ping');
145
+ break;
146
+ }
147
+ case StompCommand.ACK:
148
+ case StompCommand.NACK:
149
+ break;
150
+ default:
151
+ this.sendError(ws, `Unsupported command: ${frame.command}`);
152
+ }
153
+ }
154
+ catch (error) {
155
+ console.log(error);
156
+ this.sendError(ws, 'Invalid frame format');
157
+ }
158
+ }
159
+ async handleConnect(frame, ws) {
160
+ // 认证检查(如果启用)
161
+ if (this.authProvider && !(await this.authProvider.authenticate(frame))) {
162
+ this.sendError(ws, 'Authentication failed');
163
+ ws.close();
164
+ return;
165
+ }
166
+ const heartBeat = frame.headers['heart-beat'] || '0,0';
167
+ const connectedFrame = new StompFrame(StompCommand.CONNECTED, {
168
+ version: '1.2',
169
+ 'heart-beat': heartBeat,
170
+ });
171
+ ws.send(connectedFrame.serialize());
172
+ }
173
+ handleSend(frame, _ws) {
174
+ const { destination } = frame.headers;
175
+ this.subscribehandler.forEach((callback, key) => {
176
+ if (destination === key) {
177
+ callback(frame);
178
+ }
179
+ });
180
+ }
181
+ handleSubscribe(frame, ws) {
182
+ const subId = frame.headers.id;
183
+ const { destination } = frame.headers;
184
+ if (!subId || !destination) {
185
+ this.sendError(ws, 'Missing subscription headers');
186
+ return;
187
+ }
188
+ this.subscribeClients.push({
189
+ id: subId,
190
+ destination,
191
+ ws,
192
+ });
193
+ }
194
+ handleUnsubscribe(frame, ws) {
195
+ const subId = frame.headers.id;
196
+ this.subscribeClients = this.subscribeClients.filter((sub) => !(sub.id === subId && sub.ws === ws));
197
+ }
198
+ handleDisconnect(ws) {
199
+ this.cleanupSubscriptions(ws);
200
+ ws.close();
201
+ }
202
+ // 清理断开连接的订阅
203
+ cleanupSubscriptions(ws) {
204
+ this.subscribeClients = this.subscribeClients.filter((sub) => sub.ws !== ws);
205
+ }
206
+ // 清理断开连接的WebSocket
207
+ cleanupWebsockets() {
208
+ this.subscribeClients = this.subscribeClients.filter((sub) => sub.ws.readyState === WebSocket.OPEN);
209
+ }
210
+ // 服务端主动发送消息
211
+ send(destination, body, headers = {}) {
212
+ const subs = this.subscribeClients.filter((sub) => sub.destination === destination);
213
+ if (subs.length) {
214
+ subs.forEach((sub) => {
215
+ const frame = new StompFrame(StompCommand.MESSAGE, {
216
+ destination,
217
+ 'message-id': v4(),
218
+ timestamp: `${Date.now()}`,
219
+ subscription: sub.id,
220
+ ...headers,
221
+ }, body);
222
+ if (sub.ws.readyState === WebSocket.OPEN) {
223
+ sub.ws.send(frame.serialize());
224
+ }
225
+ });
226
+ return;
227
+ }
228
+ console.log(`${destination} unsubscribed client!`);
229
+ }
230
+ subscribe(destination, callback) {
231
+ this.subscribehandler.set(destination, callback);
232
+ }
233
+ unsubscribe(destination) {
234
+ this.subscribehandler.delete(destination);
235
+ }
236
+ sendError(ws, message) {
237
+ const errorFrame = new StompFrame(StompCommand.ERROR, { 'content-type': 'text/plain' }, message);
238
+ ws.send(errorFrame.serialize());
239
+ }
240
+ }
241
+
242
+ const className = 'stompServer';
243
+ class Stomp {
244
+ constructor() { }
245
+ static server(server, path, authProvider) {
246
+ const stomp = StompServer.server({ server, path, authProvider });
247
+ Autowired.register(className, stomp);
248
+ }
249
+ static get() {
250
+ return Autowired.get(className);
251
+ }
252
+ static publish(destination, body, headers = {}) {
253
+ this.get()?.send(destination, body, headers);
254
+ }
255
+ static subscribe(destination, callback) {
256
+ this.get()?.subscribe(destination, callback);
257
+ }
258
+ static unsubscribe(destination) {
259
+ this.get()?.unsubscribe(destination);
260
+ }
261
+ }
262
+
263
+ export { Stomp };
package/package.json ADDED
@@ -0,0 +1,56 @@
1
+ {
2
+ "name": "ws-stomp-server",
3
+ "version": "1.0.0",
4
+ "description": "ws-stomp-server",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "types": "dist/index.d.ts",
8
+ "files": [
9
+ "dist"
10
+ ],
11
+ "scripts": {
12
+ "test": "vitest",
13
+ "eslint": "eslint --fix **/*.{ts,js}",
14
+ "rollup": "rollup -c rollup.config.js",
15
+ "build": "npm run rollup"
16
+ },
17
+ "exports": {
18
+ ".": {
19
+ "types": "./dist/index.d.ts",
20
+ "import": "./dist/index.js"
21
+ },
22
+ "./*": "./*"
23
+ },
24
+ "dependencies": {
25
+ "uuid": "^13.0.0",
26
+ "ws": "^8.19.0",
27
+ "@types/ws": "^8.18.1"
28
+ },
29
+ "devDependencies": {
30
+ "@rollup/plugin-commonjs": "^29.0.0",
31
+ "@rollup/plugin-json": "^6.1.0",
32
+ "@rollup/plugin-node-resolve": "^16.0.3",
33
+ "@rollup/plugin-typescript": "^12.3.0",
34
+ "@types/node": "^25.0.3",
35
+ "rollup": "^4.55.1",
36
+ "rollup-plugin-dts": "^6.3.0",
37
+ "tslib": "^2.8.1",
38
+ "typescript": "^5.9.3",
39
+ "typescript-eslint-standard": "^9.75.0"
40
+ },
41
+ "author": "mivui",
42
+ "license": "MIT",
43
+ "keywords": [
44
+ "stomp",
45
+ "ws",
46
+ "node"
47
+ ],
48
+ "repository": {
49
+ "type": "git",
50
+ "url": "git+https://github.com/mivui/ws-stomp-server.git"
51
+ },
52
+ "bugs": {
53
+ "url": "https://github.com/mivui/ws-stomp-server/issues"
54
+ },
55
+ "homepage": "https://github.com/mivui/ws-stomp-server#readme"
56
+ }