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.
@@ -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
+ }