evstream 1.0.1 → 1.0.3
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/.me/dump.ts +102 -0
- package/.prettierignore +15 -0
- package/.prettierrc +8 -0
- package/dist/adapters/pub-sub.d.ts +14 -0
- package/dist/adapters/pub-sub.js +66 -0
- package/dist/adapters/redis.d.ts +3 -1
- package/dist/adapters/redis.js +13 -5
- package/dist/extensions/state-manager.d.ts +21 -0
- package/dist/extensions/state-manager.js +88 -0
- package/dist/state.d.ts +1 -1
- package/dist/state.js +1 -1
- package/dist/utils.js +5 -5
- package/package.json +77 -60
- package/readme.md +844 -674
- package/src/adapters/pub-sub.ts +88 -0
- package/src/adapters/redis.ts +120 -0
- package/src/errors.ts +25 -0
- package/src/extensions/state-manager.ts +186 -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,88 @@
|
|
|
1
|
+
import Redis, { RedisOptions } from 'ioredis'
|
|
2
|
+
import { uid } from '../utils.js'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Configuration options for EvRedisPubSub
|
|
6
|
+
*/
|
|
7
|
+
interface EvRedisPubSubOptions<T> {
|
|
8
|
+
/** Redis Pub/Sub channel name */
|
|
9
|
+
subject: string
|
|
10
|
+
|
|
11
|
+
/** Redis connection options */
|
|
12
|
+
options: RedisOptions
|
|
13
|
+
|
|
14
|
+
/** Optional initial message handler */
|
|
15
|
+
onMessage?: (message: T) => void
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Redis-based Pub/Sub helper for cross-process communication.
|
|
20
|
+
*
|
|
21
|
+
* - Uses separate publisher and subscriber connections
|
|
22
|
+
* - Prevents self-message delivery using instance UID
|
|
23
|
+
* - Typed message payload via generics
|
|
24
|
+
*/
|
|
25
|
+
export class EvRedisPubSub<T = unknown> {
|
|
26
|
+
#subject: string
|
|
27
|
+
#pub: Redis
|
|
28
|
+
#sub: Redis
|
|
29
|
+
#instanceId: string
|
|
30
|
+
#onMessage?: (message: T) => void
|
|
31
|
+
|
|
32
|
+
constructor({ options, subject, onMessage }: EvRedisPubSubOptions<T>) {
|
|
33
|
+
this.#pub = new Redis(options)
|
|
34
|
+
this.#sub = new Redis(options)
|
|
35
|
+
this.#subject = subject
|
|
36
|
+
this.#onMessage = onMessage
|
|
37
|
+
this.#instanceId = uid({ prefix: subject, counter: Math.random() })
|
|
38
|
+
|
|
39
|
+
this.init()
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Initializes Redis subscriptions and listeners.
|
|
44
|
+
*/
|
|
45
|
+
private async init() {
|
|
46
|
+
this.#pub.on('error', () => {})
|
|
47
|
+
this.#sub.on('error', () => {})
|
|
48
|
+
|
|
49
|
+
await this.#sub.subscribe(this.#subject)
|
|
50
|
+
|
|
51
|
+
this.#sub.on('message', (_, raw) => {
|
|
52
|
+
try {
|
|
53
|
+
const data = JSON.parse(raw)
|
|
54
|
+
|
|
55
|
+
// Ignore messages from the same instance
|
|
56
|
+
if (data?.uid !== this.#instanceId) {
|
|
57
|
+
this.#onMessage?.(data.msg as T)
|
|
58
|
+
}
|
|
59
|
+
} catch {
|
|
60
|
+
// Ignore malformed payloads
|
|
61
|
+
}
|
|
62
|
+
})
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Publishes a message to the Redis channel.
|
|
67
|
+
*/
|
|
68
|
+
async send(msg: T) {
|
|
69
|
+
await this.#pub.publish(
|
|
70
|
+
this.#subject,
|
|
71
|
+
JSON.stringify({ uid: this.#instanceId, msg })
|
|
72
|
+
)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Registers or replaces the message handler.
|
|
77
|
+
*/
|
|
78
|
+
onMessage(callback: (msg: T) => void) {
|
|
79
|
+
this.#onMessage = callback
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Gracefully closes Redis connections.
|
|
84
|
+
*/
|
|
85
|
+
async close() {
|
|
86
|
+
await Promise.all([this.#pub.quit(), this.#sub.quit()])
|
|
87
|
+
}
|
|
88
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import Redis, { RedisOptions } from 'ioredis'
|
|
2
|
+
import { EvRedisPubSub } from './pub-sub.js'
|
|
3
|
+
import { EvStateAdapter } from '../types.js'
|
|
4
|
+
import { uid } from '../utils.js'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Redis-based implementation of {@link EvStateAdapter}.
|
|
8
|
+
*
|
|
9
|
+
* This adapter enables distributed state updates using Redis Pub/Sub.
|
|
10
|
+
* It supports:
|
|
11
|
+
* - Channel-based subscriptions
|
|
12
|
+
* - Multiple listeners per channel
|
|
13
|
+
* - Self-message filtering via instance ID
|
|
14
|
+
*
|
|
15
|
+
* Designed to be used by EvState / EvStateManager for
|
|
16
|
+
* cross-process state synchronization.
|
|
17
|
+
*/
|
|
18
|
+
class EvRedisAdapter implements EvStateAdapter {
|
|
19
|
+
/** Publisher Redis client */
|
|
20
|
+
#pub: Redis
|
|
21
|
+
|
|
22
|
+
/** Subscriber Redis client */
|
|
23
|
+
#sub: Redis
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Channel → listeners mapping.
|
|
27
|
+
* Each channel may have multiple local handlers.
|
|
28
|
+
*/
|
|
29
|
+
#listeners: Map<string, Set<(msg: any) => void>>
|
|
30
|
+
|
|
31
|
+
/** Unique identifier for this adapter instance */
|
|
32
|
+
#instanceId: string
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Creates a new Redis state adapter.
|
|
36
|
+
*
|
|
37
|
+
* @param options - Optional Redis connection options
|
|
38
|
+
*/
|
|
39
|
+
constructor(options?: RedisOptions) {
|
|
40
|
+
this.#pub = new Redis(options)
|
|
41
|
+
this.#sub = new Redis(options)
|
|
42
|
+
this.#listeners = new Map()
|
|
43
|
+
this.#instanceId = uid({ counter: Math.ceil(Math.random() * 100) })
|
|
44
|
+
|
|
45
|
+
this.#sub.on('message', (channel, message) => {
|
|
46
|
+
const handlers = this.#listeners.get(channel)
|
|
47
|
+
if (!handlers) return
|
|
48
|
+
|
|
49
|
+
let parsed: any
|
|
50
|
+
try {
|
|
51
|
+
parsed = JSON.parse(message)
|
|
52
|
+
} catch {
|
|
53
|
+
// Ignore malformed payloads
|
|
54
|
+
return
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Ignore messages published by this instance
|
|
58
|
+
if (parsed?.id === this.#instanceId) return
|
|
59
|
+
|
|
60
|
+
handlers.forEach((handler) => handler(parsed?.message))
|
|
61
|
+
})
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Publishes a message to a Redis channel.
|
|
66
|
+
*
|
|
67
|
+
* The payload is wrapped with the instance ID to
|
|
68
|
+
* prevent self-delivery.
|
|
69
|
+
*
|
|
70
|
+
* @param channel - Redis channel name
|
|
71
|
+
* @param message - Message payload
|
|
72
|
+
*/
|
|
73
|
+
async publish(channel: string, message: any): Promise<void> {
|
|
74
|
+
await this.#pub.publish(
|
|
75
|
+
channel,
|
|
76
|
+
JSON.stringify({ id: this.#instanceId, message })
|
|
77
|
+
)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Subscribes to a Redis channel.
|
|
82
|
+
*
|
|
83
|
+
* Multiple listeners can be registered per channel.
|
|
84
|
+
* The Redis subscription is created only once per channel.
|
|
85
|
+
*
|
|
86
|
+
* @param channel - Redis channel name
|
|
87
|
+
* @param onMessage - Callback invoked on incoming messages
|
|
88
|
+
*/
|
|
89
|
+
async subscribe(
|
|
90
|
+
channel: string,
|
|
91
|
+
onMessage: (message: any) => void
|
|
92
|
+
): Promise<void> {
|
|
93
|
+
if (!this.#listeners.has(channel)) {
|
|
94
|
+
this.#listeners.set(channel, new Set())
|
|
95
|
+
await this.#sub.subscribe(channel)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
this.#listeners.get(channel)!.add(onMessage)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Unsubscribes from a Redis channel and removes all listeners.
|
|
103
|
+
*
|
|
104
|
+
* @param channel - Redis channel name
|
|
105
|
+
*/
|
|
106
|
+
async unsubscribe(channel: string): Promise<void> {
|
|
107
|
+
await this.#sub.unsubscribe(channel)
|
|
108
|
+
this.#listeners.delete(channel)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Gracefully closes Redis connections.
|
|
113
|
+
*/
|
|
114
|
+
quit() {
|
|
115
|
+
this.#pub.quit()
|
|
116
|
+
this.#sub.quit()
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export { EvRedisPubSub, EvRedisAdapter }
|
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
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import type { EvStreamManager } from '../manager.js'
|
|
2
|
+
import type { EvRedisAdapter } from '../adapters/redis.js'
|
|
3
|
+
import type { EvRedisPubSub } from '../adapters/pub-sub.js'
|
|
4
|
+
import { EvState } from '../state.js'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Options for creating an {@link EvStateManager}.
|
|
8
|
+
*/
|
|
9
|
+
interface EvStateManagerOptions {
|
|
10
|
+
/**
|
|
11
|
+
* Stream manager responsible for managing client connections
|
|
12
|
+
* and broadcasting state updates.
|
|
13
|
+
*/
|
|
14
|
+
manager: EvStreamManager
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Optional distributed state adapter (e.g. Redis).
|
|
18
|
+
* Enables cross-process state propagation.
|
|
19
|
+
*/
|
|
20
|
+
adapter?: EvRedisAdapter
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Optional Pub/Sub instance used to synchronize
|
|
24
|
+
* state creation and removal across instances.
|
|
25
|
+
*/
|
|
26
|
+
pubsub?: EvRedisPubSub
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Manages a collection of named {@link EvState} instances.
|
|
31
|
+
*
|
|
32
|
+
* Responsibilities:
|
|
33
|
+
* - Create and cache state objects locally
|
|
34
|
+
* - Synchronize state lifecycle (create/remove) across processes
|
|
35
|
+
* - Bridge EvState with stream manager and adapters
|
|
36
|
+
*
|
|
37
|
+
* Internally, all state keys are converted to strings to remain
|
|
38
|
+
* Redis-safe and transport-friendly.
|
|
39
|
+
*
|
|
40
|
+
* @typeParam S - Mapping of state keys to their value types
|
|
41
|
+
*/
|
|
42
|
+
export class EvStateManager<S extends Record<string, any>> {
|
|
43
|
+
/**
|
|
44
|
+
* Internal state registry.
|
|
45
|
+
* Keyed by string channel name.
|
|
46
|
+
*/
|
|
47
|
+
#states = new Map<string, EvState<any>>()
|
|
48
|
+
|
|
49
|
+
/** Stream manager used by all states */
|
|
50
|
+
#manager: EvStreamManager
|
|
51
|
+
|
|
52
|
+
/** Optional distributed adapter */
|
|
53
|
+
#adapter?: EvRedisAdapter
|
|
54
|
+
|
|
55
|
+
/** Optional Pub/Sub synchronizer */
|
|
56
|
+
#pubsub?: EvRedisPubSub
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Creates a new state manager.
|
|
60
|
+
*
|
|
61
|
+
* @param options - Initialization options
|
|
62
|
+
*/
|
|
63
|
+
constructor({ manager, adapter, pubsub }: EvStateManagerOptions) {
|
|
64
|
+
this.#manager = manager
|
|
65
|
+
this.#adapter = adapter
|
|
66
|
+
this.#pubsub = pubsub
|
|
67
|
+
|
|
68
|
+
this.pubSubCallback = this.pubSubCallback.bind(this)
|
|
69
|
+
|
|
70
|
+
if (this.#pubsub) {
|
|
71
|
+
this.#pubsub.onMessage(this.pubSubCallback)
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Creates a state locally without emitting Pub/Sub events.
|
|
77
|
+
*
|
|
78
|
+
* @param channel - State channel name
|
|
79
|
+
* @param initialValue - Initial state value
|
|
80
|
+
*/
|
|
81
|
+
private createLocalState(channel: string, initialValue: any): EvState<any> {
|
|
82
|
+
const state = new EvState({
|
|
83
|
+
channel,
|
|
84
|
+
initialValue,
|
|
85
|
+
manager: this.#manager,
|
|
86
|
+
adapter: this.#adapter,
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
this.#states.set(channel, state)
|
|
90
|
+
return state
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Removes a state locally without emitting Pub/Sub events.
|
|
95
|
+
*
|
|
96
|
+
* @param channel - State channel name
|
|
97
|
+
*/
|
|
98
|
+
private removeLocalState(channel: string) {
|
|
99
|
+
this.#states.delete(channel)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Creates or returns an existing state.
|
|
104
|
+
*
|
|
105
|
+
* If Pub/Sub is enabled, the creation is broadcast
|
|
106
|
+
* to other instances.
|
|
107
|
+
*
|
|
108
|
+
* @param key - State key
|
|
109
|
+
* @param initialValue - Initial state value
|
|
110
|
+
*/
|
|
111
|
+
createState<K extends keyof S>(key: K, initialValue: S[K]): EvState<S[K]> {
|
|
112
|
+
const channel = String(key)
|
|
113
|
+
|
|
114
|
+
if (this.#states.has(channel)) {
|
|
115
|
+
return this.#states.get(channel)! as EvState<S[K]>
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const state = this.createLocalState(channel, initialValue)
|
|
119
|
+
|
|
120
|
+
this.#pubsub?.send({
|
|
121
|
+
type: 'create',
|
|
122
|
+
channel,
|
|
123
|
+
initialValue,
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
return state as EvState<S[K]>
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Retrieves an existing state.
|
|
131
|
+
*
|
|
132
|
+
* @param key - State key
|
|
133
|
+
*/
|
|
134
|
+
getState<K extends keyof S>(key: K): EvState<S[K]> | undefined {
|
|
135
|
+
return this.#states.get(String(key)) as EvState<S[K]> | undefined
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Checks whether a state exists.
|
|
140
|
+
*
|
|
141
|
+
* @param key - State key
|
|
142
|
+
*/
|
|
143
|
+
hasState<K extends keyof S>(key: K): boolean {
|
|
144
|
+
return this.#states.has(String(key))
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Removes a state locally and propagates the removal
|
|
149
|
+
* to other instances via Pub/Sub.
|
|
150
|
+
*
|
|
151
|
+
* @param key - State key
|
|
152
|
+
*/
|
|
153
|
+
removeState<K extends keyof S>(key: K) {
|
|
154
|
+
const channel = String(key)
|
|
155
|
+
|
|
156
|
+
this.removeLocalState(channel)
|
|
157
|
+
|
|
158
|
+
this.#pubsub?.send({
|
|
159
|
+
type: 'remove',
|
|
160
|
+
channel,
|
|
161
|
+
})
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Handles incoming Pub/Sub lifecycle events.
|
|
166
|
+
*
|
|
167
|
+
* @param msg - Pub/Sub message payload
|
|
168
|
+
*/
|
|
169
|
+
private pubSubCallback(msg: any) {
|
|
170
|
+
if (!msg || typeof msg.channel !== 'string') return
|
|
171
|
+
|
|
172
|
+
switch (msg.type) {
|
|
173
|
+
case 'create': {
|
|
174
|
+
if (!this.#states.has(msg.channel)) {
|
|
175
|
+
this.createLocalState(msg.channel, msg.initialValue)
|
|
176
|
+
}
|
|
177
|
+
break
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
case 'remove': {
|
|
181
|
+
this.removeLocalState(msg.channel)
|
|
182
|
+
break
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
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
|
+
}
|