evstream 1.0.0 → 1.0.2
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/.husky/pre-commit +1 -0
- package/.prettierignore +15 -0
- package/.prettierrc +8 -0
- package/dist/adapters/redis.d.ts +10 -0
- package/dist/adapters/redis.js +70 -0
- package/dist/manager.js +25 -11
- package/dist/state.d.ts +1 -1
- package/dist/state.js +27 -3
- package/dist/stream.d.ts +1 -0
- package/dist/stream.js +25 -7
- package/dist/types.d.ts +6 -0
- package/dist/utils.js +5 -5
- package/package.json +71 -45
- package/readme.md +54 -5
- package/src/adapters/redis.ts +53 -0
- package/src/errors.ts +25 -0
- package/src/index.ts +28 -0
- package/src/manager.ts +171 -0
- package/src/message.ts +19 -0
- package/src/state.ts +84 -0
- package/src/stream.ts +122 -0
- package/src/types.ts +56 -0
- package/src/utils.ts +29 -0
- package/tsconfig.json +16 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
npm run lint-staged
|
package/.prettierignore
ADDED
package/.prettierrc
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { RedisOptions } from 'ioredis';
|
|
2
|
+
import { EvStateAdapter } from '../types.js';
|
|
3
|
+
export declare class EvRedisAdapter implements EvStateAdapter {
|
|
4
|
+
#private;
|
|
5
|
+
constructor(options?: RedisOptions);
|
|
6
|
+
publish(channel: string, message: any): Promise<void>;
|
|
7
|
+
subscribe(channel: string, onMessage: (message: any) => void): Promise<void>;
|
|
8
|
+
unsubscribe(channel: string): Promise<void>;
|
|
9
|
+
quit(): void;
|
|
10
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
|
2
|
+
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
|
3
|
+
return new (P || (P = Promise))(function (resolve, reject) {
|
|
4
|
+
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
|
5
|
+
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
|
6
|
+
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
|
7
|
+
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
|
8
|
+
});
|
|
9
|
+
};
|
|
10
|
+
var __classPrivateFieldSet = (this && this.__classPrivateFieldSet) || function (receiver, state, value, kind, f) {
|
|
11
|
+
if (kind === "m") throw new TypeError("Private method is not writable");
|
|
12
|
+
if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a setter");
|
|
13
|
+
if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot write private member to an object whose class did not declare it");
|
|
14
|
+
return (kind === "a" ? f.call(receiver, value) : f ? f.value = value : state.set(receiver, value)), value;
|
|
15
|
+
};
|
|
16
|
+
var __classPrivateFieldGet = (this && this.__classPrivateFieldGet) || function (receiver, state, kind, f) {
|
|
17
|
+
if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a getter");
|
|
18
|
+
if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot read private member from an object whose class did not declare it");
|
|
19
|
+
return kind === "m" ? f : kind === "a" ? f.call(receiver) : f ? f.value : state.get(receiver);
|
|
20
|
+
};
|
|
21
|
+
var _EvRedisAdapter_pub, _EvRedisAdapter_sub, _EvRedisAdapter_listeners;
|
|
22
|
+
import Redis from 'ioredis';
|
|
23
|
+
export class EvRedisAdapter {
|
|
24
|
+
constructor(options) {
|
|
25
|
+
_EvRedisAdapter_pub.set(this, void 0);
|
|
26
|
+
_EvRedisAdapter_sub.set(this, void 0);
|
|
27
|
+
_EvRedisAdapter_listeners.set(this, void 0);
|
|
28
|
+
__classPrivateFieldSet(this, _EvRedisAdapter_pub, new Redis(options), "f");
|
|
29
|
+
__classPrivateFieldSet(this, _EvRedisAdapter_sub, new Redis(options), "f");
|
|
30
|
+
__classPrivateFieldSet(this, _EvRedisAdapter_listeners, new Map(), "f");
|
|
31
|
+
__classPrivateFieldGet(this, _EvRedisAdapter_sub, "f").on('message', (channel, message) => {
|
|
32
|
+
const handlers = __classPrivateFieldGet(this, _EvRedisAdapter_listeners, "f").get(channel);
|
|
33
|
+
if (handlers) {
|
|
34
|
+
let parsed;
|
|
35
|
+
try {
|
|
36
|
+
parsed = JSON.parse(message);
|
|
37
|
+
}
|
|
38
|
+
catch (_a) {
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
handlers.forEach((handler) => handler(parsed));
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
publish(channel, message) {
|
|
46
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
47
|
+
yield __classPrivateFieldGet(this, _EvRedisAdapter_pub, "f").publish(channel, JSON.stringify(message));
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
subscribe(channel, onMessage) {
|
|
51
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
52
|
+
if (!__classPrivateFieldGet(this, _EvRedisAdapter_listeners, "f").has(channel)) {
|
|
53
|
+
__classPrivateFieldGet(this, _EvRedisAdapter_listeners, "f").set(channel, new Set());
|
|
54
|
+
yield __classPrivateFieldGet(this, _EvRedisAdapter_sub, "f").subscribe(channel);
|
|
55
|
+
}
|
|
56
|
+
__classPrivateFieldGet(this, _EvRedisAdapter_listeners, "f").get(channel).add(onMessage);
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
unsubscribe(channel) {
|
|
60
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
61
|
+
yield __classPrivateFieldGet(this, _EvRedisAdapter_sub, "f").unsubscribe(channel);
|
|
62
|
+
__classPrivateFieldGet(this, _EvRedisAdapter_listeners, "f").delete(channel);
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
quit() {
|
|
66
|
+
__classPrivateFieldGet(this, _EvRedisAdapter_pub, "f").quit();
|
|
67
|
+
__classPrivateFieldGet(this, _EvRedisAdapter_sub, "f").quit();
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
_EvRedisAdapter_pub = new WeakMap(), _EvRedisAdapter_sub = new WeakMap(), _EvRedisAdapter_listeners = new WeakMap();
|
package/dist/manager.js
CHANGED
|
@@ -63,17 +63,29 @@ export class EvStreamManager {
|
|
|
63
63
|
onClose(channel);
|
|
64
64
|
}
|
|
65
65
|
isClosed = true;
|
|
66
|
+
// Remove close event listener to prevent memory leaks
|
|
67
|
+
res.removeAllListeners('close');
|
|
68
|
+
// Clean up client
|
|
66
69
|
client.close();
|
|
70
|
+
// Decrement count
|
|
67
71
|
__classPrivateFieldSet(this, _EvStreamManager_count, __classPrivateFieldGet(this, _EvStreamManager_count, "f") - 1, "f");
|
|
72
|
+
// Remove from all channels
|
|
68
73
|
channel.forEach(chan => __classPrivateFieldGet(this, _EvStreamManager_instances, "m", _EvStreamManager_unlisten).call(this, chan, id));
|
|
74
|
+
// Clear channel array to release references
|
|
75
|
+
channel.length = 0;
|
|
76
|
+
// Remove client from map
|
|
69
77
|
__classPrivateFieldGet(this, _EvStreamManager_clients, "f").delete(id);
|
|
70
|
-
|
|
78
|
+
// End response if not already ended
|
|
79
|
+
if (!res.writableEnded) {
|
|
80
|
+
res.end();
|
|
81
|
+
}
|
|
71
82
|
};
|
|
72
|
-
|
|
83
|
+
const onCloseHandler = () => {
|
|
73
84
|
if (!isClosed) {
|
|
74
85
|
close();
|
|
75
86
|
}
|
|
76
|
-
}
|
|
87
|
+
};
|
|
88
|
+
res.on('close', onCloseHandler);
|
|
77
89
|
return {
|
|
78
90
|
authenticate: client.authenticate.bind(client),
|
|
79
91
|
message: client.message.bind(client),
|
|
@@ -91,6 +103,8 @@ export class EvStreamManager {
|
|
|
91
103
|
*/
|
|
92
104
|
send(name, msg) {
|
|
93
105
|
const listeners = __classPrivateFieldGet(this, _EvStreamManager_listeners, "f").get(name);
|
|
106
|
+
if (!listeners)
|
|
107
|
+
return;
|
|
94
108
|
for (const [_, id] of listeners.entries()) {
|
|
95
109
|
const client = __classPrivateFieldGet(this, _EvStreamManager_clients, "f").get(id);
|
|
96
110
|
if (client) {
|
|
@@ -102,15 +116,15 @@ export class EvStreamManager {
|
|
|
102
116
|
}
|
|
103
117
|
}
|
|
104
118
|
_EvStreamManager_clients = new WeakMap(), _EvStreamManager_listeners = new WeakMap(), _EvStreamManager_count = new WeakMap(), _EvStreamManager_maxConnections = new WeakMap(), _EvStreamManager_maxListeners = new WeakMap(), _EvStreamManager_id = new WeakMap(), _EvStreamManager_instances = new WeakSet(), _EvStreamManager_listen = function _EvStreamManager_listen(name, id) {
|
|
105
|
-
|
|
106
|
-
if (!
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
119
|
+
let listeners = __classPrivateFieldGet(this, _EvStreamManager_listeners, "f").get(name);
|
|
120
|
+
if (!listeners) {
|
|
121
|
+
listeners = new Set();
|
|
122
|
+
__classPrivateFieldGet(this, _EvStreamManager_listeners, "f").set(name, listeners);
|
|
123
|
+
}
|
|
124
|
+
if (listeners.size >= __classPrivateFieldGet(this, _EvStreamManager_maxListeners, "f")) {
|
|
125
|
+
throw new EvMaxListenerError(listeners.size, name);
|
|
112
126
|
}
|
|
113
|
-
|
|
127
|
+
listeners.add(id);
|
|
114
128
|
}, _EvStreamManager_unlisten = function _EvStreamManager_unlisten(name, id) {
|
|
115
129
|
const isListenerExists = __classPrivateFieldGet(this, _EvStreamManager_listeners, "f").get(name);
|
|
116
130
|
if (isListenerExists) {
|
package/dist/state.d.ts
CHANGED
|
@@ -5,7 +5,7 @@ type EvSetState<T> = (val: T) => T;
|
|
|
5
5
|
*/
|
|
6
6
|
export declare class EvState<T> {
|
|
7
7
|
#private;
|
|
8
|
-
constructor({ channel, initialValue, manager, key }: EvStateOptions<T>);
|
|
8
|
+
constructor({ channel, initialValue, manager, key, adapter, }: EvStateOptions<T>);
|
|
9
9
|
/**
|
|
10
10
|
* Returns the current state value.
|
|
11
11
|
*/
|
package/dist/state.js
CHANGED
|
@@ -9,22 +9,30 @@ var __classPrivateFieldGet = (this && this.__classPrivateFieldGet) || function (
|
|
|
9
9
|
if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot read private member from an object whose class did not declare it");
|
|
10
10
|
return kind === "m" ? f : kind === "a" ? f.call(receiver) : f ? f.value : state.get(receiver);
|
|
11
11
|
};
|
|
12
|
-
var _EvState_value, _EvState_channel, _EvState_manager, _EvState_key;
|
|
12
|
+
var _EvState_instances, _EvState_value, _EvState_channel, _EvState_manager, _EvState_key, _EvState_adapter, _EvState_handleRemoteUpdate;
|
|
13
13
|
import loadash from 'lodash';
|
|
14
14
|
const { isEqual } = loadash;
|
|
15
15
|
/**
|
|
16
16
|
* EvState holds a reactive state and broadcasts updates to a channel using EvStreamManager.
|
|
17
17
|
*/
|
|
18
18
|
export class EvState {
|
|
19
|
-
constructor({ channel, initialValue, manager, key }) {
|
|
19
|
+
constructor({ channel, initialValue, manager, key, adapter, }) {
|
|
20
|
+
_EvState_instances.add(this);
|
|
20
21
|
_EvState_value.set(this, void 0);
|
|
21
22
|
_EvState_channel.set(this, void 0);
|
|
22
23
|
_EvState_manager.set(this, void 0);
|
|
23
24
|
_EvState_key.set(this, void 0);
|
|
25
|
+
_EvState_adapter.set(this, void 0);
|
|
24
26
|
__classPrivateFieldSet(this, _EvState_value, initialValue, "f");
|
|
25
27
|
__classPrivateFieldSet(this, _EvState_channel, channel, "f");
|
|
26
28
|
__classPrivateFieldSet(this, _EvState_manager, manager, "f");
|
|
27
29
|
__classPrivateFieldSet(this, _EvState_key, key || 'value', "f");
|
|
30
|
+
__classPrivateFieldSet(this, _EvState_adapter, adapter, "f");
|
|
31
|
+
if (__classPrivateFieldGet(this, _EvState_adapter, "f")) {
|
|
32
|
+
__classPrivateFieldGet(this, _EvState_adapter, "f").subscribe(__classPrivateFieldGet(this, _EvState_channel, "f"), (data) => {
|
|
33
|
+
__classPrivateFieldGet(this, _EvState_instances, "m", _EvState_handleRemoteUpdate).call(this, data);
|
|
34
|
+
});
|
|
35
|
+
}
|
|
28
36
|
}
|
|
29
37
|
/**
|
|
30
38
|
* Returns the current state value.
|
|
@@ -46,7 +54,23 @@ export class EvState {
|
|
|
46
54
|
[__classPrivateFieldGet(this, _EvState_key, "f")]: newValue,
|
|
47
55
|
},
|
|
48
56
|
});
|
|
57
|
+
if (__classPrivateFieldGet(this, _EvState_adapter, "f")) {
|
|
58
|
+
__classPrivateFieldGet(this, _EvState_adapter, "f").publish(__classPrivateFieldGet(this, _EvState_channel, "f"), { [__classPrivateFieldGet(this, _EvState_key, "f")]: newValue });
|
|
59
|
+
}
|
|
49
60
|
}
|
|
50
61
|
}
|
|
51
62
|
}
|
|
52
|
-
_EvState_value = new WeakMap(), _EvState_channel = new WeakMap(), _EvState_manager = new WeakMap(), _EvState_key = new WeakMap()
|
|
63
|
+
_EvState_value = new WeakMap(), _EvState_channel = new WeakMap(), _EvState_manager = new WeakMap(), _EvState_key = new WeakMap(), _EvState_adapter = new WeakMap(), _EvState_instances = new WeakSet(), _EvState_handleRemoteUpdate = function _EvState_handleRemoteUpdate(data) {
|
|
64
|
+
if (data && typeof data === 'object' && __classPrivateFieldGet(this, _EvState_key, "f") in data) {
|
|
65
|
+
const newValue = data[__classPrivateFieldGet(this, _EvState_key, "f")];
|
|
66
|
+
if (!isEqual(newValue, __classPrivateFieldGet(this, _EvState_value, "f"))) {
|
|
67
|
+
__classPrivateFieldSet(this, _EvState_value, newValue, "f");
|
|
68
|
+
__classPrivateFieldGet(this, _EvState_manager, "f").send(__classPrivateFieldGet(this, _EvState_channel, "f"), {
|
|
69
|
+
event: __classPrivateFieldGet(this, _EvState_channel, "f"),
|
|
70
|
+
data: {
|
|
71
|
+
[__classPrivateFieldGet(this, _EvState_key, "f")]: newValue,
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
};
|
package/dist/stream.d.ts
CHANGED
package/dist/stream.js
CHANGED
|
@@ -18,7 +18,7 @@ var __classPrivateFieldGet = (this && this.__classPrivateFieldGet) || function (
|
|
|
18
18
|
if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot read private member from an object whose class did not declare it");
|
|
19
19
|
return kind === "m" ? f : kind === "a" ? f.call(receiver) : f ? f.value : state.get(receiver);
|
|
20
20
|
};
|
|
21
|
-
var _Evstream_res, _Evstream_opts, _Evstream_url;
|
|
21
|
+
var _Evstream_instances, _Evstream_res, _Evstream_opts, _Evstream_url, _Evstream_heartbeatInterval, _Evstream_onCloseHandler, _Evstream_clearHeartbeat, _Evstream_removeCloseListener;
|
|
22
22
|
import { message } from './message.js';
|
|
23
23
|
/**
|
|
24
24
|
* Evstream manages a Server-Sent Events (SSE) connection.
|
|
@@ -33,9 +33,12 @@ import { message } from './message.js';
|
|
|
33
33
|
*/
|
|
34
34
|
export class Evstream {
|
|
35
35
|
constructor(req, res, opts) {
|
|
36
|
+
_Evstream_instances.add(this);
|
|
36
37
|
_Evstream_res.set(this, void 0);
|
|
37
38
|
_Evstream_opts.set(this, void 0);
|
|
38
39
|
_Evstream_url.set(this, void 0);
|
|
40
|
+
_Evstream_heartbeatInterval.set(this, void 0);
|
|
41
|
+
_Evstream_onCloseHandler.set(this, void 0);
|
|
39
42
|
__classPrivateFieldSet(this, _Evstream_res, res, "f");
|
|
40
43
|
__classPrivateFieldSet(this, _Evstream_opts, opts, "f");
|
|
41
44
|
__classPrivateFieldSet(this, _Evstream_url, new URL(req.url, `http://${req.headers.host}`), "f");
|
|
@@ -44,12 +47,13 @@ export class Evstream {
|
|
|
44
47
|
__classPrivateFieldGet(this, _Evstream_res, "f").setHeader('Connection', 'keep-alive');
|
|
45
48
|
__classPrivateFieldGet(this, _Evstream_res, "f").flushHeaders();
|
|
46
49
|
if (opts === null || opts === void 0 ? void 0 : opts.heartbeat) {
|
|
47
|
-
|
|
50
|
+
__classPrivateFieldSet(this, _Evstream_heartbeatInterval, setInterval(() => {
|
|
48
51
|
__classPrivateFieldGet(this, _Evstream_res, "f").write(message({ event: 'heartbeat', data: '' }));
|
|
49
|
-
}, __classPrivateFieldGet(this, _Evstream_opts, "f").heartbeat);
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
});
|
|
52
|
+
}, __classPrivateFieldGet(this, _Evstream_opts, "f").heartbeat), "f");
|
|
53
|
+
__classPrivateFieldSet(this, _Evstream_onCloseHandler, () => {
|
|
54
|
+
__classPrivateFieldGet(this, _Evstream_instances, "m", _Evstream_clearHeartbeat).call(this);
|
|
55
|
+
}, "f");
|
|
56
|
+
__classPrivateFieldGet(this, _Evstream_res, "f").on('close', __classPrivateFieldGet(this, _Evstream_onCloseHandler, "f"));
|
|
53
57
|
}
|
|
54
58
|
}
|
|
55
59
|
/**
|
|
@@ -63,6 +67,7 @@ export class Evstream {
|
|
|
63
67
|
const isAuthenticated = yield __classPrivateFieldGet(this, _Evstream_opts, "f").authentication.verify(token);
|
|
64
68
|
if (typeof isAuthenticated === 'boolean') {
|
|
65
69
|
if (!isAuthenticated) {
|
|
70
|
+
__classPrivateFieldGet(this, _Evstream_instances, "m", _Evstream_clearHeartbeat).call(this);
|
|
66
71
|
this.message({
|
|
67
72
|
data: { message: 'authentication failed' },
|
|
68
73
|
event: 'error',
|
|
@@ -89,8 +94,11 @@ export class Evstream {
|
|
|
89
94
|
}
|
|
90
95
|
/**
|
|
91
96
|
* Sends an "end" event and closes the SSE connection.
|
|
97
|
+
* Cleans up heartbeat interval and event listeners to prevent memory leaks.
|
|
92
98
|
*/
|
|
93
99
|
close() {
|
|
100
|
+
__classPrivateFieldGet(this, _Evstream_instances, "m", _Evstream_clearHeartbeat).call(this);
|
|
101
|
+
__classPrivateFieldGet(this, _Evstream_instances, "m", _Evstream_removeCloseListener).call(this);
|
|
94
102
|
this.message({
|
|
95
103
|
event: 'end',
|
|
96
104
|
data: '',
|
|
@@ -98,4 +106,14 @@ export class Evstream {
|
|
|
98
106
|
__classPrivateFieldGet(this, _Evstream_res, "f").end();
|
|
99
107
|
}
|
|
100
108
|
}
|
|
101
|
-
_Evstream_res = new WeakMap(), _Evstream_opts = new WeakMap(), _Evstream_url = new WeakMap()
|
|
109
|
+
_Evstream_res = new WeakMap(), _Evstream_opts = new WeakMap(), _Evstream_url = new WeakMap(), _Evstream_heartbeatInterval = new WeakMap(), _Evstream_onCloseHandler = new WeakMap(), _Evstream_instances = new WeakSet(), _Evstream_clearHeartbeat = function _Evstream_clearHeartbeat() {
|
|
110
|
+
if (__classPrivateFieldGet(this, _Evstream_heartbeatInterval, "f")) {
|
|
111
|
+
clearInterval(__classPrivateFieldGet(this, _Evstream_heartbeatInterval, "f"));
|
|
112
|
+
__classPrivateFieldSet(this, _Evstream_heartbeatInterval, undefined, "f");
|
|
113
|
+
}
|
|
114
|
+
}, _Evstream_removeCloseListener = function _Evstream_removeCloseListener() {
|
|
115
|
+
if (__classPrivateFieldGet(this, _Evstream_onCloseHandler, "f")) {
|
|
116
|
+
__classPrivateFieldGet(this, _Evstream_res, "f").removeListener('close', __classPrivateFieldGet(this, _Evstream_onCloseHandler, "f"));
|
|
117
|
+
__classPrivateFieldSet(this, _Evstream_onCloseHandler, undefined, "f");
|
|
118
|
+
}
|
|
119
|
+
};
|
package/dist/types.d.ts
CHANGED
|
@@ -19,10 +19,16 @@ export interface EvManagerOptions {
|
|
|
19
19
|
maxConnection?: number;
|
|
20
20
|
maxListeners?: number;
|
|
21
21
|
}
|
|
22
|
+
export interface EvStateAdapter {
|
|
23
|
+
publish(channel: string, message: any): Promise<void>;
|
|
24
|
+
subscribe(channel: string, onMessage: (message: any) => void): Promise<void>;
|
|
25
|
+
unsubscribe(channel: string): Promise<void>;
|
|
26
|
+
}
|
|
22
27
|
export interface EvStateOptions<T> {
|
|
23
28
|
initialValue: T;
|
|
24
29
|
channel: string;
|
|
25
30
|
manager: EvStreamManager;
|
|
26
31
|
key?: string;
|
|
32
|
+
adapter?: EvStateAdapter;
|
|
27
33
|
}
|
|
28
34
|
export type EvOnClose = (channels: string[]) => Promise<void>;
|
package/dist/utils.js
CHANGED
|
@@ -6,21 +6,21 @@
|
|
|
6
6
|
* @returns
|
|
7
7
|
*/
|
|
8
8
|
export function safeJsonParse(val) {
|
|
9
|
-
if (typeof val ===
|
|
9
|
+
if (typeof val === 'string') {
|
|
10
10
|
return val;
|
|
11
11
|
}
|
|
12
|
-
if (typeof val ===
|
|
12
|
+
if (typeof val === 'object') {
|
|
13
13
|
try {
|
|
14
14
|
return JSON.stringify(val);
|
|
15
15
|
}
|
|
16
16
|
catch (error) {
|
|
17
|
-
return
|
|
17
|
+
return '';
|
|
18
18
|
}
|
|
19
19
|
}
|
|
20
|
-
return
|
|
20
|
+
return '';
|
|
21
21
|
}
|
|
22
22
|
export function uid(opts) {
|
|
23
23
|
const now = Date.now().toString(36);
|
|
24
24
|
const rand = Math.random().toString(26).substring(2, 10);
|
|
25
|
-
return `${(opts === null || opts === void 0 ? void 0 : opts.prefix) ? `${opts === null || opts === void 0 ? void 0 : opts.prefix}-` :
|
|
25
|
+
return `${(opts === null || opts === void 0 ? void 0 : opts.prefix) ? `${opts === null || opts === void 0 ? void 0 : opts.prefix}-` : ''}${now}-${rand}-${opts === null || opts === void 0 ? void 0 : opts.counter}`;
|
|
26
26
|
}
|
package/package.json
CHANGED
|
@@ -1,47 +1,73 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
2
|
+
"name": "evstream",
|
|
3
|
+
"version": "1.0.2",
|
|
4
|
+
"description": "A simple and easy to implement server sent event library for express.js",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"sse",
|
|
7
|
+
"server-sent-events",
|
|
8
|
+
"event-source"
|
|
9
|
+
],
|
|
10
|
+
"homepage": "https://github.com/kisshan13/evstream#readme",
|
|
11
|
+
"bugs": {
|
|
12
|
+
"url": "https://github.com/kisshan13/evstream/issues"
|
|
13
|
+
},
|
|
14
|
+
"repository": {
|
|
15
|
+
"type": "git",
|
|
16
|
+
"url": "git+https://github.com/kisshan13/evstream.git"
|
|
17
|
+
},
|
|
18
|
+
"license": "MIT",
|
|
19
|
+
"author": "Kishan Sharma",
|
|
20
|
+
"type": "module",
|
|
21
|
+
"main": "./dist/index.js",
|
|
22
|
+
"types": "./dist/index.d.ts",
|
|
23
|
+
"exports": {
|
|
24
|
+
".": {
|
|
25
|
+
"import": "./dist/index.js",
|
|
26
|
+
"types": "./dist/index.d.ts"
|
|
27
|
+
},
|
|
28
|
+
"./adapter/redis": {
|
|
29
|
+
"import": "./dist/adapters/redis.js",
|
|
30
|
+
"types": "./dist/adapters/redis.d.ts"
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
"lint-staged": {
|
|
34
|
+
"*.{js,jsx,ts,tsx,cjs,mjs}": [
|
|
35
|
+
"prettier --write"
|
|
36
|
+
],
|
|
37
|
+
"*.{json,md,yml,yaml}": [
|
|
38
|
+
"prettier --write"
|
|
39
|
+
]
|
|
40
|
+
},
|
|
41
|
+
"scripts": {
|
|
42
|
+
"dev": "tsc --watch",
|
|
43
|
+
"clean": "rimraf ./dist",
|
|
44
|
+
"build": "rimraf ./dist && tsc --incremental false",
|
|
45
|
+
"prepare": "husky",
|
|
46
|
+
"lint-staged": "lint-staged",
|
|
47
|
+
"format": "prettier --write \"**/*.{js,jsx,ts,tsx}\""
|
|
48
|
+
},
|
|
49
|
+
"engines": {
|
|
50
|
+
"node": ">=17.8.0"
|
|
51
|
+
},
|
|
52
|
+
"devDependencies": {
|
|
53
|
+
"@types/ioredis": "^5.0.0",
|
|
54
|
+
"@types/lodash": "^4.17.19",
|
|
55
|
+
"@types/node": "^24.0.7",
|
|
56
|
+
"@typescript-eslint/eslint-plugin": "^8.35.0",
|
|
57
|
+
"@typescript-eslint/parser": "^8.35.0",
|
|
58
|
+
"eslint": "^9.30.0",
|
|
59
|
+
"eslint-config-prettier": "^10.1.5",
|
|
60
|
+
"eslint-plugin-import": "^2.32.0",
|
|
61
|
+
"eslint-plugin-prettier": "^5.5.1",
|
|
62
|
+
"eslint-plugin-simple-import-sort": "^12.1.1",
|
|
63
|
+
"ioredis": "^5.5.0",
|
|
64
|
+
"prettier": "^3.7.4",
|
|
65
|
+
"rimraf": "^6.0.1",
|
|
66
|
+
"typescript": "^5.8.3",
|
|
67
|
+
"husky": "^9.1.7",
|
|
68
|
+
"lint-staged": "^16.2.7"
|
|
69
|
+
},
|
|
70
|
+
"dependencies": {
|
|
71
|
+
"lodash": "^4.17.21"
|
|
72
|
+
}
|
|
47
73
|
}
|
package/readme.md
CHANGED
|
@@ -188,7 +188,35 @@ Reactive states are data which you can shared across multiple clients within the
|
|
|
188
188
|
|
|
189
189
|
**See** `channel` **and the value pass to the** `listen()` **must be the same**
|
|
190
190
|
|
|
191
|
-
### 5.
|
|
191
|
+
### 5. Distributed Reactive State (Redis)
|
|
192
|
+
|
|
193
|
+
When running multiple server instances, you can synchronize `EvState` across them using the built-in Redis adapter.
|
|
194
|
+
|
|
195
|
+
1. **Install the peer dependency:**
|
|
196
|
+
```bash
|
|
197
|
+
npm install ioredis
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
2. **Use the adapter:**
|
|
201
|
+
|
|
202
|
+
```javascript
|
|
203
|
+
import { EvState, EvStreamManager } from "evstream"
|
|
204
|
+
import { EvRedisAdapter } from "evstream/adapter/redis"
|
|
205
|
+
|
|
206
|
+
const manager = new EvStreamManager();
|
|
207
|
+
const redisAdapter = new EvRedisAdapter("redis://localhost:6379");
|
|
208
|
+
|
|
209
|
+
const userCount = new EvState({
|
|
210
|
+
channel: "user-count",
|
|
211
|
+
initialValue: 0,
|
|
212
|
+
manager: manager,
|
|
213
|
+
adapter: redisAdapter
|
|
214
|
+
})
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
Updates to `userCount` will now be synchronized across all instances connected to the same Redis.
|
|
218
|
+
|
|
219
|
+
### 6. Sending data to a channel
|
|
192
220
|
|
|
193
221
|
To send data to a channel you can use `send()` method from `EvStreamManager` class.
|
|
194
222
|
|
|
@@ -202,7 +230,7 @@ const manager = new EvStreamManager();
|
|
|
202
230
|
manager.send("<channel-name>", {event: "custom-event", data: {"foo": "bar"}})
|
|
203
231
|
```
|
|
204
232
|
|
|
205
|
-
###
|
|
233
|
+
### 7. Listening for channels
|
|
206
234
|
|
|
207
235
|
To listen for data from any channel you can use `listen()` function from `Evstream` class.
|
|
208
236
|
|
|
@@ -368,7 +396,8 @@ new EvState<T>({
|
|
|
368
396
|
channel,
|
|
369
397
|
initialValue,
|
|
370
398
|
manager,
|
|
371
|
-
key
|
|
399
|
+
key,
|
|
400
|
+
adapter
|
|
372
401
|
}: EvStateOptions<T>)
|
|
373
402
|
```
|
|
374
403
|
|
|
@@ -378,6 +407,7 @@ new EvState<T>({
|
|
|
378
407
|
* `initialValue`: `T` – The initial state value.
|
|
379
408
|
* `manager`: `EvStreamManager` – The SSE manager instance used for broadcasting.
|
|
380
409
|
* `key` *(optional)*: `string` – The key used in the broadcasted data object (default: `'value'`).
|
|
410
|
+
* `adapter` *(optional)*: `EvStateAdapter` – Adapter for distributed state synchronization (e.g. `EvRedisAdapter`).
|
|
381
411
|
|
|
382
412
|
---
|
|
383
413
|
|
|
@@ -437,13 +467,31 @@ new EvMaxConnectionsError(connections: number)
|
|
|
437
467
|
```ts
|
|
438
468
|
const manager = new EvStreamManager({ maxConnection: 100 });
|
|
439
469
|
if (tooManyConnections) {
|
|
470
|
+
```
|
|
471
|
+
|
|
440
472
|
throw new EvMaxConnectionsError(100)
|
|
441
473
|
}
|
|
442
474
|
```
|
|
443
475
|
|
|
444
476
|
---
|
|
445
|
-
|
|
446
|
-
## `
|
|
477
|
+
|
|
478
|
+
## `EvRedisAdapter`
|
|
479
|
+
|
|
480
|
+
Adapter for synchronizing `EvState` across multiple instances using Redis Pub/Sub.
|
|
481
|
+
|
|
482
|
+
### Constructor
|
|
483
|
+
|
|
484
|
+
```ts
|
|
485
|
+
new EvRedisAdapter(options?: RedisOptions | string)
|
|
486
|
+
```
|
|
487
|
+
|
|
488
|
+
#### Parameters:
|
|
489
|
+
|
|
490
|
+
* `options`: `RedisOptions | string` – Configuration options for the Redis client (from `ioredis`), or a Redis connection URL.
|
|
491
|
+
|
|
492
|
+
---
|
|
493
|
+
|
|
494
|
+
## `EvMaxListenerError`
|
|
447
495
|
|
|
448
496
|
Represents an error thrown when the number of listeners on a given channel exceeds the allowed `maxListeners` limit (default: `5000`).
|
|
449
497
|
|
|
@@ -573,6 +621,7 @@ Options for initializing a reactive state with `EvState`.
|
|
|
573
621
|
- `channel`: Channel name for broadcasting
|
|
574
622
|
- `manager`: Instance of `EvStreamManager`
|
|
575
623
|
- `key` *(optional)*: Key for wrapping state in the broadcast (default: `'value'`)
|
|
624
|
+
- `adapter` *(optional)*: Instance of `EvStateAdapter` (e.g., `EvRedisAdapter`) for distributed synchronization.
|
|
576
625
|
|
|
577
626
|
---
|
|
578
627
|
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import Redis, { RedisOptions } from 'ioredis'
|
|
2
|
+
|
|
3
|
+
import { EvStateAdapter } from '../types.js'
|
|
4
|
+
|
|
5
|
+
export class EvRedisAdapter implements EvStateAdapter {
|
|
6
|
+
#pub: Redis
|
|
7
|
+
#sub: Redis
|
|
8
|
+
#listeners: Map<string, Set<(msg: any) => void>>
|
|
9
|
+
|
|
10
|
+
constructor(options?: RedisOptions) {
|
|
11
|
+
this.#pub = new Redis(options)
|
|
12
|
+
this.#sub = new Redis(options)
|
|
13
|
+
this.#listeners = new Map()
|
|
14
|
+
|
|
15
|
+
this.#sub.on('message', (channel, message) => {
|
|
16
|
+
const handlers = this.#listeners.get(channel)
|
|
17
|
+
if (handlers) {
|
|
18
|
+
let parsed: any
|
|
19
|
+
try {
|
|
20
|
+
parsed = JSON.parse(message)
|
|
21
|
+
} catch {
|
|
22
|
+
return
|
|
23
|
+
}
|
|
24
|
+
handlers.forEach((handler) => handler(parsed))
|
|
25
|
+
}
|
|
26
|
+
})
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async publish(channel: string, message: any): Promise<void> {
|
|
30
|
+
await this.#pub.publish(channel, JSON.stringify(message))
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async subscribe(
|
|
34
|
+
channel: string,
|
|
35
|
+
onMessage: (message: any) => void
|
|
36
|
+
): Promise<void> {
|
|
37
|
+
if (!this.#listeners.has(channel)) {
|
|
38
|
+
this.#listeners.set(channel, new Set())
|
|
39
|
+
await this.#sub.subscribe(channel)
|
|
40
|
+
}
|
|
41
|
+
this.#listeners.get(channel)!.add(onMessage)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async unsubscribe(channel: string): Promise<void> {
|
|
45
|
+
await this.#sub.unsubscribe(channel)
|
|
46
|
+
this.#listeners.delete(channel)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
quit() {
|
|
50
|
+
this.#pub.quit()
|
|
51
|
+
this.#sub.quit()
|
|
52
|
+
}
|
|
53
|
+
}
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `EvMaxConnectionsError` represents a error which occurs when `maxConnection` reached. Default `maxConnection` is 5000.
|
|
3
|
+
*
|
|
4
|
+
* To change connection limit you can set `maxConnection` while initializing `new EvStreamManager();`
|
|
5
|
+
*/
|
|
6
|
+
export class EvMaxConnectionsError extends Error {
|
|
7
|
+
constructor(connections: number) {
|
|
8
|
+
super()
|
|
9
|
+
this.message = `Max number of connected client reached. Total Connection : ${connections}`
|
|
10
|
+
this.name = `EvMaxConnectionsError`
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* `EvMaxListenerError` represents a error which occurs when `maxListeners` reached. Default `maxListeners` is 5000.
|
|
16
|
+
*
|
|
17
|
+
* To change listeners limit you can set `maxListeners` while initializing `new EvStreamManager();`
|
|
18
|
+
*/
|
|
19
|
+
export class EvMaxListenerError extends Error {
|
|
20
|
+
constructor(listeners: number, channel: string) {
|
|
21
|
+
super()
|
|
22
|
+
this.message = `Max number of listeners for the channle ${channel} reached (Listener: ${listeners}).`
|
|
23
|
+
this.name = `EvMaxListenerError`
|
|
24
|
+
}
|
|
25
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { Evstream } from './stream.js'
|
|
2
|
+
import { EvStreamManager } from './manager.js'
|
|
3
|
+
import { EvState } from './state.js'
|
|
4
|
+
|
|
5
|
+
import { EvMaxListenerError, EvMaxConnectionsError } from './errors.js'
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
EvOptions,
|
|
9
|
+
EvAuthenticationOptions,
|
|
10
|
+
EvEventsType,
|
|
11
|
+
EvManagerOptions,
|
|
12
|
+
EvMessage,
|
|
13
|
+
EvStateOptions,
|
|
14
|
+
} from './types.js'
|
|
15
|
+
|
|
16
|
+
export {
|
|
17
|
+
EvMaxConnectionsError,
|
|
18
|
+
EvMaxListenerError,
|
|
19
|
+
Evstream,
|
|
20
|
+
EvStreamManager,
|
|
21
|
+
EvState,
|
|
22
|
+
EvOptions,
|
|
23
|
+
EvAuthenticationOptions,
|
|
24
|
+
EvEventsType,
|
|
25
|
+
EvManagerOptions,
|
|
26
|
+
EvMessage,
|
|
27
|
+
EvStateOptions,
|
|
28
|
+
}
|
package/src/manager.ts
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { IncomingMessage, ServerResponse } from 'http'
|
|
2
|
+
|
|
3
|
+
import { Evstream } from './stream.js'
|
|
4
|
+
import { uid } from './utils.js'
|
|
5
|
+
import { EvMaxConnectionsError, EvMaxListenerError } from './errors.js'
|
|
6
|
+
|
|
7
|
+
import type {
|
|
8
|
+
EvManagerOptions,
|
|
9
|
+
EvMessage,
|
|
10
|
+
EvOnClose,
|
|
11
|
+
EvOptions,
|
|
12
|
+
} from './types.js'
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* `EvStreamManager` manages multiple SSE connections.
|
|
16
|
+
* Handles client creation, broadcasting messages, and channel-based listeners.
|
|
17
|
+
*
|
|
18
|
+
* Example :
|
|
19
|
+
*
|
|
20
|
+
* ```javascript
|
|
21
|
+
* const evManager = new EvStreamManager();
|
|
22
|
+
*
|
|
23
|
+
* const stream = evManager.createStream(req, res);
|
|
24
|
+
* ```
|
|
25
|
+
*
|
|
26
|
+
*/
|
|
27
|
+
export class EvStreamManager {
|
|
28
|
+
#clients: Map<string, Evstream>
|
|
29
|
+
#listeners: Map<string, Set<string>>
|
|
30
|
+
#count: number
|
|
31
|
+
#maxConnections: number
|
|
32
|
+
#maxListeners: number
|
|
33
|
+
#id?: string
|
|
34
|
+
constructor(opts?: EvManagerOptions) {
|
|
35
|
+
this.#clients = new Map()
|
|
36
|
+
this.#listeners = new Map()
|
|
37
|
+
this.#count = 0
|
|
38
|
+
|
|
39
|
+
this.#maxConnections = opts?.maxConnection || 5000
|
|
40
|
+
this.#maxListeners = opts?.maxListeners || 5000
|
|
41
|
+
this.#id = opts?.id
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Creates a new SSE stream, tracks it, and returns control methods.
|
|
46
|
+
* Enforces max connection limit.
|
|
47
|
+
*/
|
|
48
|
+
createStream(req: IncomingMessage, res: ServerResponse, opts?: EvOptions) {
|
|
49
|
+
if (this.#count >= this.#maxConnections) {
|
|
50
|
+
throw new EvMaxConnectionsError(this.#maxConnections)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const client = new Evstream(req, res, opts)
|
|
54
|
+
const id = uid({ counter: this.#count, prefix: this.#id })
|
|
55
|
+
const channel: string[] = []
|
|
56
|
+
let isClosed = false
|
|
57
|
+
|
|
58
|
+
this.#count += 1
|
|
59
|
+
this.#clients.set(id, client)
|
|
60
|
+
|
|
61
|
+
const close = (onClose?: EvOnClose) => {
|
|
62
|
+
if (isClosed) return
|
|
63
|
+
|
|
64
|
+
if (typeof onClose === 'function') {
|
|
65
|
+
onClose(channel)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
isClosed = true
|
|
69
|
+
|
|
70
|
+
// Remove close event listener to prevent memory leaks
|
|
71
|
+
res.removeAllListeners('close')
|
|
72
|
+
|
|
73
|
+
// Clean up client
|
|
74
|
+
client.close()
|
|
75
|
+
|
|
76
|
+
// Decrement count
|
|
77
|
+
this.#count -= 1
|
|
78
|
+
|
|
79
|
+
// Remove from all channels
|
|
80
|
+
channel.forEach(chan => this.#unlisten(chan, id))
|
|
81
|
+
|
|
82
|
+
// Clear channel array to release references
|
|
83
|
+
channel.length = 0
|
|
84
|
+
|
|
85
|
+
// Remove client from map
|
|
86
|
+
this.#clients.delete(id)
|
|
87
|
+
|
|
88
|
+
// End response if not already ended
|
|
89
|
+
if (!res.writableEnded) {
|
|
90
|
+
res.end()
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const onCloseHandler = () => {
|
|
95
|
+
if (!isClosed) {
|
|
96
|
+
close()
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
res.on('close', onCloseHandler)
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
authenticate: client.authenticate.bind(client),
|
|
104
|
+
message: client.message.bind(client),
|
|
105
|
+
close: close,
|
|
106
|
+
listen: (name: string) => {
|
|
107
|
+
if (isClosed) return
|
|
108
|
+
channel.push(name)
|
|
109
|
+
this.#listen(name, id)
|
|
110
|
+
},
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Sends a message to all clients listening to a specific channel.
|
|
116
|
+
*/
|
|
117
|
+
send(name: string, msg: EvMessage) {
|
|
118
|
+
const listeners = this.#listeners.get(name)
|
|
119
|
+
|
|
120
|
+
if (!listeners) return
|
|
121
|
+
|
|
122
|
+
for (const [_, id] of listeners.entries()) {
|
|
123
|
+
const client = this.#clients.get(id)
|
|
124
|
+
|
|
125
|
+
if (client) {
|
|
126
|
+
client.message({
|
|
127
|
+
...msg,
|
|
128
|
+
data:
|
|
129
|
+
typeof msg.data === 'string'
|
|
130
|
+
? { ch: name, data: msg }
|
|
131
|
+
: { ch: name, ...msg.data },
|
|
132
|
+
})
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Adds a client to a specific channel.
|
|
139
|
+
* Enforces max listeners per channel.
|
|
140
|
+
*/
|
|
141
|
+
#listen(name: string, id: string) {
|
|
142
|
+
let listeners = this.#listeners.get(name)
|
|
143
|
+
|
|
144
|
+
if (!listeners) {
|
|
145
|
+
listeners = new Set<string>()
|
|
146
|
+
this.#listeners.set(name, listeners)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (listeners.size >= this.#maxListeners) {
|
|
150
|
+
throw new EvMaxListenerError(listeners.size, name)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
listeners.add(id)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Removes a client from a specific channel.
|
|
158
|
+
* Deletes the channel if no listeners remain.
|
|
159
|
+
*/
|
|
160
|
+
#unlisten(name: string, id: string) {
|
|
161
|
+
const isListenerExists = this.#listeners.get(name)
|
|
162
|
+
|
|
163
|
+
if (isListenerExists) {
|
|
164
|
+
isListenerExists.delete(id)
|
|
165
|
+
|
|
166
|
+
if (isListenerExists.size === 0) {
|
|
167
|
+
this.#listeners.delete(name)
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
package/src/message.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { EvMessage } from './types.js'
|
|
2
|
+
import { safeJsonParse } from './utils.js'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
*
|
|
6
|
+
* This function convert the data to event stream compatible format.
|
|
7
|
+
*
|
|
8
|
+
* @param msg Message which you want to send to the client.
|
|
9
|
+
*/
|
|
10
|
+
export function message(msg: EvMessage) {
|
|
11
|
+
const event = `event:${msg.event || 'message'}\n`
|
|
12
|
+
const data = `data:${safeJsonParse(msg.data)}\n`
|
|
13
|
+
|
|
14
|
+
if (data === '') {
|
|
15
|
+
return `${msg.id ? `id:${msg.id}\n` : ''}${event}\n`
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
return `${msg.id ? `id:${msg.id}\n` : ''}${event}${data}\n`
|
|
19
|
+
}
|
package/src/state.ts
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import loadash from 'lodash'
|
|
2
|
+
|
|
3
|
+
import { EvStreamManager } from './manager.js'
|
|
4
|
+
import { EvStateAdapter, EvStateOptions } from './types.js'
|
|
5
|
+
|
|
6
|
+
const { isEqual } = loadash
|
|
7
|
+
|
|
8
|
+
type EvSetState<T> = (val: T) => T
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* EvState holds a reactive state and broadcasts updates to a channel using EvStreamManager.
|
|
12
|
+
*/
|
|
13
|
+
export class EvState<T> {
|
|
14
|
+
#value: T
|
|
15
|
+
#channel: string
|
|
16
|
+
#manager: EvStreamManager
|
|
17
|
+
#key: string
|
|
18
|
+
#adapter?: EvStateAdapter
|
|
19
|
+
|
|
20
|
+
constructor({
|
|
21
|
+
channel,
|
|
22
|
+
initialValue,
|
|
23
|
+
manager,
|
|
24
|
+
key,
|
|
25
|
+
adapter,
|
|
26
|
+
}: EvStateOptions<T>) {
|
|
27
|
+
this.#value = initialValue
|
|
28
|
+
this.#channel = channel
|
|
29
|
+
this.#manager = manager
|
|
30
|
+
this.#key = key || 'value'
|
|
31
|
+
this.#adapter = adapter
|
|
32
|
+
|
|
33
|
+
if (this.#adapter) {
|
|
34
|
+
this.#adapter.subscribe(this.#channel, (data) => {
|
|
35
|
+
this.#handleRemoteUpdate(data)
|
|
36
|
+
})
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
#handleRemoteUpdate(data: any) {
|
|
41
|
+
if (data && typeof data === 'object' && this.#key in data) {
|
|
42
|
+
const newValue = data[this.#key]
|
|
43
|
+
|
|
44
|
+
if (!isEqual(newValue, this.#value)) {
|
|
45
|
+
this.#value = newValue
|
|
46
|
+
this.#manager.send(this.#channel, {
|
|
47
|
+
event: this.#channel,
|
|
48
|
+
data: {
|
|
49
|
+
[this.#key]: newValue,
|
|
50
|
+
},
|
|
51
|
+
})
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Returns the current state value.
|
|
58
|
+
*/
|
|
59
|
+
get() {
|
|
60
|
+
return this.#value
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Updates the state using a callback.
|
|
65
|
+
* Broadcasts the new value if it has changed.
|
|
66
|
+
*/
|
|
67
|
+
set(callback: EvSetState<T>) {
|
|
68
|
+
const newValue = callback(this.#value)
|
|
69
|
+
|
|
70
|
+
if (!isEqual(newValue, this.#value)) {
|
|
71
|
+
this.#value = newValue
|
|
72
|
+
this.#manager.send(this.#channel, {
|
|
73
|
+
event: this.#channel,
|
|
74
|
+
data: {
|
|
75
|
+
[this.#key]: newValue,
|
|
76
|
+
},
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
if (this.#adapter) {
|
|
80
|
+
this.#adapter.publish(this.#channel, { [this.#key]: newValue })
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
package/src/stream.ts
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { IncomingMessage, ServerResponse } from 'http'
|
|
2
|
+
import { EvMessage, EvOptions } from './types.js'
|
|
3
|
+
import { message } from './message.js'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Evstream manages a Server-Sent Events (SSE) connection.
|
|
7
|
+
* Sets necessary headers, handles heartbeat, authentication, sending messages, and closing the stream.
|
|
8
|
+
* Example :
|
|
9
|
+
*
|
|
10
|
+
* ```javascript
|
|
11
|
+
* const ev = new Evstream(req, res);
|
|
12
|
+
*
|
|
13
|
+
* ev.message({event: "message", data: {message: "a message"}, id: "event_id_1"})
|
|
14
|
+
* ```
|
|
15
|
+
*/
|
|
16
|
+
export class Evstream {
|
|
17
|
+
#res: ServerResponse
|
|
18
|
+
#opts?: EvOptions
|
|
19
|
+
#url: URL
|
|
20
|
+
#heartbeatInterval?: NodeJS.Timeout
|
|
21
|
+
#onCloseHandler?: () => void
|
|
22
|
+
constructor(req: IncomingMessage, res: ServerResponse, opts?: EvOptions) {
|
|
23
|
+
this.#res = res
|
|
24
|
+
this.#opts = opts
|
|
25
|
+
this.#url = new URL(req.url!, `http://${req.headers.host}`)
|
|
26
|
+
|
|
27
|
+
this.#res.setHeader('Content-Type', 'text/event-stream')
|
|
28
|
+
this.#res.setHeader('Cache-Control', 'no-cache')
|
|
29
|
+
this.#res.setHeader('Connection', 'keep-alive')
|
|
30
|
+
this.#res.flushHeaders()
|
|
31
|
+
|
|
32
|
+
if (opts?.heartbeat) {
|
|
33
|
+
this.#heartbeatInterval = setInterval(() => {
|
|
34
|
+
this.#res.write(message({ event: 'heartbeat', data: '' }))
|
|
35
|
+
}, this.#opts.heartbeat)
|
|
36
|
+
|
|
37
|
+
this.#onCloseHandler = () => {
|
|
38
|
+
this.#clearHeartbeat()
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
this.#res.on('close', this.#onCloseHandler)
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Clears the heartbeat interval if it exists.
|
|
47
|
+
* Prevents memory leaks by ensuring the interval is properly cleaned up.
|
|
48
|
+
*/
|
|
49
|
+
#clearHeartbeat() {
|
|
50
|
+
if (this.#heartbeatInterval) {
|
|
51
|
+
clearInterval(this.#heartbeatInterval)
|
|
52
|
+
this.#heartbeatInterval = undefined
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Removes the close event listener to prevent memory leaks.
|
|
58
|
+
*/
|
|
59
|
+
#removeCloseListener() {
|
|
60
|
+
if (this.#onCloseHandler) {
|
|
61
|
+
this.#res.removeListener('close', this.#onCloseHandler)
|
|
62
|
+
this.#onCloseHandler = undefined
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Handles optional authentication using provided token verification.
|
|
68
|
+
* Sends error message and closes connection if authentication fails.
|
|
69
|
+
*/
|
|
70
|
+
async authenticate() {
|
|
71
|
+
if (this.#opts.authentication) {
|
|
72
|
+
const token = this.#url.searchParams.get(this.#opts.authentication.param)
|
|
73
|
+
|
|
74
|
+
const isAuthenticated = await this.#opts.authentication.verify(token)
|
|
75
|
+
|
|
76
|
+
if (typeof isAuthenticated === 'boolean') {
|
|
77
|
+
if (!isAuthenticated) {
|
|
78
|
+
this.#clearHeartbeat()
|
|
79
|
+
this.message({
|
|
80
|
+
data: { message: 'authentication failed' },
|
|
81
|
+
event: 'error',
|
|
82
|
+
})
|
|
83
|
+
this.#res.end()
|
|
84
|
+
return false
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return true
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (typeof isAuthenticated === 'object') {
|
|
91
|
+
this.message(isAuthenticated)
|
|
92
|
+
return true
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return false
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Sends an SSE message to the client.
|
|
101
|
+
* Accepts an `EvMessage` object.
|
|
102
|
+
*/
|
|
103
|
+
message(msg: EvMessage) {
|
|
104
|
+
this.#res.write(message(msg))
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Sends an "end" event and closes the SSE connection.
|
|
109
|
+
* Cleans up heartbeat interval and event listeners to prevent memory leaks.
|
|
110
|
+
*/
|
|
111
|
+
close() {
|
|
112
|
+
this.#clearHeartbeat()
|
|
113
|
+
this.#removeCloseListener()
|
|
114
|
+
|
|
115
|
+
this.message({
|
|
116
|
+
event: 'end',
|
|
117
|
+
data: '',
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
this.#res.end()
|
|
121
|
+
}
|
|
122
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { EvStreamManager } from './manager.js'
|
|
2
|
+
|
|
3
|
+
// Built-in event types.
|
|
4
|
+
export type EvEventsType = 'data' | 'error' | 'end'
|
|
5
|
+
|
|
6
|
+
// Represents a message sent to the client over SSE.
|
|
7
|
+
export interface EvMessage {
|
|
8
|
+
// Optional event name.
|
|
9
|
+
event?: string | EvEventsType
|
|
10
|
+
// Data to send; can be a string or object.
|
|
11
|
+
data: string | object
|
|
12
|
+
// Optional ID of the event.
|
|
13
|
+
id?: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Options for token-based authentication from query parameters.
|
|
17
|
+
export interface EvAuthenticationOptions {
|
|
18
|
+
method: 'query'
|
|
19
|
+
param: string
|
|
20
|
+
verify: (token: string) => Promise<EvMessage> | undefined | null | boolean
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Options for configuring a single SSE stream.
|
|
24
|
+
export interface EvOptions {
|
|
25
|
+
authentication?: EvAuthenticationOptions
|
|
26
|
+
heartbeat?: number
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Configuration options for EvStreamManager.
|
|
30
|
+
export interface EvManagerOptions {
|
|
31
|
+
// Unique ID for the manager
|
|
32
|
+
id?: string
|
|
33
|
+
|
|
34
|
+
// Max Connection which a manager can handle. If this limit exceeds it throws `EvMaxConnectionsError`
|
|
35
|
+
maxConnection?: number
|
|
36
|
+
|
|
37
|
+
// Max Listeners which a listener can broadcast a message to. If this limit exceeds it throw `EvMaxListenerError`
|
|
38
|
+
maxListeners?: number
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Options for initializing EvState.
|
|
42
|
+
export interface EvStateAdapter {
|
|
43
|
+
publish(channel: string, message: any): Promise<void>
|
|
44
|
+
subscribe(channel: string, onMessage: (message: any) => void): Promise<void>
|
|
45
|
+
unsubscribe(channel: string): Promise<void>
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface EvStateOptions<T> {
|
|
49
|
+
initialValue: T
|
|
50
|
+
channel: string
|
|
51
|
+
manager: EvStreamManager
|
|
52
|
+
key?: string
|
|
53
|
+
adapter?: EvStateAdapter
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export type EvOnClose = (channels: string[]) => Promise<void>
|
package/src/utils.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
*
|
|
3
|
+
* This function takes a value which can be string or an object and returns a string for that value. If value cannot be convertable to string then it will return a empty string.
|
|
4
|
+
*
|
|
5
|
+
* @param val Data which needs to be serialize to JSON string format
|
|
6
|
+
* @returns
|
|
7
|
+
*/
|
|
8
|
+
export function safeJsonParse(val: any) {
|
|
9
|
+
if (typeof val === 'string') {
|
|
10
|
+
return val
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
if (typeof val === 'object') {
|
|
14
|
+
try {
|
|
15
|
+
return JSON.stringify(val)
|
|
16
|
+
} catch (error) {
|
|
17
|
+
return ''
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
return ''
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function uid(opts?: { prefix?: string; counter?: number }) {
|
|
25
|
+
const now = Date.now().toString(36)
|
|
26
|
+
const rand = Math.random().toString(26).substring(2, 10)
|
|
27
|
+
|
|
28
|
+
return `${opts?.prefix ? `${opts?.prefix}-` : ''}${now}-${rand}-${opts?.counter}`
|
|
29
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compileOnSave": true,
|
|
3
|
+
"compilerOptions": {
|
|
4
|
+
"newLine": "LF",
|
|
5
|
+
"rootDir": "src/",
|
|
6
|
+
"outDir": "dist/",
|
|
7
|
+
"module": "ESNext",
|
|
8
|
+
"target": "ES2015",
|
|
9
|
+
"declaration": true,
|
|
10
|
+
"incremental": true,
|
|
11
|
+
"alwaysStrict": true,
|
|
12
|
+
"esModuleInterop": true,
|
|
13
|
+
"moduleResolution": "node",
|
|
14
|
+
"preserveConstEnums": true
|
|
15
|
+
}
|
|
16
|
+
}
|