@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/dist/commonjs/realtime.d.ts +51 -13
- package/dist/commonjs/realtime.d.ts.map +1 -1
- package/dist/commonjs/realtime.js +118 -33
- package/dist/commonjs/realtime.js.map +1 -1
- package/dist/commonjs/sdk.d.ts +1 -0
- package/dist/commonjs/sdk.d.ts.map +1 -1
- package/dist/commonjs/sdk.js +1 -0
- package/dist/commonjs/sdk.js.map +1 -1
- package/dist/esm/realtime.d.ts +51 -13
- package/dist/esm/realtime.d.ts.map +1 -1
- package/dist/esm/realtime.js +118 -33
- package/dist/esm/realtime.js.map +1 -1
- package/dist/esm/sdk.d.ts +1 -0
- package/dist/esm/sdk.d.ts.map +1 -1
- package/dist/esm/sdk.js +1 -0
- package/dist/esm/sdk.js.map +1 -1
- package/package.json +2 -2
- package/src/realtime.ts +156 -31
- package/src/sdk.ts +2 -0
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
|
|
23
|
+
operation?: RealtimeEvent;
|
|
24
|
+
event?: string;
|
|
23
25
|
data: T;
|
|
24
26
|
old?: T;
|
|
25
|
-
|
|
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
|
-
|
|
44
|
+
topic: string;
|
|
32
45
|
private options: RealtimeSubscriptionOptions;
|
|
33
|
-
private callbacks: Map<
|
|
34
|
-
private
|
|
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
|
-
|
|
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.
|
|
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.
|
|
77
|
+
this.isSubscribed = true;
|
|
58
78
|
return this;
|
|
59
79
|
}
|
|
60
80
|
|
|
61
81
|
unsubscribe(): void {
|
|
62
|
-
if (!this.
|
|
82
|
+
if (!this.isSubscribed) return;
|
|
63
83
|
this.client._send({
|
|
64
84
|
type: 'unsubscribe',
|
|
65
85
|
topic: this.topic
|
|
66
86
|
});
|
|
67
|
-
this.
|
|
87
|
+
this.isSubscribed = false;
|
|
68
88
|
this.callbacks.clear();
|
|
69
89
|
}
|
|
70
90
|
|
|
71
|
-
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
this.
|
|
77
|
-
|
|
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
|
-
|
|
80
|
-
|
|
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
|
-
|
|
148
|
+
projectId: string;
|
|
90
149
|
token?: string;
|
|
91
|
-
|
|
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
|
|
158
|
+
private projectId: string;
|
|
98
159
|
private token?: string;
|
|
99
|
-
private
|
|
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
|
-
|
|
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
|
-
|
|
241
|
-
|
|
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
|
-
|
|
259
|
-
|
|
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
|
}
|