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.
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gameglue",
3
- "version": "3.0.2",
3
+ "version": "4.0.0",
4
4
  "description": "Javascript SDK for the GameGlue developer platform.",
5
5
  "type": "module",
6
6
  "main": "dist/gg.cjs.js",
package/src/auth.js CHANGED
@@ -180,7 +180,7 @@ export class GameGlueAuth {
180
180
  if (token) {
181
181
  this._setTokenRefreshTimeout(token);
182
182
  }
183
- return token;
183
+ return token || undefined;
184
184
  }
185
185
 
186
186
  _setAccessToken(token) {
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, denormalizeCommand } from '@gameglue/schemas';
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: gameCommand,
172
- value: gameValue
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);
@@ -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), // Identity function - return raw data as-is
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 automatically denormalized from canonical names to game-specific commands.
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 | null;
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
  */