homegames-common 1.4.2 → 1.5.1

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.
@@ -0,0 +1,694 @@
1
+ /**
2
+ * GameSessionManager — Orchestrates game sessions.
3
+ *
4
+ * Takes a game (by versionId, raw code, or local path) and gives you a port
5
+ * where a WebSocket game server is running.
6
+ *
7
+ * Uses Docker containers when available, falls back to fork() when not.
8
+ *
9
+ * Consumers:
10
+ * - homegames-core HomegamesDashboard (startSession when player picks a game)
11
+ * - Studio preview (via Homenames API)
12
+ * - homedome (could use for test-running a game, though it mainly uses validateGame)
13
+ */
14
+
15
+ const { fork } = require('child_process');
16
+ const http = require('http');
17
+ const path = require('path');
18
+ const fs = require('fs');
19
+ const os = require('os');
20
+
21
+ const { isDockerAvailable, isImageBuilt, ensureImage, runGameContainer, stopContainer, isContainerRunning } = require('./docker-helper');
22
+ const { squishMap, DEFAULT_SQUISH_VERSION, fetchGameFromForgejo, loadGameClassFromPath, detectSquishVersion, parseSquishVersion } = require('./game-loader');
23
+
24
+ // ---------------------------------------------------------------------------
25
+ // Port pool
26
+ // ---------------------------------------------------------------------------
27
+ const DEFAULT_PORT_MIN = 7002;
28
+ const DEFAULT_PORT_MAX = 7099;
29
+
30
+ class PortPool {
31
+ constructor(min, max) {
32
+ this.ports = {};
33
+ for (let i = min; i <= max; i++) {
34
+ this.ports[i] = false;
35
+ }
36
+ }
37
+
38
+ acquire() {
39
+ for (const p in this.ports) {
40
+ if (!this.ports[p]) {
41
+ this.ports[p] = true;
42
+ return Number(p);
43
+ }
44
+ }
45
+ return null;
46
+ }
47
+
48
+ release(port) {
49
+ if (this.ports[port] !== undefined) {
50
+ this.ports[port] = false;
51
+ }
52
+ }
53
+ }
54
+
55
+ // ---------------------------------------------------------------------------
56
+ // Session tracking
57
+ // ---------------------------------------------------------------------------
58
+ let sessionIdCounter = 0;
59
+
60
+ class GameSessionManager {
61
+ /**
62
+ * @param {object} opts
63
+ * @param {number} [opts.portMin] — start of port range (default 7002)
64
+ * @param {number} [opts.portMax] — end of port range (default 7099)
65
+ * @param {string} [opts.dockerImageDir] — path to dir containing the Dockerfile for homegames-runner
66
+ * @param {string} [opts.childServerPath] — path to child_game_server.js (for fork fallback)
67
+ * @param {string} [opts.dockerImageName] — Docker image name (default 'homegames-runner')
68
+ * @param {number} [opts.gracePeriodMs] — ms to wait with no players before killing (default 30000)
69
+ * @param {number} [opts.lifecycleCheckMs] — ms between lifecycle checks (default 10000)
70
+ * @param {object} [opts.forgejo] — { url, token } for Forgejo access
71
+ * @param {string} [opts.saveDataRoot] — root directory for game save data
72
+ * @param {string} [opts.username] — homegames username
73
+ * @param {string} [opts.certPath] — path to TLS certs
74
+ * @param {function} [opts.log] — logging function ({ info, error })
75
+ * @param {number} [opts.bezelX] — bezel size X for init message (default 0)
76
+ * @param {number} [opts.bezelY] — bezel size Y for init message (default 0)
77
+ */
78
+ constructor(opts = {}) {
79
+ this.portPool = new PortPool(
80
+ opts.portMin || DEFAULT_PORT_MIN,
81
+ opts.portMax || DEFAULT_PORT_MAX
82
+ );
83
+ this.dockerImageDir = opts.dockerImageDir || null;
84
+ this.dockerImageName = opts.dockerImageName || 'homegames-runner';
85
+ this.childServerPath = opts.childServerPath || null;
86
+ this.gracePeriodMs = opts.gracePeriodMs || 30000;
87
+ this.lifecycleCheckMs = opts.lifecycleCheckMs || 10000;
88
+ this.forgejo = opts.forgejo || {};
89
+ this.saveDataRoot = opts.saveDataRoot || path.join(os.tmpdir(), 'hg-save-data');
90
+ this.assetCachePath = opts.assetCachePath || null;
91
+ this.maxSessions = opts.maxSessions || 50;
92
+ this.username = opts.username || null;
93
+ this.certPath = opts.certPath || null;
94
+ this.log = opts.log || { info: console.log, error: console.error };
95
+
96
+ this.sessions = {};
97
+ this._dockerChecked = false;
98
+ this._dockerOk = false;
99
+ }
100
+
101
+ /**
102
+ * Determine whether Docker is available and the image is ready.
103
+ * Caches the result after first call.
104
+ */
105
+ async _ensureDockerReady() {
106
+ if (this._dockerChecked) return this._dockerOk;
107
+ this._dockerChecked = true;
108
+
109
+ if (!(await isDockerAvailable())) {
110
+ this.log.info('Docker not available — sessions will use fork()');
111
+ this._dockerOk = false;
112
+ return false;
113
+ }
114
+
115
+ if (this.dockerImageDir) {
116
+ try {
117
+ await ensureImage(this.dockerImageDir, this.dockerImageName);
118
+ this._dockerOk = true;
119
+ this.log.info('Docker available and homegames-runner image ready');
120
+ } catch (err) {
121
+ this.log.error('Failed to build Docker image: ' + err.message);
122
+ this._dockerOk = false;
123
+ }
124
+ } else {
125
+ // No Dockerfile dir specified — check if image already exists
126
+ this._dockerOk = await isImageBuilt(this.dockerImageName);
127
+ if (this._dockerOk) {
128
+ this.log.info('Docker available, existing homegames-runner image found');
129
+ } else {
130
+ this.log.info('Docker available but no homegames-runner image. Specify dockerImageDir to build it. Using fork().');
131
+ }
132
+ }
133
+
134
+ return this._dockerOk;
135
+ }
136
+
137
+ /**
138
+ * Start a game session.
139
+ *
140
+ * @param {object} input — one of:
141
+ * { versionId } — fetch code from Forgejo and run it
142
+ * { code } — run raw code (Studio preview)
143
+ * { gamePath } — run from local file path
144
+ * @param {object} [opts]
145
+ * { onReady } — callback when session is ready and listening
146
+ * { env } — extra env vars for child process / container
147
+ * { movePlayer } — function({ playerId, port }) for Dashboard integration
148
+ * { playerId } — initial player to notify on ready
149
+ *
150
+ * @returns {Promise<{ sessionId, port, type: 'docker'|'fork' }>}
151
+ */
152
+ async startSession(input, opts = {}) {
153
+ // Cap concurrent sessions
154
+ const maxSessions = this.maxSessions || 50;
155
+ if (Object.keys(this.sessions).length >= maxSessions) {
156
+ throw new Error('Maximum concurrent sessions reached');
157
+ }
158
+
159
+ const port = this.portPool.acquire();
160
+ if (!port) {
161
+ throw new Error('No available ports for new game session');
162
+ }
163
+
164
+ const sessionId = ++sessionIdCounter;
165
+ const useDocker = await this._ensureDockerReady();
166
+
167
+ try {
168
+ if (useDocker) {
169
+ return await this._startDockerSession(sessionId, port, input, opts);
170
+ } else {
171
+ return await this._startForkSession(sessionId, port, input, opts);
172
+ }
173
+ } catch (err) {
174
+ this.portPool.release(port);
175
+ throw err;
176
+ }
177
+ }
178
+
179
+ // -----------------------------------------------------------------------
180
+ // Docker path
181
+ // -----------------------------------------------------------------------
182
+ async _startDockerSession(sessionId, port, input, opts) {
183
+ let codePath;
184
+ let squishVersion;
185
+ let cleanupFn = null;
186
+ let gameEntryRelative = null;
187
+
188
+ if (input.code) {
189
+ // Raw code from Studio — write to temp dir
190
+ codePath = path.join(os.tmpdir(), `hg-session-${sessionId}`);
191
+ fs.mkdirSync(codePath, { recursive: true });
192
+ fs.writeFileSync(path.join(codePath, 'index.js'), input.code);
193
+ squishVersion = detectSquishVersion(input.code);
194
+ cleanupFn = () => {
195
+ try { fs.rmSync(codePath, { recursive: true, force: true }); } catch (e) {}
196
+ };
197
+ } else if (input.gamePath) {
198
+ // Local file path — mount a broad enough ancestor so that
199
+ // relative requires (e.g. ../../common/util) resolve correctly.
200
+ // We walk up from the game file to find the project root
201
+ // (directory containing node_modules or package.json).
202
+ const resolved = path.resolve(input.gamePath);
203
+ codePath = this._findProjectRoot(resolved) || path.dirname(resolved);
204
+ // Tell the container where the entry point is relative to the mount
205
+ gameEntryRelative = path.relative(codePath, resolved);
206
+ // Parse squish version from source via AST (no require) — safe for
207
+ // temp files / paths where squish packages aren't installed locally.
208
+ try {
209
+ squishVersion = parseSquishVersion(resolved);
210
+ } catch (err) {
211
+ squishVersion = DEFAULT_SQUISH_VERSION;
212
+ }
213
+ } else if (input.versionId) {
214
+ // Fetch from Forgejo
215
+ const result = await fetchGameFromForgejo({
216
+ forgejoUrl: this.forgejo.url,
217
+ forgejoToken: this.forgejo.token,
218
+ owner: input.owner,
219
+ repo: input.repo,
220
+ ref: input.ref,
221
+ });
222
+ codePath = path.dirname(result.entryPath);
223
+ squishVersion = result.squishVersion;
224
+ cleanupFn = result.cleanup;
225
+ } else {
226
+ throw new Error('startSession requires one of: code, gamePath, or versionId');
227
+ }
228
+
229
+ // Save data directory for this game
230
+ const saveDataPath = path.join(this.saveDataRoot, `session-${sessionId}`);
231
+
232
+ // Pass host config to the container so it can reach the local API,
233
+ // API_URL is used by Asset.js for downloading game assets.
234
+ // squish's Asset.js hardcodes https for downloads, so API_URL must
235
+ // use https://. Use the public API for asset downloads; Homenames
236
+ // access uses DOCKER_HOST_HOSTNAME separately.
237
+ const extraEnv = {};
238
+ extraEnv.API_URL = 'https://api.homegames.io';
239
+ // Homenames runs on the host — container reaches it via DOCKER_HOST_HOSTNAME
240
+ // which is set in docker-helper.js. HTTPS_ENABLED controls whether
241
+ // HomenamesHelper uses https — must be false for local dev.
242
+ extraEnv.HTTPS_ENABLED = 'false';
243
+
244
+ const { containerId } = await runGameContainer({
245
+ codePath,
246
+ port,
247
+ squishVersion,
248
+ saveDataPath,
249
+ assetCachePath: this.assetCachePath,
250
+ imageName: this.dockerImageName,
251
+ gameEntryRelative,
252
+ noFrame: input.noFrame || false,
253
+ extraEnv,
254
+ });
255
+
256
+ const session = {
257
+ id: sessionId,
258
+ port,
259
+ type: 'docker',
260
+ containerId,
261
+ squishVersion,
262
+ cleanup: cleanupFn,
263
+ _emptyTicks: 0,
264
+ };
265
+
266
+ this.sessions[sessionId] = session;
267
+ this._startLifecycleMonitor(sessionId);
268
+
269
+ this.log.info(`Session ${sessionId} started via Docker on port ${port} (container ${containerId.slice(0, 12)})`);
270
+
271
+ if (opts.onReady) {
272
+ this.log.info(`Session ${sessionId} waiting for port ${port} to become reachable...`);
273
+ this._waitForPort(port, 15000).then(() => {
274
+ this.log.info(`Session ${sessionId} port ${port} is reachable, calling onReady`);
275
+ // Additional delay for Docker Desktop on macOS — the port mapping
276
+ // through the Linux VM can briefly refuse connections after the
277
+ // TCP check passes. The WebSocket upgrade needs the full stack ready.
278
+ return this._waitForWebSocket(port, 10000);
279
+ }).then(() => {
280
+ this.log.info(`Session ${sessionId} WebSocket confirmed, calling onReady`);
281
+ opts.onReady(session);
282
+ }).catch((err) => {
283
+ this.log.error(`Session ${sessionId} container never became ready: ${err.message}`);
284
+ });
285
+ }
286
+
287
+ return { sessionId, port, type: 'docker' };
288
+ }
289
+
290
+ // -----------------------------------------------------------------------
291
+ // Fork path (fallback when Docker is not available)
292
+ // -----------------------------------------------------------------------
293
+ async _startForkSession(sessionId, port, input, opts) {
294
+ if (!this.childServerPath) {
295
+ throw new Error('No childServerPath configured and Docker is not available');
296
+ }
297
+
298
+ let gamePath;
299
+ let squishVersion;
300
+ let cleanupFn = null;
301
+
302
+ if (input.code) {
303
+ const tmpDir = path.join(os.tmpdir(), `hg-session-${sessionId}`);
304
+ fs.mkdirSync(tmpDir, { recursive: true });
305
+ gamePath = path.join(tmpDir, 'index.js');
306
+ fs.writeFileSync(gamePath, input.code);
307
+ squishVersion = detectSquishVersion(input.code);
308
+ cleanupFn = () => {
309
+ try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch (e) {}
310
+ };
311
+ } else if (input.gamePath) {
312
+ gamePath = path.resolve(input.gamePath);
313
+ try {
314
+ squishVersion = parseSquishVersion(gamePath);
315
+ } catch (err) {
316
+ console.log(err);
317
+ squishVersion = DEFAULT_SQUISH_VERSION;
318
+ }
319
+ } else if (input.versionId) {
320
+ const result = await fetchGameFromForgejo({
321
+ forgejoUrl: this.forgejo.url,
322
+ forgejoToken: this.forgejo.token,
323
+ owner: input.owner,
324
+ repo: input.repo,
325
+ ref: input.ref,
326
+ });
327
+ gamePath = result.entryPath;
328
+ squishVersion = result.squishVersion;
329
+ cleanupFn = result.cleanup;
330
+ } else {
331
+ throw new Error('startSession requires one of: code, gamePath, or versionId');
332
+ }
333
+
334
+ const squishPkg = squishMap[squishVersion] || squishMap[DEFAULT_SQUISH_VERSION];
335
+
336
+ return new Promise((resolve, reject) => {
337
+ // Allowlist environment variables for child processes.
338
+ // Only pass what the child needs — avoid leaking secrets
339
+ // (e.g. FORGEJO_ADMIN_TOKEN, auth tokens, etc.)
340
+ const ALLOWED_ENV_KEYS = [
341
+ 'PATH', 'HOME', 'USER', 'LANG', 'TERM',
342
+ 'NODE_ENV', 'NODE_PATH',
343
+ 'SQUISH_PATH', 'API_URL', 'LOGGER_LOCATION',
344
+ 'HTTPS_ENABLED', 'HOMENAMES_PORT', 'BEZEL_SIZE_X', 'BEZEL_SIZE_Y',
345
+ 'DOCKER_HOST_HOSTNAME', 'GRACE_PERIOD_MS',
346
+ ];
347
+ const env = {};
348
+ for (const key of ALLOWED_ENV_KEYS) {
349
+ if (process.env[key] !== undefined) {
350
+ env[key] = process.env[key];
351
+ }
352
+ }
353
+ // Apply required overrides (these take precedence)
354
+ env.NODE_PATH = `${process.cwd()}${path.sep}node_modules`;
355
+ env.SQUISH_PATH = squishPkg;
356
+ // opts.env is filtered through the same allowlist — no arbitrary injection
357
+ if (opts.env) {
358
+ for (const key of ALLOWED_ENV_KEYS) {
359
+ if (opts.env[key] !== undefined) {
360
+ env[key] = opts.env[key];
361
+ }
362
+ }
363
+ }
364
+
365
+ const child = fork(this.childServerPath, [], {
366
+ env,
367
+ stdio: ['pipe', 'pipe', 'pipe', 'ipc'],
368
+ execArgv: ['--max-old-space-size=64'],
369
+ });
370
+
371
+ child.send(JSON.stringify({
372
+ key: input.gameKey || path.basename(gamePath, '.js'),
373
+ squishVersion,
374
+ gamePath,
375
+ port,
376
+ player: opts.playerId ? { id: opts.playerId } : undefined,
377
+ username: this.username,
378
+ certPath: this.certPath,
379
+ noFrame: input.noFrame || false,
380
+ }));
381
+
382
+ const session = {
383
+ id: sessionId,
384
+ port,
385
+ type: 'fork',
386
+ child,
387
+ squishVersion,
388
+ cleanup: cleanupFn,
389
+ gameKey: input.gameKey || null,
390
+ gamePath,
391
+ _emptyTicks: 0,
392
+ requestCallbacks: {},
393
+ _requestIdCounter: 0,
394
+ };
395
+
396
+ child.on('message', (thang) => {
397
+ const msg = JSON.parse(thang);
398
+ if (msg.success) {
399
+ this.sessions[sessionId] = session;
400
+ this._startLifecycleMonitor(sessionId);
401
+ this.log.info(`Session ${sessionId} started via fork() on port ${port}`);
402
+ resolve({ sessionId, port, type: 'fork' });
403
+
404
+ if (opts.onReady) opts.onReady(session);
405
+ } else if (msg.requestId && session.requestCallbacks[msg.requestId]) {
406
+ session.requestCallbacks[msg.requestId](msg.payload);
407
+ delete session.requestCallbacks[msg.requestId];
408
+ }
409
+ });
410
+
411
+ child.on('error', (err) => {
412
+ this.log.error(`Session ${sessionId} fork error: ${err.message}`);
413
+ if (this.sessions[sessionId]) {
414
+ this._cleanupSession(sessionId);
415
+ } else {
416
+ // Session never registered — release port directly
417
+ this.portPool.release(port);
418
+ if (cleanupFn) cleanupFn();
419
+ }
420
+ reject(err);
421
+ });
422
+
423
+ child.on('close', () => {
424
+ this.log.info(`Session ${sessionId} fork closed`);
425
+ if (this.sessions[sessionId]) {
426
+ this._cleanupSession(sessionId);
427
+ } else {
428
+ // Session never registered — release port directly
429
+ this.portPool.release(port);
430
+ if (cleanupFn) cleanupFn();
431
+ }
432
+ });
433
+ });
434
+ }
435
+
436
+ // -----------------------------------------------------------------------
437
+ // Find the project root (nearest ancestor with node_modules or package.json)
438
+ // so we mount enough of the tree for relative requires to work.
439
+ // -----------------------------------------------------------------------
440
+ _findProjectRoot(filePath) {
441
+ let dir = path.dirname(filePath);
442
+ const root = path.parse(dir).root;
443
+
444
+ while (dir !== root) {
445
+ if (fs.existsSync(path.join(dir, 'node_modules')) || fs.existsSync(path.join(dir, 'package.json'))) {
446
+ return dir;
447
+ }
448
+ dir = path.dirname(dir);
449
+ }
450
+ return null;
451
+ }
452
+
453
+ // -----------------------------------------------------------------------
454
+ // Wait for a port to become reachable (container startup)
455
+ // -----------------------------------------------------------------------
456
+ _waitForPort(port, timeoutMs = 15000) {
457
+ const net = require('net');
458
+ const start = Date.now();
459
+ const interval = 500;
460
+
461
+ return new Promise((resolve, reject) => {
462
+ const check = () => {
463
+ if (Date.now() - start > timeoutMs) {
464
+ return reject(new Error(`Port ${port} not reachable after ${timeoutMs}ms`));
465
+ }
466
+
467
+ const socket = new net.Socket();
468
+ socket.setTimeout(1000);
469
+ socket.once('connect', () => {
470
+ socket.destroy();
471
+ resolve();
472
+ });
473
+ socket.once('error', () => {
474
+ socket.destroy();
475
+ setTimeout(check, interval);
476
+ });
477
+ socket.once('timeout', () => {
478
+ socket.destroy();
479
+ setTimeout(check, interval);
480
+ });
481
+ socket.connect(port, '127.0.0.1');
482
+ };
483
+ check();
484
+ });
485
+ }
486
+
487
+ _waitForWebSocket(port, timeoutMs = 10000) {
488
+ const WebSocket = require('ws');
489
+ const start = Date.now();
490
+ const interval = 500;
491
+
492
+ return new Promise((resolve, reject) => {
493
+ const attempt = () => {
494
+ if (Date.now() - start > timeoutMs) {
495
+ return reject(new Error(`WebSocket on port ${port} not ready after ${timeoutMs}ms`));
496
+ }
497
+
498
+ const ws = new WebSocket(`ws://127.0.0.1:${port}`);
499
+ ws.once('open', () => {
500
+ ws.close();
501
+ resolve();
502
+ });
503
+ ws.once('error', () => {
504
+ ws.close();
505
+ setTimeout(attempt, interval);
506
+ });
507
+ };
508
+ attempt();
509
+ });
510
+ }
511
+
512
+ // -----------------------------------------------------------------------
513
+ // Lifecycle monitor — kill sessions with no players after grace period
514
+ // -----------------------------------------------------------------------
515
+ _startLifecycleMonitor(sessionId) {
516
+ const session = this.sessions[sessionId];
517
+ if (!session) return;
518
+
519
+ session._lifecycleInterval = setInterval(async () => {
520
+ const s = this.sessions[sessionId];
521
+ if (!s) {
522
+ clearInterval(session._lifecycleInterval);
523
+ return;
524
+ }
525
+
526
+ if (s.type === 'docker') {
527
+ const running = await isContainerRunning(s.containerId);
528
+ this.log.info(`Session ${sessionId} lifecycle check: container running = ${running}`);
529
+ if (!running) {
530
+ this.log.info(`Session ${sessionId} container exited`);
531
+ this._cleanupSession(sessionId);
532
+ }
533
+ } else if (s.type === 'fork') {
534
+ // For fork sessions, send heartbeat. The child_game_server.js
535
+ // already has its own checkPulse logic.
536
+ try {
537
+ s.child.send(JSON.stringify({ type: 'heartbeat' }));
538
+ } catch (err) {
539
+ // Child already dead
540
+ this._cleanupSession(sessionId);
541
+ }
542
+ }
543
+ }, this.lifecycleCheckMs);
544
+ }
545
+
546
+ // -----------------------------------------------------------------------
547
+ // Stop a session
548
+ // -----------------------------------------------------------------------
549
+ async stopSession(sessionId) {
550
+ const session = this.sessions[sessionId];
551
+ if (!session) return;
552
+
553
+ if (session.type === 'docker') {
554
+ await stopContainer(session.containerId);
555
+ } else if (session.type === 'fork') {
556
+ try { session.child.kill(); } catch (e) {}
557
+ }
558
+
559
+ this._cleanupSession(sessionId);
560
+ }
561
+
562
+ _cleanupSession(sessionId) {
563
+ const session = this.sessions[sessionId];
564
+ if (!session) return;
565
+
566
+ if (session._lifecycleInterval) {
567
+ clearInterval(session._lifecycleInterval);
568
+ }
569
+
570
+ // Resolve any pending request callbacks so callers don't hang
571
+ if (session.requestCallbacks) {
572
+ for (const reqId in session.requestCallbacks) {
573
+ try { session.requestCallbacks[reqId](null); } catch (e) {}
574
+ }
575
+ session.requestCallbacks = {};
576
+ }
577
+
578
+ this.portPool.release(session.port);
579
+
580
+ if (session.cleanup) {
581
+ session.cleanup();
582
+ }
583
+
584
+ delete this.sessions[sessionId];
585
+ }
586
+
587
+ // -----------------------------------------------------------------------
588
+ // Query helpers
589
+ // -----------------------------------------------------------------------
590
+
591
+ getSession(sessionId) {
592
+ return this.sessions[sessionId] || null;
593
+ }
594
+
595
+ findSessionByPort(port) {
596
+ for (const id in this.sessions) {
597
+ if (this.sessions[id].port === port) return this.sessions[id];
598
+ }
599
+ return null;
600
+ }
601
+
602
+ findSessionsByGame(gameKey) {
603
+ return Object.values(this.sessions).filter(s => s.gameKey === gameKey);
604
+ }
605
+
606
+ listSessions() {
607
+ return Object.values(this.sessions).map(s => ({
608
+ id: s.id,
609
+ port: s.port,
610
+ type: s.type,
611
+ squishVersion: s.squishVersion,
612
+ gameKey: s.gameKey || null,
613
+ gameId: s.gameId || null,
614
+ }));
615
+ }
616
+
617
+ /**
618
+ * Send a message to a forked child session (no-op for Docker sessions).
619
+ */
620
+ sendToSession(sessionId, msg) {
621
+ const session = this.sessions[sessionId];
622
+ if (session && session.type === 'fork' && session.child) {
623
+ session.child.send(JSON.stringify(msg));
624
+ }
625
+ }
626
+
627
+ /**
628
+ * Request data from a forked child session (e.g., getPlayers).
629
+ */
630
+ requestFromSession(sessionId, apiName) {
631
+ return new Promise((resolve, reject) => {
632
+ const session = this.sessions[sessionId];
633
+ if (!session) {
634
+ resolve(null);
635
+ return;
636
+ }
637
+
638
+ if (session.type === 'docker') {
639
+ // Docker sessions: use HTTP API on the session's port
640
+ const apiPath = apiName === 'getPlayers' ? '/api/players' : `/api/${apiName}`;
641
+ const req = http.get(`http://localhost:${session.port}${apiPath}`, (res) => {
642
+ let buf = '';
643
+ res.on('data', (chunk) => { buf += chunk; });
644
+ res.on('end', () => {
645
+ try { resolve(JSON.parse(buf)); } catch (e) { resolve(null); }
646
+ });
647
+ });
648
+ req.on('error', () => resolve(null));
649
+ req.setTimeout(5000, () => { req.destroy(); resolve(null); });
650
+ return;
651
+ }
652
+
653
+ if (session.type !== 'fork') {
654
+ resolve(null);
655
+ return;
656
+ }
657
+
658
+ const requestId = ++session._requestIdCounter;
659
+ session.requestCallbacks[requestId] = resolve;
660
+ session.child.send(JSON.stringify({ api: apiName, requestId }));
661
+
662
+ // Timeout after 5 seconds
663
+ setTimeout(() => {
664
+ if (session.requestCallbacks[requestId]) {
665
+ delete session.requestCallbacks[requestId];
666
+ resolve(null);
667
+ }
668
+ }, 5000);
669
+ });
670
+ }
671
+
672
+ /**
673
+ * Get a log stream for a session.
674
+ * For Docker sessions: streams container logs.
675
+ * For fork sessions: returns the child's stdout/stderr (if captured).
676
+ * Returns { type: 'docker'|'fork', stream?, child? }
677
+ */
678
+ async getSessionLogStream(sessionId) {
679
+ const session = this.sessions[sessionId];
680
+ if (!session) return null;
681
+
682
+ if (session.type === 'docker') {
683
+ const { streamContainerLogs } = require('./docker-helper');
684
+ const { logStream, demuxDockerLogs } = await streamContainerLogs(session.containerId);
685
+ return { type: 'docker', logStream, demuxDockerLogs };
686
+ } else if (session.type === 'fork') {
687
+ return { type: 'fork', child: session.child };
688
+ }
689
+
690
+ return null;
691
+ }
692
+ }
693
+
694
+ module.exports = GameSessionManager;