lightstream-sdk 1.0.0

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,250 @@
1
+ // src/stream-client.ts
2
+ // Real-time Raw WebSocket client for LightStream Gateway.
3
+
4
+ import { LIGHTSTREAM_ERROR_CODES, type LightStreamErrorCode } from './errors.js'
5
+ import type {
6
+ ClientOptions,
7
+ ConnectionStatus,
8
+ LightStreamEventMap,
9
+ Platform,
10
+ StreamConnectedEvent,
11
+ StreamOfflineEvent,
12
+ ConnectionErrorEvent,
13
+ ChatMessageEvent,
14
+ GiftEvent,
15
+ } from './types.js'
16
+
17
+ export class LightStreamStreamClient {
18
+ private ws: any = null
19
+ private _status: ConnectionStatus = 'idle'
20
+ private listeners: Map<string, Set<Function>> = new Map()
21
+ private pingTimer: any = null
22
+ private reconnectTimer: any = null
23
+ private reconnectAttempts = 0
24
+ private currentPlatform: Platform | null = null
25
+ private currentChannel: string | null = null
26
+
27
+ constructor(private options: ClientOptions) {}
28
+
29
+ get status(): ConnectionStatus {
30
+ return this._status
31
+ }
32
+
33
+ private setStatus(newStatus: ConnectionStatus) {
34
+ if (this._status === newStatus) return
35
+ this._status = newStatus
36
+ this.emit('status', newStatus)
37
+ }
38
+
39
+ connect(platform: Platform, channel: string): Promise<void> {
40
+ this.currentPlatform = platform
41
+ this.currentChannel = channel.trim().toLowerCase()
42
+ return this.initSocket()
43
+ }
44
+
45
+ private async initSocket(): Promise<void> {
46
+ if (!this.currentPlatform || !this.currentChannel) {
47
+ throw new Error('Platform and channel must be specified to connect')
48
+ }
49
+
50
+ this.cleanupSocket()
51
+ this.setStatus('connecting')
52
+
53
+ const baseWs = (this.options.wsUrl || 'wss://gateway.lightstream.lat/ws').replace(/\/+$/, '')
54
+ const url = new URL(baseWs)
55
+ url.searchParams.set('platform', this.currentPlatform)
56
+ url.searchParams.set('channel', this.currentChannel)
57
+
58
+ if (this.options.token) {
59
+ url.searchParams.set('token', this.options.token)
60
+ } else if (this.options.apiKey) {
61
+ url.searchParams.set('apiKey', this.options.apiKey)
62
+ }
63
+
64
+ const SocketCtor = typeof WebSocket !== 'undefined'
65
+ ? WebSocket
66
+ : ((await import('ws')).default as any)
67
+
68
+ return new Promise((resolve) => {
69
+ try {
70
+ const socket = new SocketCtor(url.toString())
71
+ this.ws = socket
72
+
73
+ socket.onopen = () => {
74
+ this.reconnectAttempts = 0
75
+ this.startHeartbeat()
76
+ resolve()
77
+ }
78
+
79
+ socket.onmessage = (event: any) => {
80
+ this.handleIncoming(typeof event.data === 'string' ? event.data : event.data?.toString())
81
+ }
82
+
83
+ socket.onerror = (err: any) => {
84
+ this.handleFailure(LIGHTSTREAM_ERROR_CODES.UPSTREAM_ERROR, err?.message || 'WebSocket connection error')
85
+ }
86
+
87
+ socket.onclose = () => {
88
+ this.cleanupSocket()
89
+ if (this._status !== 'disconnected' && this._status !== 'offline') {
90
+ this.setStatus('disconnected')
91
+ this.emit('disconnected', 'Socket connection closed')
92
+ this.scheduleReconnect()
93
+ }
94
+ }
95
+ } catch (err: any) {
96
+ this.handleFailure(LIGHTSTREAM_ERROR_CODES.INTERNAL_ERROR, err.message)
97
+ resolve()
98
+ }
99
+ })
100
+ }
101
+
102
+ private handleIncoming(rawData?: string) {
103
+ if (!rawData) return
104
+ let parsed: any
105
+ try {
106
+ parsed = JSON.parse(rawData)
107
+ } catch {
108
+ return
109
+ }
110
+
111
+ if (parsed.type === 'pong') return
112
+
113
+ if (parsed.type === 'connected') {
114
+ this.setStatus('connected')
115
+ const evt: StreamConnectedEvent = {
116
+ channel: parsed.channel || this.currentChannel!,
117
+ platform: parsed.platform || this.currentPlatform!,
118
+ isLive: true,
119
+ title: parsed.title,
120
+ viewerCount: parsed.viewerCount,
121
+ startedAt: parsed.startedAt,
122
+ }
123
+ this.emit('connected', evt)
124
+ return
125
+ }
126
+
127
+ if (parsed.type === 'offline') {
128
+ this.setStatus('offline')
129
+ const offlineEvt: StreamOfflineEvent = {
130
+ channel: parsed.channel || this.currentChannel!,
131
+ platform: parsed.platform || this.currentPlatform!,
132
+ isLive: false,
133
+ code: (parsed.code as LightStreamErrorCode) || LIGHTSTREAM_ERROR_CODES.STREAMER_OFFLINE,
134
+ message: parsed.message || 'The streamer is currently offline',
135
+ }
136
+ this.emit('stream:offline', offlineEvt)
137
+ return
138
+ }
139
+
140
+ if (parsed.type === 'error') {
141
+ this.handleFailure(parsed.code || LIGHTSTREAM_ERROR_CODES.INTERNAL_ERROR, parsed.message)
142
+ return
143
+ }
144
+
145
+ if (parsed.event === 'chat' || parsed.type === 'chat') {
146
+ this.emit('chat', parsed.data as ChatMessageEvent)
147
+ return
148
+ }
149
+
150
+ if (parsed.event === 'gift' || parsed.type === 'gift') {
151
+ this.emit('gift', parsed.data as GiftEvent)
152
+ return
153
+ }
154
+
155
+ this.emit('raw', parsed)
156
+ }
157
+
158
+ private handleFailure(code: LightStreamErrorCode, message: string) {
159
+ this.setStatus('error')
160
+ const errPayload: ConnectionErrorEvent = {
161
+ channel: this.currentChannel || undefined,
162
+ platform: this.currentPlatform || undefined,
163
+ code,
164
+ message,
165
+ retryable: code !== LIGHTSTREAM_ERROR_CODES.FORBIDDEN_CHANNEL && code !== LIGHTSTREAM_ERROR_CODES.AUTHENTICATION_FAILED,
166
+ }
167
+ this.emit('error', errPayload)
168
+ if (errPayload.retryable) {
169
+ this.scheduleReconnect()
170
+ }
171
+ }
172
+
173
+ private scheduleReconnect() {
174
+ if (this.options.autoReconnect === false) return
175
+ const maxAttempts = this.options.maxReconnectAttempts ?? 5
176
+ if (this.reconnectAttempts >= maxAttempts) return
177
+
178
+ this.reconnectAttempts++
179
+ this.setStatus('reconnecting')
180
+
181
+ const baseDelay = this.options.reconnectIntervalMs ?? 2000
182
+ const delay = Math.min(baseDelay * Math.pow(1.5, this.reconnectAttempts - 1), 30000)
183
+
184
+ this.reconnectTimer = setTimeout(() => {
185
+ this.initSocket().catch(() => {})
186
+ }, delay)
187
+ }
188
+
189
+ private startHeartbeat() {
190
+ const interval = this.options.pingIntervalMs ?? 30000
191
+ this.pingTimer = setInterval(() => {
192
+ if (this.ws && this.ws.readyState === 1) {
193
+ this.ws.send(JSON.stringify({ type: 'ping' }))
194
+ }
195
+ }, interval)
196
+ }
197
+
198
+ private cleanupSocket() {
199
+ if (this.pingTimer) {
200
+ clearInterval(this.pingTimer)
201
+ this.pingTimer = null
202
+ }
203
+ if (this.reconnectTimer) {
204
+ clearTimeout(this.reconnectTimer)
205
+ this.reconnectTimer = null
206
+ }
207
+ if (this.ws) {
208
+ try {
209
+ this.ws.onclose = null
210
+ this.ws.onerror = null
211
+ this.ws.close()
212
+ } catch {}
213
+ this.ws = null
214
+ }
215
+ }
216
+
217
+ disconnect(): void {
218
+ this.cleanupSocket()
219
+ this.setStatus('disconnected')
220
+ this.currentChannel = null
221
+ this.currentPlatform = null
222
+ }
223
+
224
+ on<K extends keyof LightStreamEventMap>(event: K, handler: LightStreamEventMap[K]): void {
225
+ if (!this.listeners.has(event)) {
226
+ this.listeners.set(event, new Set())
227
+ }
228
+ this.listeners.get(event)!.add(handler)
229
+ }
230
+
231
+ off<K extends keyof LightStreamEventMap>(event: K, handler: LightStreamEventMap[K]): void {
232
+ const handlers = this.listeners.get(event)
233
+ if (handlers) {
234
+ handlers.delete(handler)
235
+ }
236
+ }
237
+
238
+ private emit(event: string, ...args: any[]): void {
239
+ const handlers = this.listeners.get(event)
240
+ if (handlers) {
241
+ for (const handler of handlers) {
242
+ try {
243
+ handler(...args)
244
+ } catch (e) {
245
+ console.error(`Error in LightStream listener [${event}]:`, e)
246
+ }
247
+ }
248
+ }
249
+ }
250
+ }
package/src/types.ts ADDED
@@ -0,0 +1,102 @@
1
+ // src/types.ts
2
+ // Domain Types and Contracts for LightStream SDK.
3
+
4
+ import type { LightStreamErrorCode } from './errors.js'
5
+
6
+ export type Platform = 'tiktok' | 'kick' | 'twitch'
7
+ export type Timeframe = 'hourly' | 'daily' | 'weekly' | 'all'
8
+
9
+ export type ConnectionStatus =
10
+ | 'idle'
11
+ | 'connecting'
12
+ | 'connected'
13
+ | 'offline'
14
+ | 'reconnecting'
15
+ | 'disconnected'
16
+ | 'error'
17
+
18
+ export interface ClientOptions {
19
+ baseUrl?: string
20
+ wsUrl?: string
21
+ apiKey?: string
22
+ token?: string
23
+ jwtKey?: string
24
+ autoReconnect?: boolean
25
+ maxReconnectAttempts?: number
26
+ reconnectIntervalMs?: number
27
+ pingIntervalMs?: number
28
+ }
29
+
30
+ export interface StreamConnectedEvent {
31
+ channel: string
32
+ platform: Platform
33
+ isLive: true
34
+ title?: string
35
+ viewerCount?: number
36
+ startedAt?: string
37
+ }
38
+
39
+ export interface StreamOfflineEvent {
40
+ channel: string
41
+ platform: Platform
42
+ isLive: false
43
+ code: LightStreamErrorCode
44
+ message: string
45
+ }
46
+
47
+ export interface ConnectionErrorEvent {
48
+ channel?: string
49
+ platform?: Platform
50
+ code: LightStreamErrorCode
51
+ message: string
52
+ details?: unknown
53
+ retryable: boolean
54
+ }
55
+
56
+ export interface ChatMessageEvent {
57
+ id: string
58
+ platform: Platform
59
+ channel: string
60
+ user: {
61
+ id: string
62
+ username: string
63
+ displayName: string
64
+ avatar?: string
65
+ badges?: string[]
66
+ }
67
+ content: string
68
+ timestamp: number
69
+ emotes?: Array<{ name: string; url: string }>
70
+ }
71
+
72
+ export interface GiftEvent {
73
+ id: string
74
+ platform: Platform
75
+ channel: string
76
+ user: {
77
+ id: string
78
+ username: string
79
+ displayName: string
80
+ avatar?: string
81
+ }
82
+ gift: {
83
+ id: string
84
+ name: string
85
+ value: number
86
+ repeatCount: number
87
+ repeatEnd: boolean
88
+ iconUrl?: string
89
+ }
90
+ timestamp: number
91
+ }
92
+
93
+ export interface LightStreamEventMap {
94
+ status: (status: ConnectionStatus) => void
95
+ connected: (event: StreamConnectedEvent) => void
96
+ 'stream:offline': (event: StreamOfflineEvent) => void
97
+ error: (event: ConnectionErrorEvent) => void
98
+ disconnected: (reason: string) => void
99
+ chat: (event: ChatMessageEvent) => void
100
+ gift: (event: GiftEvent) => void
101
+ raw: (data: unknown) => void
102
+ }