gameglue 3.0.2 → 4.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/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/server.js +2 -2
- package/package.json +1 -1
- package/src/auth.js +1 -1
- package/src/index.js +43 -0
- package/src/listener.js +4 -17
- package/src/listener.spec.js +19 -3
- package/src/presence_listener.js +112 -0
- package/types/index.d.ts +102 -2
package/examples/server.js
CHANGED
|
@@ -27,8 +27,8 @@ function handleRequest(req, res) {
|
|
|
27
27
|
// Remove query string
|
|
28
28
|
filePath = filePath.split('?')[0];
|
|
29
29
|
|
|
30
|
-
// Security: prevent directory traversal
|
|
31
|
-
filePath = path.normalize(filePath).replace(/^(\.\.[\/\\])+/, '');
|
|
30
|
+
// Security: prevent directory traversal (normalize but keep forward slashes for URL matching)
|
|
31
|
+
filePath = path.normalize(filePath).replace(/^(\.\.[\/\\])+/, '').split(path.sep).join('/');
|
|
32
32
|
|
|
33
33
|
// Check if requesting SDK from parent dist folder
|
|
34
34
|
if (filePath.startsWith('/dist/')) {
|
package/package.json
CHANGED
package/src/auth.js
CHANGED
package/src/index.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { GameGlueAuth } from './auth';
|
|
2
2
|
import { io } from "socket.io-client";
|
|
3
3
|
import { Listener } from "./listener";
|
|
4
|
+
import { PresenceListener } from "./presence_listener";
|
|
4
5
|
import { isCorsError, logCorsHelp } from './utils';
|
|
5
6
|
import { getGameSchema, getCategorySchema, normalizeTelemetry } from '@gameglue/schemas';
|
|
6
7
|
|
|
@@ -52,6 +53,48 @@ class GameGlue extends GameGlueAuth {
|
|
|
52
53
|
return listener.setupEventListener();
|
|
53
54
|
}
|
|
54
55
|
|
|
56
|
+
/**
|
|
57
|
+
* Create a presence listener for public broadcaster updates.
|
|
58
|
+
* This does not require authentication.
|
|
59
|
+
* @param {Object} config - { clientId, gameId }
|
|
60
|
+
* @returns {Promise<PresenceListener>}
|
|
61
|
+
*/
|
|
62
|
+
async createPresenceListener(config) {
|
|
63
|
+
if (!config) throw new Error('Not a valid presence listener config');
|
|
64
|
+
if (!config.gameId || !GAME_IDS[config.gameId]) throw new Error('Not a valid Game ID');
|
|
65
|
+
if (!config.clientId) throw new Error('Client ID not supplied');
|
|
66
|
+
|
|
67
|
+
const socket = io(this._socketUrl, {
|
|
68
|
+
transports: ['websocket'],
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
await new Promise((resolve, reject) => {
|
|
72
|
+
socket.on('connect', resolve);
|
|
73
|
+
socket.on('connect_error', (err) => {
|
|
74
|
+
if (isCorsError(err)) {
|
|
75
|
+
logCorsHelp('WebSocket Connection', this._socketUrl);
|
|
76
|
+
}
|
|
77
|
+
reject(new Error(`Socket connection failed: ${err.message}`));
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
const listener = new PresenceListener(socket, config);
|
|
82
|
+
const establishConnectionResponse = await listener.establishConnection();
|
|
83
|
+
|
|
84
|
+
// Handle reconnection
|
|
85
|
+
socket.io.on('reconnect', () => {
|
|
86
|
+
listener.establishConnection();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
if (establishConnectionResponse.status !== 'success') {
|
|
90
|
+
throw new Error(
|
|
91
|
+
`There was a problem setting up the presence listener. Reason: ${establishConnectionResponse.reason}`
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return listener.setupEventListener();
|
|
96
|
+
}
|
|
97
|
+
|
|
55
98
|
// ============ Internal Methods ============
|
|
56
99
|
|
|
57
100
|
async _ensureConnected() {
|
package/src/listener.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import EventEmitter from 'event-emitter';
|
|
2
|
-
import { getGameSchema, normalizeTelemetry
|
|
2
|
+
import { getGameSchema, normalizeTelemetry } from '@gameglue/schemas';
|
|
3
3
|
|
|
4
4
|
export class Listener {
|
|
5
5
|
constructor(socket, config) {
|
|
@@ -150,26 +150,13 @@ export class Listener {
|
|
|
150
150
|
throw new Error('command must be a non-empty string');
|
|
151
151
|
}
|
|
152
152
|
|
|
153
|
-
// Denormalize canonical command to game-specific command
|
|
154
|
-
let gameCommand = command;
|
|
155
|
-
let gameValue = value;
|
|
156
|
-
|
|
157
|
-
if (this._gameSchema) {
|
|
158
|
-
const denormalized = denormalizeCommand(command, this._gameSchema, value);
|
|
159
|
-
if (denormalized) {
|
|
160
|
-
gameCommand = denormalized.command;
|
|
161
|
-
gameValue = denormalized.value;
|
|
162
|
-
}
|
|
163
|
-
// If not found, pass through as-is (allows game-specific commands)
|
|
164
|
-
}
|
|
165
|
-
|
|
166
153
|
return new Promise((resolve) => {
|
|
167
154
|
const payload = {
|
|
168
155
|
userId: this._config.userId,
|
|
169
156
|
gameId: this._config.gameId,
|
|
170
157
|
data: {
|
|
171
|
-
fieldName:
|
|
172
|
-
value
|
|
158
|
+
fieldName: command,
|
|
159
|
+
value
|
|
173
160
|
}
|
|
174
161
|
};
|
|
175
162
|
|
|
@@ -203,4 +190,4 @@ export class Listener {
|
|
|
203
190
|
}
|
|
204
191
|
}
|
|
205
192
|
|
|
206
|
-
EventEmitter(Listener.prototype);
|
|
193
|
+
EventEmitter(Listener.prototype);
|
package/src/listener.spec.js
CHANGED
|
@@ -6,11 +6,12 @@ jest.mock('@gameglue/schemas', () => ({
|
|
|
6
6
|
getGameSchema: jest.fn(() => ({
|
|
7
7
|
gameId: 'msfs',
|
|
8
8
|
fieldMappings: {}, // Empty means no transformation, fields pass through
|
|
9
|
-
commandMappings: {
|
|
9
|
+
commandMappings: {
|
|
10
|
+
gear_up: { gameCommand: 'GEAR_UP', type: 'command' }
|
|
11
|
+
},
|
|
10
12
|
extraFields: {}
|
|
11
13
|
})),
|
|
12
|
-
normalizeTelemetry: jest.fn((raw) => raw)
|
|
13
|
-
denormalizeCommand: jest.fn((cmd, schema, value) => ({ command: cmd, value }))
|
|
14
|
+
normalizeTelemetry: jest.fn((raw) => raw) // Identity function - return raw data as-is
|
|
14
15
|
}));
|
|
15
16
|
|
|
16
17
|
describe('Listener', () => {
|
|
@@ -420,6 +421,21 @@ describe('Listener', () => {
|
|
|
420
421
|
expect(result).toEqual({ status: 'success' });
|
|
421
422
|
});
|
|
422
423
|
|
|
424
|
+
it('should send canonical command without denormalizing', async () => {
|
|
425
|
+
await listener.sendCommand('gear_up', true);
|
|
426
|
+
|
|
427
|
+
expect(mockSocket.emit).toHaveBeenCalledWith(
|
|
428
|
+
'set',
|
|
429
|
+
expect.objectContaining({
|
|
430
|
+
data: {
|
|
431
|
+
fieldName: 'gear_up',
|
|
432
|
+
value: true
|
|
433
|
+
}
|
|
434
|
+
}),
|
|
435
|
+
expect.any(Function)
|
|
436
|
+
);
|
|
437
|
+
});
|
|
438
|
+
|
|
423
439
|
it('should handle various value types', async () => {
|
|
424
440
|
await listener.sendCommand('altitude', 35000);
|
|
425
441
|
await listener.sendCommand('flaps', 0.5);
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import EventEmitter from 'event-emitter';
|
|
2
|
+
import { getGameSchema, normalizeTelemetry } from '@gameglue/schemas';
|
|
3
|
+
|
|
4
|
+
export class PresenceListener {
|
|
5
|
+
constructor(socket, config) {
|
|
6
|
+
this._config = config;
|
|
7
|
+
this._socket = socket;
|
|
8
|
+
this._gameSchema = getGameSchema(config.gameId);
|
|
9
|
+
this._broadcasters = [];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
async establishConnection() {
|
|
13
|
+
if (!this._socket || !this._config.clientId || !this._config.gameId) {
|
|
14
|
+
throw new Error('Missing arguments in establishConnection');
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
return new Promise((resolve) => {
|
|
18
|
+
const payload = {
|
|
19
|
+
clientId: this._config.clientId,
|
|
20
|
+
gameId: this._config.gameId
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
this._socket.timeout(5000).emit('presenceSubscribe', payload, (error, response) => {
|
|
24
|
+
if (error) {
|
|
25
|
+
return resolve({ status: 'failed', reason: 'Presence request timed out.' });
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (response?.status === 'success') {
|
|
29
|
+
const broadcasters = Array.isArray(response.broadcasters)
|
|
30
|
+
? response.broadcasters.map((snapshot) => this._normalizeSnapshot(snapshot))
|
|
31
|
+
: [];
|
|
32
|
+
this._broadcasters = broadcasters;
|
|
33
|
+
return resolve({ status: 'success', broadcasters });
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return resolve({
|
|
37
|
+
status: 'failed',
|
|
38
|
+
reason: response?.reason || 'Presence subscription failed.'
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
setupEventListener() {
|
|
45
|
+
this._socket.on('presence-update', (snapshot) => {
|
|
46
|
+
const normalized = this._normalizeSnapshot(snapshot);
|
|
47
|
+
this._upsertBroadcaster(normalized);
|
|
48
|
+
this.emit('update', normalized);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
this._socket.on('presence-remove', ({ userId }) => {
|
|
52
|
+
if (!userId) return;
|
|
53
|
+
this._broadcasters = this._broadcasters.filter((b) => b.userId !== userId);
|
|
54
|
+
this.emit('remove', { userId });
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
this._socket.on('connect', () => {
|
|
58
|
+
this.emit('connect');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
this._socket.on('disconnect', (reason) => {
|
|
62
|
+
this.emit('disconnect', reason);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
return this;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
getBroadcasters() {
|
|
69
|
+
return [...this._broadcasters];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
disconnect() {
|
|
73
|
+
if (this._socket) {
|
|
74
|
+
this._socket.disconnect();
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
_normalizeSnapshot(snapshot) {
|
|
79
|
+
if (!snapshot || typeof snapshot !== 'object') {
|
|
80
|
+
return snapshot;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const rawData = snapshot.data;
|
|
84
|
+
const normalizedData = rawData && this._gameSchema
|
|
85
|
+
? normalizeTelemetry(rawData, this._gameSchema)
|
|
86
|
+
: rawData;
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
...snapshot,
|
|
90
|
+
data: normalizedData,
|
|
91
|
+
raw: rawData
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
_upsertBroadcaster(snapshot) {
|
|
96
|
+
if (!snapshot || !snapshot.userId) {
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const idx = this._broadcasters.findIndex((b) => b.userId === snapshot.userId);
|
|
101
|
+
if (idx >= 0) {
|
|
102
|
+
const next = [...this._broadcasters];
|
|
103
|
+
next[idx] = snapshot;
|
|
104
|
+
this._broadcasters = next;
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
this._broadcasters = [...this._broadcasters, snapshot];
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
EventEmitter(PresenceListener.prototype);
|
package/types/index.d.ts
CHANGED
|
@@ -21,6 +21,23 @@ declare module 'gameglue' {
|
|
|
21
21
|
fields?: string[];
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
export interface PresenceListenerConfig {
|
|
25
|
+
/** Client ID of the app (e.g., 'remote-pilot') */
|
|
26
|
+
clientId: string;
|
|
27
|
+
/** Game ID (e.g., 'msfs', 'xplane') */
|
|
28
|
+
gameId: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface PresenceSnapshot<T = Record<string, unknown>, R = Record<string, unknown>> {
|
|
32
|
+
userId: string;
|
|
33
|
+
username: string;
|
|
34
|
+
displayName: string;
|
|
35
|
+
gameId: string;
|
|
36
|
+
data: T;
|
|
37
|
+
raw?: R;
|
|
38
|
+
updatedAt: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
24
41
|
export interface CommandResult {
|
|
25
42
|
status: 'success' | 'failed';
|
|
26
43
|
reason?: string;
|
|
@@ -158,9 +175,32 @@ declare module 'gameglue' {
|
|
|
158
175
|
*/
|
|
159
176
|
on(event: 'flight_phase', callback: (evt: FlightPhaseEvent) => void): void;
|
|
160
177
|
|
|
178
|
+
/**
|
|
179
|
+
* Unsubscribe a callback from telemetry update events
|
|
180
|
+
*/
|
|
181
|
+
off<T = Record<string, unknown>, R = Record<string, unknown>>(
|
|
182
|
+
event: 'update',
|
|
183
|
+
callback: (evt: UpdateEvent<T, R>) => void
|
|
184
|
+
): void;
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Unsubscribe a callback from landing events
|
|
188
|
+
*/
|
|
189
|
+
off(event: 'landing', callback: (evt: LandingEvent) => void): void;
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Unsubscribe a callback from takeoff events
|
|
193
|
+
*/
|
|
194
|
+
off(event: 'takeoff', callback: (evt: TakeoffEvent) => void): void;
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Unsubscribe a callback from flight phase change events
|
|
198
|
+
*/
|
|
199
|
+
off(event: 'flight_phase', callback: (evt: FlightPhaseEvent) => void): void;
|
|
200
|
+
|
|
161
201
|
/**
|
|
162
202
|
* Send a command to the broadcaster (game client)
|
|
163
|
-
* Commands are
|
|
203
|
+
* Commands are sent as canonical names; the game client handles game-specific translation.
|
|
164
204
|
* @param command The canonical command name (e.g., 'gear_up', 'set_autopilot_heading')
|
|
165
205
|
* @param value The value to set (optional for toggle commands)
|
|
166
206
|
*/
|
|
@@ -185,6 +225,60 @@ declare module 'gameglue' {
|
|
|
185
225
|
getFields(): string[] | null;
|
|
186
226
|
}
|
|
187
227
|
|
|
228
|
+
export interface PresenceListener {
|
|
229
|
+
/**
|
|
230
|
+
* Subscribe to presence update events
|
|
231
|
+
* @param event Event name ('update')
|
|
232
|
+
* @param callback Callback function receiving presence snapshot
|
|
233
|
+
*/
|
|
234
|
+
on<T = Record<string, unknown>, R = Record<string, unknown>>(
|
|
235
|
+
event: 'update',
|
|
236
|
+
callback: (snapshot: PresenceSnapshot<T, R>) => void
|
|
237
|
+
): void;
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Subscribe to presence removal events
|
|
241
|
+
* @param event Event name ('remove')
|
|
242
|
+
* @param callback Callback function receiving removed userId
|
|
243
|
+
*/
|
|
244
|
+
on(event: 'remove', callback: (evt: { userId: string }) => void): void;
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Subscribe to connection events
|
|
248
|
+
*/
|
|
249
|
+
on(event: 'connect', callback: () => void): void;
|
|
250
|
+
on(event: 'disconnect', callback: (reason?: string) => void): void;
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Unsubscribe a callback from presence update events
|
|
254
|
+
*/
|
|
255
|
+
off<T = Record<string, unknown>, R = Record<string, unknown>>(
|
|
256
|
+
event: 'update',
|
|
257
|
+
callback: (snapshot: PresenceSnapshot<T, R>) => void
|
|
258
|
+
): void;
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Unsubscribe a callback from presence removal events
|
|
262
|
+
*/
|
|
263
|
+
off(event: 'remove', callback: (evt: { userId: string }) => void): void;
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Unsubscribe from connection events
|
|
267
|
+
*/
|
|
268
|
+
off(event: 'connect', callback: () => void): void;
|
|
269
|
+
off(event: 'disconnect', callback: (reason?: string) => void): void;
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Get current list of broadcasters
|
|
273
|
+
*/
|
|
274
|
+
getBroadcasters<T = Record<string, unknown>, R = Record<string, unknown>>(): PresenceSnapshot<T, R>[];
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Disconnect the presence socket
|
|
278
|
+
*/
|
|
279
|
+
disconnect(): void;
|
|
280
|
+
}
|
|
281
|
+
|
|
188
282
|
export interface LogoutOptions {
|
|
189
283
|
/** If false, only clears local tokens without redirecting to Keycloak logout */
|
|
190
284
|
redirect?: boolean;
|
|
@@ -220,7 +314,7 @@ declare module 'gameglue' {
|
|
|
220
314
|
/**
|
|
221
315
|
* Get the access token for API calls.
|
|
222
316
|
*/
|
|
223
|
-
getAccessToken(): string |
|
|
317
|
+
getAccessToken(): string | undefined;
|
|
224
318
|
|
|
225
319
|
/**
|
|
226
320
|
* Create a listener for game telemetry.
|
|
@@ -228,6 +322,12 @@ declare module 'gameglue' {
|
|
|
228
322
|
*/
|
|
229
323
|
createListener(config: ListenerConfig): Promise<Listener>;
|
|
230
324
|
|
|
325
|
+
/**
|
|
326
|
+
* Create a presence listener for public broadcaster updates.
|
|
327
|
+
* Does not require authentication.
|
|
328
|
+
*/
|
|
329
|
+
createPresenceListener(config: PresenceListenerConfig): Promise<PresenceListener>;
|
|
330
|
+
|
|
231
331
|
/**
|
|
232
332
|
* Register callback for token refresh events.
|
|
233
333
|
*/
|