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