gameglue 1.2.4 → 2.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.
- package/README.md +79 -16
- package/dist/gg.cjs.js +1 -1
- package/dist/gg.cjs.js.map +1 -1
- package/dist/gg.esm.js +1 -1
- package/dist/gg.esm.js.map +1 -1
- package/dist/gg.umd.js +1 -1
- package/dist/gg.umd.js.map +1 -1
- package/examples/flight-dashboard.html +69 -39
- package/package.json +1 -1
- package/src/auth.js +187 -86
- package/src/auth.spec.js +167 -77
- package/src/index.js +82 -44
- package/src/listener.js +16 -2
- package/src/listener.spec.js +139 -0
- package/src/test/setup.js +2 -1
- package/src/utils.js +3 -0
- package/src/utils.spec.js +14 -0
- package/dist/gg.sdk.js +0 -1
- /package/{babel.config.js → babel.config.cjs} +0 -0
- /package/{jest.config.js → jest.config.cjs} +0 -0
package/src/auth.spec.js
CHANGED
|
@@ -43,9 +43,9 @@ describe('GameGlueAuth', () => {
|
|
|
43
43
|
console.log = originalConsoleLog;
|
|
44
44
|
console.error = originalConsoleError;
|
|
45
45
|
jest.useRealTimers();
|
|
46
|
-
// Clear storage
|
|
47
|
-
storage.
|
|
48
|
-
storage.
|
|
46
|
+
// Clear storage
|
|
47
|
+
storage.remove('gg-auth-token');
|
|
48
|
+
storage.remove('gg-refresh-token');
|
|
49
49
|
});
|
|
50
50
|
|
|
51
51
|
describe('constructor', () => {
|
|
@@ -96,59 +96,73 @@ describe('GameGlueAuth', () => {
|
|
|
96
96
|
});
|
|
97
97
|
});
|
|
98
98
|
|
|
99
|
-
describe('
|
|
99
|
+
describe('_setAccessToken / getAccessToken', () => {
|
|
100
100
|
it('should store and retrieve access token', () => {
|
|
101
|
-
auth.
|
|
101
|
+
auth._setAccessToken(validAccessToken);
|
|
102
102
|
const retrieved = auth.getAccessToken();
|
|
103
103
|
|
|
104
104
|
expect(retrieved).toBe(validAccessToken);
|
|
105
105
|
});
|
|
106
106
|
|
|
107
107
|
it('should set up token refresh timeout when setting token', () => {
|
|
108
|
-
auth.
|
|
108
|
+
auth._setAccessToken(validAccessToken);
|
|
109
109
|
|
|
110
110
|
// Check that a timeout was scheduled
|
|
111
111
|
expect(jest.getTimerCount()).toBeGreaterThan(0);
|
|
112
112
|
});
|
|
113
113
|
|
|
114
114
|
it('should not set timeout for null token', () => {
|
|
115
|
-
auth.
|
|
115
|
+
auth._setAccessToken(null);
|
|
116
116
|
|
|
117
117
|
expect(jest.getTimerCount()).toBe(0);
|
|
118
118
|
});
|
|
119
119
|
});
|
|
120
120
|
|
|
121
|
-
describe('
|
|
121
|
+
describe('_setRefreshToken / _getRefreshToken', () => {
|
|
122
122
|
it('should store and retrieve refresh token', () => {
|
|
123
|
-
auth.
|
|
124
|
-
const retrieved = auth.
|
|
123
|
+
auth._setRefreshToken(validRefreshToken);
|
|
124
|
+
const retrieved = auth._getRefreshToken();
|
|
125
125
|
|
|
126
126
|
expect(retrieved).toBe(validRefreshToken);
|
|
127
127
|
});
|
|
128
128
|
});
|
|
129
129
|
|
|
130
|
-
describe('
|
|
130
|
+
describe('getUser', () => {
|
|
131
131
|
it('should extract user ID from JWT', () => {
|
|
132
|
-
auth.
|
|
132
|
+
auth._setAccessToken(validAccessToken);
|
|
133
133
|
|
|
134
|
-
const userId = auth.
|
|
134
|
+
const userId = auth.getUser();
|
|
135
135
|
|
|
136
136
|
expect(userId).toBe('user-123');
|
|
137
137
|
});
|
|
138
|
+
|
|
139
|
+
it('should throw error when not authenticated', () => {
|
|
140
|
+
expect(() => auth.getUser()).toThrow('Not authenticated');
|
|
141
|
+
});
|
|
138
142
|
});
|
|
139
143
|
|
|
140
|
-
describe('
|
|
141
|
-
it('should return false
|
|
142
|
-
const result = auth.
|
|
144
|
+
describe('_hasValidTokens', () => {
|
|
145
|
+
it('should return false when no token exists', () => {
|
|
146
|
+
const result = auth._hasValidTokens();
|
|
143
147
|
|
|
144
148
|
expect(result).toBe(false);
|
|
145
149
|
});
|
|
146
150
|
|
|
147
|
-
it('should return true for expired token', () => {
|
|
148
|
-
|
|
151
|
+
it('should return true for valid non-expired token', () => {
|
|
152
|
+
auth._setAccessToken(validAccessToken);
|
|
153
|
+
|
|
154
|
+
const result = auth._hasValidTokens();
|
|
149
155
|
|
|
150
156
|
expect(result).toBe(true);
|
|
151
157
|
});
|
|
158
|
+
|
|
159
|
+
it('should return false for expired token', () => {
|
|
160
|
+
auth._setAccessToken(expiredAccessToken);
|
|
161
|
+
|
|
162
|
+
const result = auth._hasValidTokens();
|
|
163
|
+
|
|
164
|
+
expect(result).toBe(false);
|
|
165
|
+
});
|
|
152
166
|
});
|
|
153
167
|
|
|
154
168
|
describe('isAuthenticated', () => {
|
|
@@ -159,51 +173,96 @@ describe('GameGlueAuth', () => {
|
|
|
159
173
|
});
|
|
160
174
|
|
|
161
175
|
it('should return true for valid non-expired token', async () => {
|
|
162
|
-
auth.
|
|
176
|
+
auth._setAccessToken(validAccessToken);
|
|
163
177
|
|
|
164
178
|
const result = await auth.isAuthenticated();
|
|
165
179
|
|
|
166
180
|
expect(result).toBe(true);
|
|
167
181
|
});
|
|
168
182
|
|
|
169
|
-
it('should
|
|
170
|
-
auth.
|
|
171
|
-
auth.setRefreshToken(validRefreshToken);
|
|
172
|
-
|
|
173
|
-
// Mock successful refresh
|
|
174
|
-
global.fetch.mockResolvedValueOnce({
|
|
175
|
-
status: 200,
|
|
176
|
-
json: () =>
|
|
177
|
-
Promise.resolve({
|
|
178
|
-
access_token: validAccessToken,
|
|
179
|
-
refresh_token: validRefreshToken
|
|
180
|
-
})
|
|
181
|
-
});
|
|
183
|
+
it('should return false for expired token', async () => {
|
|
184
|
+
auth._setAccessToken(expiredAccessToken);
|
|
182
185
|
|
|
183
186
|
const result = await auth.isAuthenticated();
|
|
184
187
|
|
|
185
|
-
expect(
|
|
188
|
+
expect(result).toBe(false);
|
|
186
189
|
});
|
|
187
190
|
|
|
188
|
-
it('should
|
|
189
|
-
|
|
190
|
-
auth.setRefreshToken(validRefreshToken);
|
|
191
|
+
it('should process callback params if present in URL', async () => {
|
|
192
|
+
window.location.hash = '#state=abc123&code=xyz789';
|
|
191
193
|
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
194
|
+
const { OidcClient } = require('oidc-client-ts');
|
|
195
|
+
OidcClient.mockImplementation(() => ({
|
|
196
|
+
createSigninRequest: jest.fn(),
|
|
197
|
+
processSigninResponse: jest.fn().mockResolvedValue({
|
|
198
|
+
access_token: validAccessToken,
|
|
199
|
+
refresh_token: validRefreshToken
|
|
200
|
+
})
|
|
201
|
+
}));
|
|
197
202
|
|
|
198
|
-
const
|
|
203
|
+
const authNew = new GameGlueAuth(mockConfig);
|
|
204
|
+
const result = await authNew.isAuthenticated();
|
|
199
205
|
|
|
200
|
-
expect(result).toBe(
|
|
206
|
+
expect(result).toBe(true);
|
|
207
|
+
// URL should be cleared
|
|
208
|
+
expect(window.history.replaceState).toHaveBeenCalled();
|
|
201
209
|
});
|
|
202
210
|
});
|
|
203
211
|
|
|
204
|
-
describe('
|
|
212
|
+
describe('login', () => {
|
|
213
|
+
it('should call createSigninRequest', async () => {
|
|
214
|
+
const createSigninRequest = jest.fn().mockResolvedValue({ url: 'https://auth.example.com/login' });
|
|
215
|
+
const { OidcClient } = require('oidc-client-ts');
|
|
216
|
+
OidcClient.mockImplementation(() => ({
|
|
217
|
+
createSigninRequest,
|
|
218
|
+
processSigninResponse: jest.fn()
|
|
219
|
+
}));
|
|
220
|
+
|
|
221
|
+
const authNew = new GameGlueAuth(mockConfig);
|
|
222
|
+
authNew.login();
|
|
223
|
+
|
|
224
|
+
// Wait for promise to resolve
|
|
225
|
+
await Promise.resolve();
|
|
226
|
+
|
|
227
|
+
expect(createSigninRequest).toHaveBeenCalledWith({ state: { bar: 15 } });
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
describe('logout', () => {
|
|
232
|
+
it('should clear tokens from storage', () => {
|
|
233
|
+
auth._setAccessToken(validAccessToken);
|
|
234
|
+
auth._setRefreshToken(validRefreshToken);
|
|
235
|
+
|
|
236
|
+
auth.logout({ redirect: false });
|
|
237
|
+
|
|
238
|
+
// After remove, storage.get returns undefined in Node environment (in-memory map)
|
|
239
|
+
expect(auth.getAccessToken()).toBeUndefined();
|
|
240
|
+
expect(auth._getRefreshToken()).toBeUndefined();
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it('should not clear tokens when redirect: false not passed', () => {
|
|
244
|
+
// We can't easily test the redirect behavior in jsdom, but we can verify
|
|
245
|
+
// the method accepts options and clears tokens regardless
|
|
246
|
+
auth._setAccessToken(validAccessToken);
|
|
247
|
+
auth._setRefreshToken(validRefreshToken);
|
|
248
|
+
|
|
249
|
+
// logout() clears tokens first, then redirects
|
|
250
|
+
auth.logout({ redirect: false });
|
|
251
|
+
|
|
252
|
+
expect(auth.getAccessToken()).toBeUndefined();
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it('should accept options object', () => {
|
|
256
|
+
auth._setAccessToken(validAccessToken);
|
|
257
|
+
|
|
258
|
+
// Should not throw when called with different options
|
|
259
|
+
expect(() => auth.logout({ redirect: false })).not.toThrow();
|
|
260
|
+
});
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
describe('_attemptRefresh', () => {
|
|
205
264
|
it('should call token endpoint with correct parameters', async () => {
|
|
206
|
-
auth.
|
|
265
|
+
auth._setRefreshToken(validRefreshToken);
|
|
207
266
|
|
|
208
267
|
global.fetch.mockResolvedValueOnce({
|
|
209
268
|
status: 200,
|
|
@@ -214,7 +273,7 @@ describe('GameGlueAuth', () => {
|
|
|
214
273
|
})
|
|
215
274
|
});
|
|
216
275
|
|
|
217
|
-
await auth.
|
|
276
|
+
await auth._attemptRefresh();
|
|
218
277
|
|
|
219
278
|
expect(global.fetch).toHaveBeenCalledWith(
|
|
220
279
|
'https://auth.gameglue.gg/realms/GameGlue/protocol/openid-connect/token',
|
|
@@ -228,7 +287,7 @@ describe('GameGlueAuth', () => {
|
|
|
228
287
|
});
|
|
229
288
|
|
|
230
289
|
it('should update tokens on successful refresh', async () => {
|
|
231
|
-
auth.
|
|
290
|
+
auth._setRefreshToken(validRefreshToken);
|
|
232
291
|
|
|
233
292
|
const newAccessToken = createMockJwt({ sub: 'user-456' }, 3600);
|
|
234
293
|
const newRefreshToken = createMockJwt({ sub: 'user-456', type: 'refresh' }, 86400);
|
|
@@ -242,16 +301,16 @@ describe('GameGlueAuth', () => {
|
|
|
242
301
|
})
|
|
243
302
|
});
|
|
244
303
|
|
|
245
|
-
await auth.
|
|
304
|
+
await auth._attemptRefresh();
|
|
246
305
|
|
|
247
306
|
expect(auth.getAccessToken()).toBe(newAccessToken);
|
|
248
|
-
expect(auth.
|
|
307
|
+
expect(auth._getRefreshToken()).toBe(newRefreshToken);
|
|
249
308
|
});
|
|
250
309
|
|
|
251
|
-
it('should call refresh callback on successful refresh', async () => {
|
|
310
|
+
it('should call refresh callback with access token on successful refresh', async () => {
|
|
252
311
|
const callback = jest.fn();
|
|
253
312
|
auth.onTokenRefreshed(callback);
|
|
254
|
-
auth.
|
|
313
|
+
auth._setRefreshToken(validRefreshToken);
|
|
255
314
|
|
|
256
315
|
const responseData = {
|
|
257
316
|
access_token: validAccessToken,
|
|
@@ -263,21 +322,21 @@ describe('GameGlueAuth', () => {
|
|
|
263
322
|
json: () => Promise.resolve(responseData)
|
|
264
323
|
});
|
|
265
324
|
|
|
266
|
-
await auth.
|
|
325
|
+
await auth._attemptRefresh();
|
|
267
326
|
|
|
268
|
-
expect(callback).toHaveBeenCalledWith(
|
|
327
|
+
expect(callback).toHaveBeenCalledWith(validAccessToken);
|
|
269
328
|
});
|
|
270
329
|
|
|
271
330
|
it('should handle network errors gracefully', async () => {
|
|
272
|
-
auth.
|
|
331
|
+
auth._setRefreshToken(validRefreshToken);
|
|
273
332
|
global.fetch.mockRejectedValueOnce(new Error('Network error'));
|
|
274
333
|
|
|
275
334
|
// Should not throw
|
|
276
|
-
await expect(auth.
|
|
335
|
+
await expect(auth._attemptRefresh()).resolves.toBeUndefined();
|
|
277
336
|
});
|
|
278
337
|
|
|
279
338
|
it('should not update tokens on non-200 response', async () => {
|
|
280
|
-
auth.
|
|
339
|
+
auth._setRefreshToken(validRefreshToken);
|
|
281
340
|
const originalAccessToken = auth.getAccessToken();
|
|
282
341
|
|
|
283
342
|
global.fetch.mockResolvedValueOnce({
|
|
@@ -285,9 +344,9 @@ describe('GameGlueAuth', () => {
|
|
|
285
344
|
json: () => Promise.resolve({ error: 'invalid_grant' })
|
|
286
345
|
});
|
|
287
346
|
|
|
288
|
-
await auth.
|
|
347
|
+
await auth._attemptRefresh();
|
|
289
348
|
|
|
290
|
-
// Access token should remain unchanged (
|
|
349
|
+
// Access token should remain unchanged (null in this case)
|
|
291
350
|
expect(auth.getAccessToken()).toBe(originalAccessToken);
|
|
292
351
|
});
|
|
293
352
|
});
|
|
@@ -301,58 +360,58 @@ describe('GameGlueAuth', () => {
|
|
|
301
360
|
});
|
|
302
361
|
});
|
|
303
362
|
|
|
304
|
-
describe('
|
|
363
|
+
describe('_setTokenRefreshTimeout', () => {
|
|
305
364
|
it('should schedule refresh before token expiration', () => {
|
|
306
365
|
// Token expires in 1 hour (3600 seconds)
|
|
307
|
-
auth.
|
|
366
|
+
auth._setTokenRefreshTimeout(validAccessToken);
|
|
308
367
|
|
|
309
368
|
// Should have scheduled a timeout
|
|
310
369
|
expect(jest.getTimerCount()).toBe(1);
|
|
311
370
|
});
|
|
312
371
|
|
|
313
372
|
it('should clear previous timeout when setting new one', () => {
|
|
314
|
-
auth.
|
|
315
|
-
auth.
|
|
373
|
+
auth._setTokenRefreshTimeout(validAccessToken);
|
|
374
|
+
auth._setTokenRefreshTimeout(validAccessToken);
|
|
316
375
|
|
|
317
376
|
// Should only have one active timeout
|
|
318
377
|
expect(jest.getTimerCount()).toBe(1);
|
|
319
378
|
});
|
|
320
379
|
|
|
321
380
|
it('should not schedule timeout for expired token', () => {
|
|
322
|
-
auth.
|
|
381
|
+
auth._setTokenRefreshTimeout(expiredAccessToken);
|
|
323
382
|
|
|
324
383
|
// Timer count may be 0 since the timeUntilExp is negative
|
|
325
384
|
// The important thing is it doesn't throw
|
|
326
385
|
});
|
|
327
386
|
});
|
|
328
387
|
|
|
329
|
-
describe('
|
|
388
|
+
describe('_hasCallbackParams', () => {
|
|
330
389
|
it('should return true when hash contains state and code', () => {
|
|
331
390
|
window.location.hash = '#state=abc123&code=xyz789';
|
|
332
391
|
|
|
333
|
-
expect(auth.
|
|
392
|
+
expect(auth._hasCallbackParams()).toBe(true);
|
|
334
393
|
});
|
|
335
394
|
|
|
336
395
|
it('should return true when hash contains state and error', () => {
|
|
337
396
|
window.location.hash = '#state=abc123&error=access_denied';
|
|
338
397
|
|
|
339
|
-
expect(auth.
|
|
398
|
+
expect(auth._hasCallbackParams()).toBe(true);
|
|
340
399
|
});
|
|
341
400
|
|
|
342
401
|
it('should return false when hash does not contain state', () => {
|
|
343
402
|
window.location.hash = '#code=xyz789';
|
|
344
403
|
|
|
345
|
-
expect(auth.
|
|
404
|
+
expect(auth._hasCallbackParams()).toBe(false);
|
|
346
405
|
});
|
|
347
406
|
|
|
348
407
|
it('should return false when hash is empty', () => {
|
|
349
408
|
window.location.hash = '';
|
|
350
409
|
|
|
351
|
-
expect(auth.
|
|
410
|
+
expect(auth._hasCallbackParams()).toBe(false);
|
|
352
411
|
});
|
|
353
412
|
});
|
|
354
413
|
|
|
355
|
-
describe('
|
|
414
|
+
describe('_processCallback', () => {
|
|
356
415
|
it('should process signin response and store tokens', async () => {
|
|
357
416
|
const { OidcClient } = require('oidc-client-ts');
|
|
358
417
|
OidcClient.mockImplementation(() => ({
|
|
@@ -363,14 +422,14 @@ describe('GameGlueAuth', () => {
|
|
|
363
422
|
}));
|
|
364
423
|
|
|
365
424
|
const authNew = new GameGlueAuth(mockConfig);
|
|
366
|
-
await authNew.
|
|
425
|
+
await authNew._processCallback();
|
|
367
426
|
|
|
368
427
|
expect(authNew.getAccessToken()).toBe(validAccessToken);
|
|
369
|
-
expect(authNew.
|
|
370
|
-
expect(window.history.
|
|
428
|
+
expect(authNew._getRefreshToken()).toBe(validRefreshToken);
|
|
429
|
+
expect(window.history.replaceState).toHaveBeenCalled();
|
|
371
430
|
});
|
|
372
431
|
|
|
373
|
-
it('should
|
|
432
|
+
it('should throw on error response', async () => {
|
|
374
433
|
const { OidcClient } = require('oidc-client-ts');
|
|
375
434
|
OidcClient.mockImplementation(() => ({
|
|
376
435
|
processSigninResponse: jest.fn().mockResolvedValue({
|
|
@@ -380,12 +439,43 @@ describe('GameGlueAuth', () => {
|
|
|
380
439
|
}));
|
|
381
440
|
|
|
382
441
|
const authNew = new GameGlueAuth(mockConfig);
|
|
383
|
-
const consoleSpy = jest.spyOn(console, 'error').mockImplementation();
|
|
384
442
|
|
|
385
|
-
await authNew.
|
|
443
|
+
await expect(authNew._processCallback()).rejects.toThrow('access_denied');
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
it('should throw when no access token received', async () => {
|
|
447
|
+
const { OidcClient } = require('oidc-client-ts');
|
|
448
|
+
OidcClient.mockImplementation(() => ({
|
|
449
|
+
processSigninResponse: jest.fn().mockResolvedValue({
|
|
450
|
+
access_token: null
|
|
451
|
+
})
|
|
452
|
+
}));
|
|
453
|
+
|
|
454
|
+
const authNew = new GameGlueAuth(mockConfig);
|
|
455
|
+
|
|
456
|
+
await expect(authNew._processCallback()).rejects.toThrow('No access token received');
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
it('should handle concurrent calls (only process once)', async () => {
|
|
460
|
+
const { OidcClient } = require('oidc-client-ts');
|
|
461
|
+
const processSigninResponse = jest.fn().mockResolvedValue({
|
|
462
|
+
access_token: validAccessToken,
|
|
463
|
+
refresh_token: validRefreshToken
|
|
464
|
+
});
|
|
465
|
+
OidcClient.mockImplementation(() => ({
|
|
466
|
+
processSigninResponse
|
|
467
|
+
}));
|
|
468
|
+
|
|
469
|
+
const authNew = new GameGlueAuth(mockConfig);
|
|
470
|
+
|
|
471
|
+
// Call twice concurrently
|
|
472
|
+
await Promise.all([
|
|
473
|
+
authNew._processCallback(),
|
|
474
|
+
authNew._processCallback()
|
|
475
|
+
]);
|
|
386
476
|
|
|
387
|
-
|
|
388
|
-
|
|
477
|
+
// Should only have been called once due to locking
|
|
478
|
+
expect(processSigninResponse).toHaveBeenCalledTimes(1);
|
|
389
479
|
});
|
|
390
480
|
});
|
|
391
481
|
});
|
package/src/index.js
CHANGED
|
@@ -1,74 +1,112 @@
|
|
|
1
1
|
import { GameGlueAuth } from './auth';
|
|
2
2
|
import { io } from "socket.io-client";
|
|
3
|
-
import { Listener} from "./listener";
|
|
3
|
+
import { Listener } from "./listener";
|
|
4
4
|
|
|
5
5
|
const GAME_IDS = {
|
|
6
6
|
'msfs': true,
|
|
7
7
|
};
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
const DEFAULT_SOCKET_URL = 'https://socks.gameglue.gg';
|
|
10
|
+
|
|
11
|
+
class GameGlue extends GameGlueAuth {
|
|
10
12
|
constructor(cfg) {
|
|
11
13
|
super(cfg);
|
|
12
|
-
this._socket =
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
async auth() {
|
|
16
|
-
await this.authenticate();
|
|
17
|
-
if (await this.isAuthenticated()) {
|
|
18
|
-
await this.initialize();
|
|
19
|
-
}
|
|
20
|
-
return this.getUserId();
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
async initialize() {
|
|
24
|
-
return new Promise((resolve) => {
|
|
25
|
-
const token = this.getAccessToken();
|
|
26
|
-
// For local development, use 'http://localhost:3031'
|
|
27
|
-
this._socket = io('https://socks.gameglue.gg', {
|
|
28
|
-
transports: ['websocket'],
|
|
29
|
-
auth: {
|
|
30
|
-
token
|
|
31
|
-
}
|
|
32
|
-
});
|
|
33
|
-
// TODO: Update this code to use the new refresh logic. Example in gg-client repo
|
|
34
|
-
this._socket.on('connect', () => {
|
|
35
|
-
resolve();
|
|
36
|
-
});
|
|
37
|
-
this.onTokenRefreshed(this.updateSocketAuth);
|
|
38
|
-
});
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
updateSocketAuth(authToken) {
|
|
42
|
-
this._socket.auth.token = authToken;
|
|
14
|
+
this._socket = null;
|
|
15
|
+
this._socketUrl = cfg.socketUrl || DEFAULT_SOCKET_URL;
|
|
16
|
+
this._connectPromise = null;
|
|
43
17
|
}
|
|
44
|
-
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Create a listener for game telemetry.
|
|
21
|
+
* Connects to socket server lazily on first call.
|
|
22
|
+
* @param {Object} config - { userId, gameId, fields? }
|
|
23
|
+
* @returns {Promise<Listener>}
|
|
24
|
+
*/
|
|
45
25
|
async createListener(config) {
|
|
46
26
|
if (!config) throw new Error('Not a valid listener config');
|
|
47
27
|
if (!config.gameId || !GAME_IDS[config.gameId]) throw new Error('Not a valid Game ID');
|
|
48
28
|
if (!config.userId) throw new Error('User ID not supplied');
|
|
49
29
|
if (config.fields && !Array.isArray(config.fields)) throw new Error('fields must be an array');
|
|
50
30
|
|
|
51
|
-
// Ensure socket is
|
|
52
|
-
|
|
53
|
-
await this.initialize();
|
|
54
|
-
}
|
|
31
|
+
// Ensure socket is connected (lazy initialization)
|
|
32
|
+
await this._ensureConnected();
|
|
55
33
|
|
|
56
34
|
const listener = new Listener(this._socket, config);
|
|
57
35
|
const establishConnectionResponse = await listener.establishConnection();
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
36
|
+
|
|
37
|
+
// Handle reconnection
|
|
38
|
+
this._socket.io.on('reconnect_attempt', () => {
|
|
39
|
+
this._updateSocketAuth(this.getAccessToken());
|
|
61
40
|
});
|
|
62
41
|
this._socket.io.on('reconnect', () => {
|
|
63
42
|
listener.establishConnection();
|
|
64
43
|
});
|
|
65
|
-
|
|
44
|
+
|
|
66
45
|
if (establishConnectionResponse.status !== 'success') {
|
|
67
46
|
throw new Error(`There was a problem setting up the listener. Reason: ${establishConnectionResponse.reason}`);
|
|
68
47
|
}
|
|
69
|
-
|
|
48
|
+
|
|
70
49
|
return listener.setupEventListener();
|
|
71
50
|
}
|
|
51
|
+
|
|
52
|
+
// ============ Internal Methods ============
|
|
53
|
+
|
|
54
|
+
async _ensureConnected() {
|
|
55
|
+
// Already connected
|
|
56
|
+
if (this._socket?.connected) {
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Connection in progress - wait for it
|
|
61
|
+
if (this._connectPromise) {
|
|
62
|
+
await this._connectPromise;
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Start new connection
|
|
67
|
+
this._connectPromise = this._connect();
|
|
68
|
+
|
|
69
|
+
try {
|
|
70
|
+
await this._connectPromise;
|
|
71
|
+
} finally {
|
|
72
|
+
this._connectPromise = null;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
_connect() {
|
|
77
|
+
return new Promise((resolve, reject) => {
|
|
78
|
+
const token = this.getAccessToken();
|
|
79
|
+
|
|
80
|
+
if (!token) {
|
|
81
|
+
reject(new Error('Not authenticated - call isAuthenticated() first'));
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
this._socket = io(this._socketUrl, {
|
|
86
|
+
transports: ['websocket'],
|
|
87
|
+
auth: { token }
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
this._socket.on('connect', () => {
|
|
91
|
+
resolve();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
this._socket.on('connect_error', (err) => {
|
|
95
|
+
reject(new Error(`Socket connection failed: ${err.message}`));
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
// Update socket auth when token refreshes
|
|
99
|
+
this.onTokenRefreshed((newToken) => {
|
|
100
|
+
this._updateSocketAuth(newToken);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
_updateSocketAuth(authToken) {
|
|
106
|
+
if (this._socket) {
|
|
107
|
+
this._socket.auth.token = authToken;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
72
110
|
}
|
|
73
111
|
|
|
74
112
|
if (typeof window !== 'undefined') {
|
|
@@ -76,4 +114,4 @@ if (typeof window !== 'undefined') {
|
|
|
76
114
|
}
|
|
77
115
|
|
|
78
116
|
export default GameGlue;
|
|
79
|
-
export { GameGlue };
|
|
117
|
+
export { GameGlue };
|
package/src/listener.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
|
|
1
|
+
import EventEmitter from 'event-emitter';
|
|
2
2
|
|
|
3
3
|
export class Listener {
|
|
4
4
|
constructor(socket, config) {
|
|
@@ -39,7 +39,21 @@ export class Listener {
|
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
setupEventListener() {
|
|
42
|
-
this._socket.on('update',
|
|
42
|
+
this._socket.on('update', (payload) => {
|
|
43
|
+
// Apply client-side field filtering if fields are specified
|
|
44
|
+
if (this._fields && this._fields.length > 0 && payload?.data) {
|
|
45
|
+
const filteredData = {};
|
|
46
|
+
for (const field of this._fields) {
|
|
47
|
+
if (field in payload.data) {
|
|
48
|
+
filteredData[field] = payload.data[field];
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
this.emit('update', { ...payload, data: filteredData });
|
|
52
|
+
} else {
|
|
53
|
+
// No filtering - pass through full payload
|
|
54
|
+
this.emit('update', payload);
|
|
55
|
+
}
|
|
56
|
+
});
|
|
43
57
|
return this;
|
|
44
58
|
}
|
|
45
59
|
|