evstream 1.0.2 → 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/.me/dump.ts +102 -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/package.json +5 -1
- package/readme.md +844 -674
- package/src/adapters/pub-sub.ts +88 -0
- package/src/adapters/redis.ts +120 -53
- package/src/extensions/state-manager.ts +186 -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
|
+
}
|
package/src/adapters/redis.ts
CHANGED
|
@@ -1,53 +1,120 @@
|
|
|
1
|
-
import Redis, { RedisOptions } from 'ioredis'
|
|
2
|
-
|
|
3
|
-
import { EvStateAdapter } from '../types.js'
|
|
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
|
-
this.#
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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 }
|
|
@@ -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
|
+
}
|