evstream 1.0.1 → 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.js +1 -1
- package/dist/state.d.ts +1 -1
- package/dist/state.js +1 -1
- package/dist/utils.js +5 -5
- package/package.json +73 -60
- 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
package/dist/adapters/redis.js
CHANGED
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, adapter }: 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
|
@@ -16,7 +16,7 @@ const { isEqual } = loadash;
|
|
|
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, adapter }) {
|
|
19
|
+
constructor({ channel, initialValue, manager, key, adapter, }) {
|
|
20
20
|
_EvState_instances.add(this);
|
|
21
21
|
_EvState_value.set(this, void 0);
|
|
22
22
|
_EvState_channel.set(this, void 0);
|
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,60 +1,73 @@
|
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
1
|
+
{
|
|
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
|
+
}
|
|
73
|
+
}
|
|
@@ -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
|
+
}
|