@aerostack/sdk-node 0.8.4 → 0.8.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/__tests__/realtime.test.d.ts +2 -0
- package/dist/commonjs/__tests__/realtime.test.d.ts.map +1 -0
- package/dist/commonjs/__tests__/realtime.test.js +364 -0
- package/dist/commonjs/__tests__/realtime.test.js.map +1 -0
- package/dist/commonjs/__tests__/sdk.test.d.ts +2 -0
- package/dist/commonjs/__tests__/sdk.test.d.ts.map +1 -0
- package/dist/commonjs/__tests__/sdk.test.js +356 -0
- package/dist/commonjs/__tests__/sdk.test.js.map +1 -0
- package/dist/commonjs/realtime.d.ts +2 -0
- package/dist/commonjs/realtime.d.ts.map +1 -1
- package/dist/commonjs/realtime.js +19 -5
- package/dist/commonjs/realtime.js.map +1 -1
- package/dist/esm/__tests__/realtime.test.d.ts +2 -0
- package/dist/esm/__tests__/realtime.test.d.ts.map +1 -0
- package/dist/esm/__tests__/realtime.test.js +362 -0
- package/dist/esm/__tests__/realtime.test.js.map +1 -0
- package/dist/esm/__tests__/sdk.test.d.ts +2 -0
- package/dist/esm/__tests__/sdk.test.d.ts.map +1 -0
- package/dist/esm/__tests__/sdk.test.js +354 -0
- package/dist/esm/__tests__/sdk.test.js.map +1 -0
- package/dist/esm/realtime.d.ts +2 -0
- package/dist/esm/realtime.d.ts.map +1 -1
- package/dist/esm/realtime.js +19 -5
- package/dist/esm/realtime.js.map +1 -1
- package/examples/e2e/__tests__/e2e.test.ts +118 -0
- package/examples/e2e/package.json +15 -0
- package/examples/e2e/vitest.config.ts +8 -0
- package/package.json +2 -2
- package/src/__tests__/realtime.test.ts +430 -0
- package/src/__tests__/sdk.test.ts +412 -0
- package/src/realtime.ts +17 -3
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
|
|
3
|
+
// Mock the generated APIs and realtime
|
|
4
|
+
vi.mock('../_generated/index.js', () => {
|
|
5
|
+
const createMockApi = () => ({});
|
|
6
|
+
class MockConfiguration {
|
|
7
|
+
basePath: string;
|
|
8
|
+
headers: Record<string, string>;
|
|
9
|
+
apiKey?: string;
|
|
10
|
+
constructor(opts: any = {}) {
|
|
11
|
+
this.basePath = opts.basePath || '';
|
|
12
|
+
this.headers = opts.headers || {};
|
|
13
|
+
this.apiKey = opts.apiKey;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
return {
|
|
17
|
+
Configuration: MockConfiguration,
|
|
18
|
+
AuthenticationApi: vi.fn().mockImplementation(() => ({ auth: true })),
|
|
19
|
+
CacheApi: vi.fn().mockImplementation(() => ({
|
|
20
|
+
cacheGet: vi.fn().mockResolvedValue({ _exists: true, value: 'cached' }),
|
|
21
|
+
cacheSet: vi.fn().mockResolvedValue({}),
|
|
22
|
+
cacheDelete: vi.fn().mockResolvedValue({}),
|
|
23
|
+
cacheList: vi.fn().mockResolvedValue({ keys: [], cursor: null }),
|
|
24
|
+
cacheKeys: vi.fn().mockResolvedValue({ keys: ['k1', 'k2'] }),
|
|
25
|
+
cacheGetMany: vi.fn().mockResolvedValue({ results: [] }),
|
|
26
|
+
cacheSetMany: vi.fn().mockResolvedValue({}),
|
|
27
|
+
cacheDeleteMany: vi.fn().mockResolvedValue({}),
|
|
28
|
+
cacheFlush: vi.fn().mockResolvedValue({}),
|
|
29
|
+
cacheExpire: vi.fn().mockResolvedValue({}),
|
|
30
|
+
cacheIncrement: vi.fn().mockResolvedValue({ value: 5 }),
|
|
31
|
+
})),
|
|
32
|
+
DatabaseApi: vi.fn().mockImplementation(() => ({
|
|
33
|
+
dbQuery: vi.fn().mockResolvedValue({ results: [] }),
|
|
34
|
+
})),
|
|
35
|
+
QueueApi: vi.fn().mockImplementation(() => ({ queue: true })),
|
|
36
|
+
StorageApi: vi.fn().mockImplementation(() => ({ storage: true })),
|
|
37
|
+
AIApi: vi.fn().mockImplementation(() => ({ ai: true })),
|
|
38
|
+
ServicesApi: vi.fn().mockImplementation(() => ({ services: true })),
|
|
39
|
+
GatewayApi: vi.fn().mockImplementation(() => ({ gateway: true })),
|
|
40
|
+
};
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
vi.mock('../realtime.js', () => {
|
|
44
|
+
return {
|
|
45
|
+
NodeRealtimeClient: vi.fn().mockImplementation(() => ({
|
|
46
|
+
connect: vi.fn(),
|
|
47
|
+
disconnect: vi.fn(),
|
|
48
|
+
channel: vi.fn(),
|
|
49
|
+
})),
|
|
50
|
+
};
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
vi.mock('@aerostack/core', () => {
|
|
54
|
+
return {
|
|
55
|
+
AerostackClient: vi.fn().mockImplementation(() => ({
|
|
56
|
+
db: { query: vi.fn() },
|
|
57
|
+
cache: { get: vi.fn() },
|
|
58
|
+
})),
|
|
59
|
+
};
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
import { SDK, Aerostack, createClient } from '../sdk.js';
|
|
63
|
+
|
|
64
|
+
describe('SDK', () => {
|
|
65
|
+
describe('constructor', () => {
|
|
66
|
+
it('should initialize with default options', () => {
|
|
67
|
+
const sdk = new SDK();
|
|
68
|
+
expect(sdk).toBeDefined();
|
|
69
|
+
expect(sdk.database).toBeDefined();
|
|
70
|
+
expect(sdk.auth).toBeDefined();
|
|
71
|
+
expect(sdk.cache).toBeDefined();
|
|
72
|
+
expect(sdk.queue).toBeDefined();
|
|
73
|
+
expect(sdk.storage).toBeDefined();
|
|
74
|
+
expect(sdk.ai).toBeDefined();
|
|
75
|
+
expect(sdk.services).toBeDefined();
|
|
76
|
+
expect(sdk.gateway).toBeDefined();
|
|
77
|
+
expect(sdk.realtime).toBeDefined();
|
|
78
|
+
expect(sdk.rpc).toBeDefined();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('should accept apiKey option', () => {
|
|
82
|
+
const sdk = new SDK({ apiKey: 'my-key' });
|
|
83
|
+
expect(sdk).toBeDefined();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('should accept apiKeyAuth alias', () => {
|
|
87
|
+
const sdk = new SDK({ apiKeyAuth: 'my-key' });
|
|
88
|
+
expect(sdk).toBeDefined();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('should accept serverUrl option', () => {
|
|
92
|
+
const sdk = new SDK({ serverUrl: 'https://custom.com/v1' });
|
|
93
|
+
expect(sdk).toBeDefined();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('should accept serverURL alias', () => {
|
|
97
|
+
const sdk = new SDK({ serverURL: 'https://custom.com/v1' });
|
|
98
|
+
expect(sdk).toBeDefined();
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('should accept projectId', () => {
|
|
102
|
+
const sdk = new SDK({ projectId: 'proj-1' });
|
|
103
|
+
expect(sdk).toBeDefined();
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('should accept maxReconnectAttempts', () => {
|
|
107
|
+
const sdk = new SDK({ maxReconnectAttempts: 5 });
|
|
108
|
+
expect(sdk).toBeDefined();
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
describe('CacheFacade', () => {
|
|
113
|
+
it('should get a cached value', async () => {
|
|
114
|
+
const sdk = new SDK({ apiKey: 'key' });
|
|
115
|
+
const result = await sdk.cache.get('test-key');
|
|
116
|
+
expect(result).toBe('cached');
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('should return null when key not found', async () => {
|
|
120
|
+
const sdk = new SDK({ apiKey: 'key' });
|
|
121
|
+
// Override the mock for this test
|
|
122
|
+
const cacheApi = (sdk.cache as any).api;
|
|
123
|
+
cacheApi.cacheGet.mockResolvedValueOnce({ _exists: false });
|
|
124
|
+
const result = await sdk.cache.get('missing');
|
|
125
|
+
expect(result).toBeNull();
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('should set a value', async () => {
|
|
129
|
+
const sdk = new SDK({ apiKey: 'key' });
|
|
130
|
+
await sdk.cache.set('key', 'value');
|
|
131
|
+
const cacheApi = (sdk.cache as any).api;
|
|
132
|
+
expect(cacheApi.cacheSet).toHaveBeenCalled();
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('should set a value with TTL', async () => {
|
|
136
|
+
const sdk = new SDK({ apiKey: 'key' });
|
|
137
|
+
await sdk.cache.set('key', 'value', 3600);
|
|
138
|
+
const cacheApi = (sdk.cache as any).api;
|
|
139
|
+
const call = cacheApi.cacheSet.mock.calls[0][0];
|
|
140
|
+
expect(call.cacheSetRequest.ttl).toBe(3600);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('should delete a key', async () => {
|
|
144
|
+
const sdk = new SDK({ apiKey: 'key' });
|
|
145
|
+
await sdk.cache.delete('key');
|
|
146
|
+
const cacheApi = (sdk.cache as any).api;
|
|
147
|
+
expect(cacheApi.cacheDelete).toHaveBeenCalled();
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('should check if key exists', async () => {
|
|
151
|
+
const sdk = new SDK({ apiKey: 'key' });
|
|
152
|
+
const result = await sdk.cache.exists('key');
|
|
153
|
+
expect(result).toBe(true);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('should list keys', async () => {
|
|
157
|
+
const sdk = new SDK({ apiKey: 'key' });
|
|
158
|
+
await sdk.cache.list('prefix:', 10, 'cursor');
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('should get all keys', async () => {
|
|
162
|
+
const sdk = new SDK({ apiKey: 'key' });
|
|
163
|
+
const keys = await sdk.cache.keys('prefix:');
|
|
164
|
+
expect(keys).toEqual(['k1', 'k2']);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('should get many keys', async () => {
|
|
168
|
+
const sdk = new SDK({ apiKey: 'key' });
|
|
169
|
+
const result = await sdk.cache.getMany(['k1', 'k2']);
|
|
170
|
+
expect(result).toEqual([]);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('should set many entries', async () => {
|
|
174
|
+
const sdk = new SDK({ apiKey: 'key' });
|
|
175
|
+
await sdk.cache.setMany([{ key: 'k1', value: 'v1' }]);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('should delete many keys', async () => {
|
|
179
|
+
const sdk = new SDK({ apiKey: 'key' });
|
|
180
|
+
await sdk.cache.deleteMany(['k1', 'k2']);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('should flush cache', async () => {
|
|
184
|
+
const sdk = new SDK({ apiKey: 'key' });
|
|
185
|
+
await sdk.cache.flush('prefix:');
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it('should expire a key', async () => {
|
|
189
|
+
const sdk = new SDK({ apiKey: 'key' });
|
|
190
|
+
await sdk.cache.expire('key', 300);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it('should increment a counter', async () => {
|
|
194
|
+
const sdk = new SDK({ apiKey: 'key' });
|
|
195
|
+
const result = await sdk.cache.increment('counter', 1, 0, 3600);
|
|
196
|
+
expect(result).toBe(5);
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
describe('DatabaseFacade', () => {
|
|
201
|
+
it('should execute a query', async () => {
|
|
202
|
+
const sdk = new SDK({ apiKey: 'key' });
|
|
203
|
+
const result = await sdk.database.dbQuery({
|
|
204
|
+
dbQueryRequest: { sql: 'SELECT 1', params: [] },
|
|
205
|
+
});
|
|
206
|
+
expect(result).toEqual({ results: [] });
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it('should accept requestBody alias', async () => {
|
|
210
|
+
const sdk = new SDK({ apiKey: 'key' });
|
|
211
|
+
await sdk.database.dbQuery({
|
|
212
|
+
requestBody: { sql: 'SELECT 1', params: [] },
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
describe('setApiKey', () => {
|
|
218
|
+
it('should update all service instances', () => {
|
|
219
|
+
const sdk = new SDK({ apiKey: 'old-key' });
|
|
220
|
+
sdk.setApiKey('new-key');
|
|
221
|
+
// After setApiKey, all services should be recreated
|
|
222
|
+
expect(sdk.database).toBeDefined();
|
|
223
|
+
expect(sdk.auth).toBeDefined();
|
|
224
|
+
expect(sdk.cache).toBeDefined();
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
describe('streamGateway', () => {
|
|
229
|
+
it('should make POST request to gateway endpoint', async () => {
|
|
230
|
+
const mockFetch = vi.fn().mockResolvedValue({
|
|
231
|
+
ok: true,
|
|
232
|
+
body: createMockStream('data: {"choices":[{"delta":{"content":"Hello"}}]}\n\ndata: [DONE]\n\n'),
|
|
233
|
+
});
|
|
234
|
+
vi.stubGlobal('fetch', mockFetch);
|
|
235
|
+
|
|
236
|
+
const sdk = new SDK({ apiKey: 'key', serverUrl: 'https://api.test.com/v1' });
|
|
237
|
+
const result = await sdk.streamGateway({
|
|
238
|
+
apiSlug: 'my-chatbot',
|
|
239
|
+
messages: [{ role: 'user', content: 'Hi' }],
|
|
240
|
+
consumerKey: 'ask_live_123',
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
expect(result.text).toBe('Hello');
|
|
244
|
+
expect(mockFetch).toHaveBeenCalledOnce();
|
|
245
|
+
const [url, opts] = mockFetch.mock.calls[0];
|
|
246
|
+
expect(url).toBe('https://api.test.com/api/gateway/my-chatbot/v1/chat/completions');
|
|
247
|
+
expect(opts.headers.Authorization).toBe('Bearer ask_live_123');
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it('should use token when consumerKey not provided', async () => {
|
|
251
|
+
const mockFetch = vi.fn().mockResolvedValue({
|
|
252
|
+
ok: true,
|
|
253
|
+
body: createMockStream('data: [DONE]\n\n'),
|
|
254
|
+
});
|
|
255
|
+
vi.stubGlobal('fetch', mockFetch);
|
|
256
|
+
|
|
257
|
+
const sdk = new SDK({ serverUrl: 'https://api.test.com/v1' });
|
|
258
|
+
await sdk.streamGateway({
|
|
259
|
+
apiSlug: 'bot',
|
|
260
|
+
messages: [{ role: 'user', content: 'Hi' }],
|
|
261
|
+
token: 'jwt-token',
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
expect(mockFetch.mock.calls[0][1].headers.Authorization).toBe('Bearer jwt-token');
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it('should prepend system prompt', async () => {
|
|
268
|
+
const mockFetch = vi.fn().mockResolvedValue({
|
|
269
|
+
ok: true,
|
|
270
|
+
body: createMockStream('data: [DONE]\n\n'),
|
|
271
|
+
});
|
|
272
|
+
vi.stubGlobal('fetch', mockFetch);
|
|
273
|
+
|
|
274
|
+
const sdk = new SDK({ serverUrl: 'https://api.test.com/v1' });
|
|
275
|
+
await sdk.streamGateway({
|
|
276
|
+
apiSlug: 'bot',
|
|
277
|
+
messages: [{ role: 'user', content: 'Hi' }],
|
|
278
|
+
systemPrompt: 'You are helpful',
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
|
|
282
|
+
expect(body.messages[0]).toEqual({ role: 'system', content: 'You are helpful' });
|
|
283
|
+
expect(body.messages[1]).toEqual({ role: 'user', content: 'Hi' });
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it('should call onToken callback', async () => {
|
|
287
|
+
const mockFetch = vi.fn().mockResolvedValue({
|
|
288
|
+
ok: true,
|
|
289
|
+
body: createMockStream('data: {"choices":[{"delta":{"content":"Hi"}}]}\n\ndata: {"choices":[{"delta":{"content":" there"}}]}\n\ndata: [DONE]\n\n'),
|
|
290
|
+
});
|
|
291
|
+
vi.stubGlobal('fetch', mockFetch);
|
|
292
|
+
|
|
293
|
+
const tokens: string[] = [];
|
|
294
|
+
const sdk = new SDK({ serverUrl: 'https://api.test.com/v1' });
|
|
295
|
+
await sdk.streamGateway({
|
|
296
|
+
apiSlug: 'bot',
|
|
297
|
+
messages: [{ role: 'user', content: 'Hi' }],
|
|
298
|
+
onToken: (delta: string) => tokens.push(delta),
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
expect(tokens).toEqual(['Hi', ' there']);
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
it('should call onDone callback', async () => {
|
|
305
|
+
const mockFetch = vi.fn().mockResolvedValue({
|
|
306
|
+
ok: true,
|
|
307
|
+
body: createMockStream('data: {"choices":[{"delta":{"content":"Hi"}}],"usage":{"total_tokens":10}}\n\ndata: [DONE]\n\n'),
|
|
308
|
+
});
|
|
309
|
+
vi.stubGlobal('fetch', mockFetch);
|
|
310
|
+
|
|
311
|
+
let doneResult: any;
|
|
312
|
+
const sdk = new SDK({ serverUrl: 'https://api.test.com/v1' });
|
|
313
|
+
await sdk.streamGateway({
|
|
314
|
+
apiSlug: 'bot',
|
|
315
|
+
messages: [{ role: 'user', content: 'Hi' }],
|
|
316
|
+
onDone: (result: any) => { doneResult = result; },
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
expect(doneResult.tokensUsed).toBe(10);
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
it('should throw on non-OK response', async () => {
|
|
323
|
+
const mockFetch = vi.fn().mockResolvedValue({
|
|
324
|
+
ok: false,
|
|
325
|
+
status: 500,
|
|
326
|
+
json: vi.fn().mockResolvedValue({ error: 'Server error' }),
|
|
327
|
+
});
|
|
328
|
+
vi.stubGlobal('fetch', mockFetch);
|
|
329
|
+
|
|
330
|
+
const sdk = new SDK({ serverUrl: 'https://api.test.com/v1' });
|
|
331
|
+
await expect(sdk.streamGateway({
|
|
332
|
+
apiSlug: 'bot',
|
|
333
|
+
messages: [{ role: 'user', content: 'Hi' }],
|
|
334
|
+
})).rejects.toThrow('Server error');
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
it('should call onError on failure', async () => {
|
|
338
|
+
const mockFetch = vi.fn().mockResolvedValue({
|
|
339
|
+
ok: false,
|
|
340
|
+
status: 500,
|
|
341
|
+
json: vi.fn().mockResolvedValue({ error: 'boom' }),
|
|
342
|
+
});
|
|
343
|
+
vi.stubGlobal('fetch', mockFetch);
|
|
344
|
+
|
|
345
|
+
let capturedError: Error | null = null;
|
|
346
|
+
const sdk = new SDK({ serverUrl: 'https://api.test.com/v1' });
|
|
347
|
+
|
|
348
|
+
try {
|
|
349
|
+
await sdk.streamGateway({
|
|
350
|
+
apiSlug: 'bot',
|
|
351
|
+
messages: [{ role: 'user', content: 'Hi' }],
|
|
352
|
+
onError: (err: Error) => { capturedError = err; },
|
|
353
|
+
});
|
|
354
|
+
} catch {
|
|
355
|
+
// Expected
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
expect(capturedError).not.toBeNull();
|
|
359
|
+
expect(capturedError!.message).toBe('boom');
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
it('should handle abort gracefully', async () => {
|
|
363
|
+
const abortController = new AbortController();
|
|
364
|
+
const mockFetch = vi.fn().mockRejectedValue(
|
|
365
|
+
Object.assign(new Error('Aborted'), { name: 'AbortError' })
|
|
366
|
+
);
|
|
367
|
+
vi.stubGlobal('fetch', mockFetch);
|
|
368
|
+
|
|
369
|
+
const sdk = new SDK({ serverUrl: 'https://api.test.com/v1' });
|
|
370
|
+
const result = await sdk.streamGateway({
|
|
371
|
+
apiSlug: 'bot',
|
|
372
|
+
messages: [{ role: 'user', content: 'Hi' }],
|
|
373
|
+
signal: abortController.signal,
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
expect(result.text).toBe('');
|
|
377
|
+
expect(result.tokensUsed).toBe(0);
|
|
378
|
+
});
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
describe('Aerostack alias', () => {
|
|
382
|
+
it('should be the same as SDK', () => {
|
|
383
|
+
expect(Aerostack).toBe(SDK);
|
|
384
|
+
});
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
describe('createClient', () => {
|
|
388
|
+
it('should return an SDK instance', () => {
|
|
389
|
+
const client = createClient({ apiKey: 'key' });
|
|
390
|
+
expect(client).toBeInstanceOf(SDK);
|
|
391
|
+
});
|
|
392
|
+
});
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
// Helper to create a mock ReadableStream
|
|
396
|
+
function createMockStream(text: string) {
|
|
397
|
+
const encoder = new TextEncoder();
|
|
398
|
+
const data = encoder.encode(text);
|
|
399
|
+
let read = false;
|
|
400
|
+
return {
|
|
401
|
+
getReader: () => ({
|
|
402
|
+
read: async () => {
|
|
403
|
+
if (!read) {
|
|
404
|
+
read = true;
|
|
405
|
+
return { done: false, value: data };
|
|
406
|
+
}
|
|
407
|
+
return { done: true, value: undefined };
|
|
408
|
+
},
|
|
409
|
+
cancel: vi.fn(),
|
|
410
|
+
}),
|
|
411
|
+
};
|
|
412
|
+
}
|
package/src/realtime.ts
CHANGED
|
@@ -99,6 +99,7 @@ export class RealtimeSubscription<T = any> {
|
|
|
99
99
|
});
|
|
100
100
|
this._isSubscribed = false;
|
|
101
101
|
this.callbacks.clear();
|
|
102
|
+
this.client._removeSubscription(this.topic);
|
|
102
103
|
}
|
|
103
104
|
|
|
104
105
|
get isSubscribed() { return this._isSubscribed; }
|
|
@@ -227,8 +228,6 @@ export class NodeRealtimeClient {
|
|
|
227
228
|
private async _doConnect(): Promise<void> {
|
|
228
229
|
this._setStatus('connecting');
|
|
229
230
|
const url = new URL(this.wsUrl);
|
|
230
|
-
if (this.apiKey) url.searchParams.set('apiKey', this.apiKey);
|
|
231
|
-
if (this.token) url.searchParams.set('token', this.token);
|
|
232
231
|
if (this.projectId) url.searchParams.set('projectId', this.projectId);
|
|
233
232
|
|
|
234
233
|
return new Promise(async (resolve, reject) => {
|
|
@@ -245,7 +244,14 @@ export class NodeRealtimeClient {
|
|
|
245
244
|
}
|
|
246
245
|
}
|
|
247
246
|
|
|
248
|
-
|
|
247
|
+
// SECURITY: Pass credentials via Sec-WebSocket-Protocol header — never as URL query params
|
|
248
|
+
// (URL params appear in CDN logs, browser history, and Referer headers).
|
|
249
|
+
const protocols: string[] = [];
|
|
250
|
+
if (this.apiKey) protocols.push(`aerostack-key.${this.apiKey}`);
|
|
251
|
+
if (this.token) protocols.push(`aerostack-token.${this.token}`);
|
|
252
|
+
if (protocols.length > 0) protocols.push('aerostack-v1');
|
|
253
|
+
const protocolsArg = protocols.length > 0 ? protocols : undefined;
|
|
254
|
+
this.ws = protocolsArg ? new WsClass(url.toString(), protocolsArg) : new WsClass(url.toString());
|
|
249
255
|
|
|
250
256
|
this.ws!.onopen = () => {
|
|
251
257
|
this._setStatus('connected');
|
|
@@ -302,6 +308,9 @@ export class NodeRealtimeClient {
|
|
|
302
308
|
}
|
|
303
309
|
|
|
304
310
|
channel<T = any>(topic: string, options: RealtimeSubscriptionOptions = {}): RealtimeSubscription<T> {
|
|
311
|
+
if (!this.projectId) {
|
|
312
|
+
throw new Error('projectId is required for channel subscriptions. Set it in NodeRealtimeOptions.');
|
|
313
|
+
}
|
|
305
314
|
let fullTopic: string;
|
|
306
315
|
if (!topic.includes('/')) {
|
|
307
316
|
fullTopic = `table/${topic}/${this.projectId}`;
|
|
@@ -328,6 +337,11 @@ export class NodeRealtimeClient {
|
|
|
328
337
|
return Math.random().toString(36).slice(2) + Date.now().toString(36);
|
|
329
338
|
}
|
|
330
339
|
|
|
340
|
+
/** @internal — Remove a subscription from the map (called on unsubscribe) */
|
|
341
|
+
_removeSubscription(topic: string): void {
|
|
342
|
+
this.subscriptions.delete(topic);
|
|
343
|
+
}
|
|
344
|
+
|
|
331
345
|
/** @internal */
|
|
332
346
|
_send(data: any): void {
|
|
333
347
|
if (this.ws && this._status === 'connected') {
|