@aerostack/sdk-web 0.7.4 → 0.7.6

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/src/realtime.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Aerostack Realtime Client for Web/Browser SDK
3
3
  */
4
- export type RealtimeEvent = 'INSERT' | 'UPDATE' | 'DELETE' | '*';
4
+ export type RealtimeEvent = 'INSERT' | 'UPDATE' | 'DELETE' | '*' | string;
5
5
 
6
6
  export interface RealtimeMessage {
7
7
  type: string;
@@ -16,22 +16,35 @@ export interface RealtimeSubscriptionOptions {
16
16
 
17
17
  export type RealtimeCallback<T = any> = (payload: RealtimePayload<T>) => void;
18
18
 
19
+ /** Typed payload for realtime events */
19
20
  export interface RealtimePayload<T = any> {
20
- type: 'db_change' | 'chat_message';
21
+ type: 'db_change' | 'chat_message' | 'event';
21
22
  topic: string;
22
- operation: RealtimeEvent;
23
+ operation?: RealtimeEvent;
24
+ event?: string;
23
25
  data: T;
24
26
  old?: T;
25
- timestamp?: string;
27
+ userId?: string;
28
+ timestamp?: number | string;
26
29
  [key: string]: any;
27
30
  }
28
31
 
32
+ /** Chat history message returned from REST API */
33
+ export interface HistoryMessage {
34
+ id: string;
35
+ room_id: string;
36
+ user_id: string;
37
+ event: string;
38
+ data: any;
39
+ created_at: number;
40
+ }
41
+
29
42
  export class RealtimeSubscription<T = any> {
30
43
  private client: RealtimeClient;
31
- private topic: string;
44
+ topic: string;
32
45
  private options: RealtimeSubscriptionOptions;
33
- private callbacks: Map<RealtimeEvent, Set<RealtimeCallback<T>>> = new Map();
34
- private _isSubscribed: boolean = false;
46
+ private callbacks: Map<string, Set<RealtimeCallback<T>>> = new Map();
47
+ private isSubscribed: boolean = false;
35
48
 
36
49
  constructor(client: RealtimeClient, topic: string, options: RealtimeSubscriptionOptions = {}) {
37
50
  this.client = client;
@@ -39,7 +52,8 @@ export class RealtimeSubscription<T = any> {
39
52
  this.options = options;
40
53
  }
41
54
 
42
- on(event: RealtimeEvent, callback: RealtimeCallback<T>): this {
55
+ /** Listen for DB change events (INSERT/UPDATE/DELETE/*) or custom named events */
56
+ on(event: RealtimeEvent | string, callback: RealtimeCallback<T>): this {
43
57
  if (!this.callbacks.has(event)) {
44
58
  this.callbacks.set(event, new Set());
45
59
  }
@@ -47,56 +61,104 @@ export class RealtimeSubscription<T = any> {
47
61
  return this;
48
62
  }
49
63
 
64
+ /** Remove a specific callback for an event */
65
+ off(event: RealtimeEvent | string, callback: RealtimeCallback<T>): this {
66
+ this.callbacks.get(event)?.delete(callback);
67
+ return this;
68
+ }
69
+
50
70
  subscribe(): this {
51
- if (this._isSubscribed) return this;
71
+ if (this.isSubscribed) return this;
52
72
  this.client._send({
53
73
  type: 'subscribe',
54
74
  topic: this.topic,
55
75
  filter: this.options.filter
56
76
  });
57
- this._isSubscribed = true;
77
+ this.isSubscribed = true;
58
78
  return this;
59
79
  }
60
80
 
61
81
  unsubscribe(): void {
62
- if (!this._isSubscribed) return;
82
+ if (!this.isSubscribed) return;
63
83
  this.client._send({
64
84
  type: 'unsubscribe',
65
85
  topic: this.topic
66
86
  });
67
- this._isSubscribed = false;
87
+ this.isSubscribed = false;
68
88
  this.callbacks.clear();
69
89
  }
70
90
 
71
- get isSubscribed() { return this._isSubscribed; }
91
+ // ─── Phase 1: Pub/Sub — Publish custom events ─────────────────────────
92
+ /** Publish a custom event to all subscribers on this channel */
93
+ publish(event: string, data: any, options?: { persist?: boolean }): void {
94
+ this.client._send({
95
+ type: 'publish',
96
+ topic: this.topic,
97
+ event,
98
+ data,
99
+ persist: options?.persist,
100
+ id: this.client._generateId(),
101
+ });
102
+ }
72
103
 
73
- /** @internal */
74
- _emit(payload: RealtimePayload<T>): void {
75
- const event = payload.operation as RealtimeEvent;
76
- this.callbacks.get(event)?.forEach(cb => {
77
- try { cb(payload); } catch (e) { console.error('Realtime callback error:', e); }
104
+ // ─── Phase 2: Chat History ────────────────────────────────────────────
105
+ /** Fetch persisted message history for this channel (requires persist: true on publish) */
106
+ async getHistory(limit: number = 50, before?: number): Promise<HistoryMessage[]> {
107
+ return this.client._fetchHistory(this.topic, limit, before);
108
+ }
109
+
110
+ // ─── Phase 3: Presence ────────────────────────────────────────────────
111
+ /** Track this user's presence state on this channel (auto-synced to subscribers) */
112
+ track(state: Record<string, any>): void {
113
+ this.client._send({
114
+ type: 'track',
115
+ topic: this.topic,
116
+ state,
78
117
  });
79
- this.callbacks.get('*')?.forEach(cb => {
80
- try { cb(payload); } catch (e) { console.error('Realtime callback error:', e); }
118
+ }
119
+
120
+ /** Stop tracking presence on this channel */
121
+ untrack(): void {
122
+ this.client._send({
123
+ type: 'untrack',
124
+ topic: this.topic,
81
125
  });
82
126
  }
127
+
128
+ /** @internal */
129
+ _emit(payload: RealtimePayload<T>): void {
130
+ // DB change events (INSERT/UPDATE/DELETE)
131
+ if (payload.operation) {
132
+ const event = payload.operation as string;
133
+ this.callbacks.get(event)?.forEach(cb => cb(payload));
134
+ }
135
+ // Custom named events ('player-moved', 'presence:join', etc.)
136
+ if (payload.event) {
137
+ this.callbacks.get(payload.event)?.forEach(cb => cb(payload));
138
+ }
139
+ // Catch-all
140
+ this.callbacks.get('*')?.forEach(cb => cb(payload));
141
+ }
83
142
  }
84
143
 
85
144
  export type RealtimeStatus = 'idle' | 'connecting' | 'connected' | 'reconnecting' | 'disconnected';
86
145
 
87
146
  export interface RealtimeClientOptions {
88
147
  baseUrl: string;
89
- apiKey?: string;
148
+ projectId: string;
90
149
  token?: string;
91
- projectId?: string;
150
+ userId?: string;
151
+ apiKey?: string;
152
+ /** Max reconnect attempts before giving up (default: Infinity) */
92
153
  maxReconnectAttempts?: number;
93
154
  }
94
155
 
95
156
  export class RealtimeClient {
96
157
  private baseUrl: string;
97
- private apiKey?: string;
158
+ private projectId: string;
98
159
  private token?: string;
99
- private projectId?: string;
160
+ private userId?: string;
161
+ private apiKey?: string;
100
162
  private ws: WebSocket | null = null;
101
163
  private subscriptions: Map<string, RealtimeSubscription> = new Map();
102
164
  private reconnectTimer: any = null;
@@ -106,27 +168,35 @@ export class RealtimeClient {
106
168
  private _connectingPromise: Promise<void> | null = null;
107
169
  private _status: RealtimeStatus = 'idle';
108
170
  private _statusListeners: Set<(s: RealtimeStatus) => void> = new Set();
171
+ // HTTP base URL for REST endpoints (history, etc.)
172
+ private _httpBaseUrl: string;
173
+ // Pong tracking
109
174
  private _lastPong: number = 0;
175
+ // Max reconnect attempts
110
176
  private _maxReconnectAttempts: number;
111
177
  private _maxRetriesListeners: Set<() => void> = new Set();
112
178
 
113
179
  constructor(options: RealtimeClientOptions) {
114
180
  const wsBase = options.baseUrl.replace(/\/v1\/?$/, '').replace(/^http/, 'ws');
115
181
  this.baseUrl = `${wsBase}/api/realtime`;
182
+ this._httpBaseUrl = options.baseUrl.replace(/\/v1\/?$/, '');
183
+ this.projectId = options.projectId;
116
184
  this.token = options.token;
185
+ this.userId = options.userId;
117
186
  this.apiKey = options.apiKey;
118
- this.projectId = options.projectId; // Initialize projectId
119
187
  this._maxReconnectAttempts = options.maxReconnectAttempts ?? Infinity;
120
188
  }
121
189
 
122
190
  get status(): RealtimeStatus { return this._status; }
123
191
  get connected(): boolean { return this._status === 'connected'; }
124
192
 
193
+ /** Subscribe to connection status changes. Returns unsubscribe fn. */
125
194
  onStatusChange(cb: (status: RealtimeStatus) => void): () => void {
126
195
  this._statusListeners.add(cb);
127
196
  return () => this._statusListeners.delete(cb);
128
197
  }
129
198
 
199
+ /** Called when max reconnect attempts exceeded. Returns unsubscribe fn. */
130
200
  onMaxRetriesExceeded(cb: () => void): () => void {
131
201
  this._maxRetriesListeners.add(cb);
132
202
  return () => this._maxRetriesListeners.delete(cb);
@@ -137,6 +207,7 @@ export class RealtimeClient {
137
207
  this._statusListeners.forEach(cb => cb(s));
138
208
  }
139
209
 
210
+ /** Update the auth token on a live connection */
140
211
  setToken(newToken: string): void {
141
212
  this.token = newToken;
142
213
  this._send({ type: 'auth', token: newToken });
@@ -155,8 +226,9 @@ export class RealtimeClient {
155
226
  this._setStatus('connecting');
156
227
  return new Promise((resolve, reject) => {
157
228
  const url = new URL(this.baseUrl);
229
+ url.searchParams.set('projectId', this.projectId);
230
+ if (this.userId) url.searchParams.set('userId', this.userId);
158
231
  if (this.token) url.searchParams.set('token', this.token);
159
- if (this.projectId) url.searchParams.set('projectId', this.projectId);
160
232
 
161
233
  // SECURITY: Pass API key via Sec-WebSocket-Protocol header only — never as URL query param
162
234
  // (URL params appear in CDN logs, browser history, and Referer headers).
@@ -175,11 +247,12 @@ export class RealtimeClient {
175
247
  this._lastPong = Date.now();
176
248
  this.startHeartbeat();
177
249
  this._setupOfflineDetection();
250
+ // Flush queued messages
178
251
  while (this._sendQueue.length > 0) {
179
252
  this.ws!.send(JSON.stringify(this._sendQueue.shift()));
180
253
  }
181
254
  for (const sub of this.subscriptions.values()) {
182
- if (sub.isSubscribed) sub.subscribe();
255
+ sub.subscribe();
183
256
  }
184
257
  resolve();
185
258
  };
@@ -237,8 +310,19 @@ export class RealtimeClient {
237
310
  return sub as RealtimeSubscription<T>;
238
311
  }
239
312
 
240
- sendChat(roomId: string, text: string): void {
241
- this._send({ type: 'chat', roomId, text });
313
+ /** Legacy: send a chat message (now persisted to DB) */
314
+ sendChat(roomId: string, text: string, metadata?: Record<string, any>): void {
315
+ this._send({ type: 'chat', roomId, text, metadata });
316
+ }
317
+
318
+ /** Legacy: get a chat room subscription */
319
+ chatRoom(roomId: string): RealtimeSubscription {
320
+ return this.channel(`chat/${roomId}/${this.projectId}`);
321
+ }
322
+
323
+ /** @internal — Generate unique message ID for ack tracking */
324
+ _generateId(): string {
325
+ return Math.random().toString(36).slice(2) + Date.now().toString(36);
242
326
  }
243
327
 
244
328
  /** @internal */
@@ -250,13 +334,53 @@ export class RealtimeClient {
250
334
  }
251
335
  }
252
336
 
337
+ /** @internal — Fetch chat/event history via REST API */
338
+ async _fetchHistory(room: string, limit: number = 50, before?: number): Promise<HistoryMessage[]> {
339
+ const url = new URL(`${this._httpBaseUrl}/api/v1/public/realtime/history`);
340
+ url.searchParams.set('room', room);
341
+ url.searchParams.set('limit', String(limit));
342
+ if (before) url.searchParams.set('before', String(before));
343
+
344
+ const headers: Record<string, string> = {};
345
+ if (this.apiKey) headers['X-Aerostack-Key'] = this.apiKey;
346
+ if (this.token) headers['Authorization'] = `Bearer ${this.token}`;
347
+
348
+ const res = await fetch(url.toString(), { headers });
349
+ const json = await res.json() as any;
350
+ return json.messages || [];
351
+ }
352
+
253
353
  private handleMessage(data: RealtimeMessage) {
354
+ // Track pong for liveness
254
355
  if (data.type === 'pong') {
255
356
  this._lastPong = Date.now();
256
357
  return;
257
358
  }
258
- const sub = this.subscriptions.get(data.topic);
259
- if (sub) sub._emit(data as any);
359
+
360
+ // Ack (fire-and-forget acknowledgment from server)
361
+ if (data.type === 'ack') {
362
+ return;
363
+ }
364
+
365
+ // Route to subscription: db_change, chat_message, event, presence:*
366
+ if (data.type === 'db_change' || data.type === 'chat_message' || data.type === 'event') {
367
+ const sub = this.subscriptions.get(data.topic);
368
+ if (sub) {
369
+ sub._emit(data as any);
370
+ }
371
+ }
372
+
373
+ // Re-key subscription on server-confirmed topic (for non-TS SDKs compatibility)
374
+ if (data.type === 'subscribed' && data.topic) {
375
+ for (const [origTopic, sub] of this.subscriptions.entries()) {
376
+ if (data.topic !== origTopic && data.topic.startsWith(origTopic)) {
377
+ this.subscriptions.delete(origTopic);
378
+ sub.topic = data.topic;
379
+ this.subscriptions.set(data.topic, sub);
380
+ break;
381
+ }
382
+ }
383
+ }
260
384
  }
261
385
 
262
386
  private startHeartbeat() {
@@ -298,6 +422,7 @@ export class RealtimeClient {
298
422
  }
299
423
  }
300
424
 
425
+ // Offline/online detection (browser only)
301
426
  private _handleOnline = () => {
302
427
  if (this._status !== 'connected') {
303
428
  this.reconnectAttempts = 0;
package/src/sdk.ts CHANGED
@@ -13,6 +13,7 @@ export interface SDKOptions {
13
13
  /** Alias for serverUrl for backward compatibility */
14
14
  serverURL?: string;
15
15
  maxReconnectAttempts?: number;
16
+ projectId?: string;
16
17
  }
17
18
 
18
19
  /**
@@ -69,6 +70,7 @@ export class SDK {
69
70
  this.realtime = new RealtimeClient({
70
71
  baseUrl: serverUrl,
71
72
  apiKey: apiKey,
73
+ projectId: options.projectId || '',
72
74
  maxReconnectAttempts: options.maxReconnectAttempts
73
75
  });
74
76
  }