homegames-common 1.4.0 → 1.5.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/docker-helper.js +396 -0
- package/game-loader.js +214 -0
- package/game-session-manager.js +693 -0
- package/game-session.js +600 -0
- package/index.js +97 -625
- package/package.json +5 -1
package/docker-helper.js
ADDED
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
const Docker = require('dockerode');
|
|
2
|
+
const tar = require('tar');
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// Shared Docker client — auto-detects the platform-appropriate transport:
|
|
8
|
+
// Linux/macOS: /var/run/docker.sock
|
|
9
|
+
// Windows: //./pipe/docker_engine
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
const docker = new Docker();
|
|
12
|
+
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
// Cache Docker availability for the process lifetime.
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
let _dockerAvailable = null;
|
|
17
|
+
|
|
18
|
+
const isDockerAvailable = async () => {
|
|
19
|
+
if (_dockerAvailable !== null) return _dockerAvailable;
|
|
20
|
+
|
|
21
|
+
try {
|
|
22
|
+
await docker.ping();
|
|
23
|
+
_dockerAvailable = true;
|
|
24
|
+
} catch (err) {
|
|
25
|
+
_dockerAvailable = false;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return _dockerAvailable;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
// Check whether the homegames-runner image exists locally.
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
const isImageBuilt = async (imageName = 'homegames-runner') => {
|
|
35
|
+
try {
|
|
36
|
+
const images = await docker.listImages({
|
|
37
|
+
filters: { reference: [imageName] },
|
|
38
|
+
});
|
|
39
|
+
return images.length > 0;
|
|
40
|
+
} catch (err) {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
// Build the homegames-runner image from a Dockerfile directory.
|
|
47
|
+
//
|
|
48
|
+
// Uses the Docker Engine API directly via a tar stream of the build context.
|
|
49
|
+
// No bash/shell dependency — works on Windows, macOS, and Linux.
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
const buildImage = async (dockerfilePath, imageName = 'homegames-runner') => {
|
|
52
|
+
const dir = path.resolve(dockerfilePath);
|
|
53
|
+
|
|
54
|
+
const stream = await docker.buildImage(tar.c({ cwd: dir }, fs.readdirSync(dir)), { t: imageName });
|
|
55
|
+
|
|
56
|
+
return new Promise((resolve, reject) => {
|
|
57
|
+
docker.modem.followProgress(stream, (err, output) => {
|
|
58
|
+
if (err) {
|
|
59
|
+
reject(new Error(`Docker build failed: ${err.message}`));
|
|
60
|
+
} else {
|
|
61
|
+
// Check the last message for an error object (build failures
|
|
62
|
+
// sometimes surface here rather than in the callback error).
|
|
63
|
+
const last = output && output[output.length - 1];
|
|
64
|
+
if (last && last.error) {
|
|
65
|
+
reject(new Error(`Docker build failed: ${last.error}`));
|
|
66
|
+
} else {
|
|
67
|
+
resolve();
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
// Ensure the image is built. Build it if missing.
|
|
76
|
+
// ---------------------------------------------------------------------------
|
|
77
|
+
const ensureImage = async (dockerfilePath, imageName = 'homegames-runner') => {
|
|
78
|
+
if (await isImageBuilt(imageName)) return;
|
|
79
|
+
return buildImage(dockerfilePath, imageName);
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
// Run a game inside a Docker container for live game sessions.
|
|
84
|
+
//
|
|
85
|
+
// Options:
|
|
86
|
+
// codePath — absolute path to a directory containing the game code
|
|
87
|
+
// port — host port to map (also used as container port)
|
|
88
|
+
// squishVersion — squish version string
|
|
89
|
+
// saveDataPath — absolute path to host directory for game save data (optional)
|
|
90
|
+
// imageName — Docker image name (default: 'homegames-runner')
|
|
91
|
+
// memoryLimit — memory limit in bytes or string (default: '256m')
|
|
92
|
+
// cpuLimit — CPU limit as a string (default: '1')
|
|
93
|
+
// gameEntryRelative — relative path to the entry file inside the mounted code dir
|
|
94
|
+
//
|
|
95
|
+
// Returns: { containerId, port }
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
const runGameContainer = async ({
|
|
98
|
+
codePath,
|
|
99
|
+
port,
|
|
100
|
+
squishVersion,
|
|
101
|
+
saveDataPath,
|
|
102
|
+
assetCachePath = null,
|
|
103
|
+
imageName = 'homegames-runner',
|
|
104
|
+
memoryLimit = '256m',
|
|
105
|
+
cpuLimit = '1',
|
|
106
|
+
gameEntryRelative = null,
|
|
107
|
+
noFrame = false,
|
|
108
|
+
extraEnv = {},
|
|
109
|
+
}) => {
|
|
110
|
+
const env = [
|
|
111
|
+
`GAME_PORT=${port}`,
|
|
112
|
+
`SQUISH_VERSION=${squishVersion}`,
|
|
113
|
+
`NO_FRAME=${noFrame ? '1' : ''}`,
|
|
114
|
+
];
|
|
115
|
+
|
|
116
|
+
// Pass through extra environment variables from the host
|
|
117
|
+
for (const key in extraEnv) {
|
|
118
|
+
if (extraEnv[key] !== undefined && extraEnv[key] !== null) {
|
|
119
|
+
env.push(`${key}=${extraEnv[key]}`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (gameEntryRelative) {
|
|
124
|
+
env.push(`GAME_ENTRY=${gameEntryRelative}`);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// On macOS and Windows (Docker Desktop) host.docker.internal is provided
|
|
128
|
+
// automatically. On Linux we need to add it explicitly.
|
|
129
|
+
const extraHosts = [];
|
|
130
|
+
if (process.platform === 'linux') {
|
|
131
|
+
extraHosts.push('host.docker.internal:host-gateway');
|
|
132
|
+
}
|
|
133
|
+
env.push('DOCKER_HOST_HOSTNAME=host.docker.internal');
|
|
134
|
+
|
|
135
|
+
const binds = [
|
|
136
|
+
`${path.resolve(codePath)}:/app/game:ro`,
|
|
137
|
+
];
|
|
138
|
+
|
|
139
|
+
if (saveDataPath) {
|
|
140
|
+
const resolved = path.resolve(saveDataPath);
|
|
141
|
+
if (!fs.existsSync(resolved)) {
|
|
142
|
+
fs.mkdirSync(resolved, { recursive: true });
|
|
143
|
+
}
|
|
144
|
+
binds.push(`${resolved}:/app/save:rw`);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Mount the host's asset cache so containers share downloaded assets
|
|
148
|
+
if (assetCachePath) {
|
|
149
|
+
const resolved = path.resolve(assetCachePath);
|
|
150
|
+
if (!fs.existsSync(resolved)) {
|
|
151
|
+
fs.mkdirSync(resolved, { recursive: true });
|
|
152
|
+
}
|
|
153
|
+
binds.push(`${resolved}:/root/.homegames/asset-cache:rw`);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Parse memory limit to bytes if it's a human string (e.g. '256m')
|
|
157
|
+
const memoryBytes = parseMemoryString(memoryLimit);
|
|
158
|
+
|
|
159
|
+
const container = await docker.createContainer({
|
|
160
|
+
Image: imageName,
|
|
161
|
+
Cmd: ['container-entry.js'],
|
|
162
|
+
Env: env,
|
|
163
|
+
Labels: {
|
|
164
|
+
'homegames-session': 'true',
|
|
165
|
+
'homegames-port': String(port),
|
|
166
|
+
},
|
|
167
|
+
ExposedPorts: {
|
|
168
|
+
[`${port}/tcp`]: {},
|
|
169
|
+
},
|
|
170
|
+
HostConfig: {
|
|
171
|
+
AutoRemove: true,
|
|
172
|
+
Binds: binds,
|
|
173
|
+
PortBindings: {
|
|
174
|
+
[`${port}/tcp`]: [{ HostPort: String(port) }],
|
|
175
|
+
},
|
|
176
|
+
Memory: memoryBytes,
|
|
177
|
+
NanoCpus: parseCpuLimit(cpuLimit),
|
|
178
|
+
PidsLimit: 64,
|
|
179
|
+
CapDrop: ['ALL'],
|
|
180
|
+
Tmpfs: {
|
|
181
|
+
'/tmp': 'rw,size=64m',
|
|
182
|
+
},
|
|
183
|
+
ExtraHosts: extraHosts,
|
|
184
|
+
},
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
await container.start();
|
|
188
|
+
|
|
189
|
+
return { containerId: container.id, port };
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
// ---------------------------------------------------------------------------
|
|
193
|
+
// Run game validation in a Docker container (for homedome).
|
|
194
|
+
// No networking, no port mapping, no save directory.
|
|
195
|
+
//
|
|
196
|
+
// Returns: { success: boolean, squishVersion?: string, error?: string }
|
|
197
|
+
// ---------------------------------------------------------------------------
|
|
198
|
+
const validateGame = async ({
|
|
199
|
+
codePath,
|
|
200
|
+
squishVersion,
|
|
201
|
+
imageName = 'homegames-runner',
|
|
202
|
+
timeoutMs = 30000,
|
|
203
|
+
memoryLimit = '256m',
|
|
204
|
+
}) => {
|
|
205
|
+
const memoryBytes = parseMemoryString(memoryLimit);
|
|
206
|
+
|
|
207
|
+
let container;
|
|
208
|
+
try {
|
|
209
|
+
container = await docker.createContainer({
|
|
210
|
+
Image: imageName,
|
|
211
|
+
Cmd: ['validate.js'],
|
|
212
|
+
Env: [
|
|
213
|
+
`SQUISH_VERSION=${squishVersion}`,
|
|
214
|
+
],
|
|
215
|
+
HostConfig: {
|
|
216
|
+
Binds: [
|
|
217
|
+
`${path.resolve(codePath)}:/app/game:ro`,
|
|
218
|
+
],
|
|
219
|
+
Memory: memoryBytes,
|
|
220
|
+
NanoCpus: 0.5e9,
|
|
221
|
+
PidsLimit: 32,
|
|
222
|
+
CapDrop: ['ALL'],
|
|
223
|
+
ReadonlyRootfs: true,
|
|
224
|
+
Tmpfs: { '/tmp': 'rw,noexec,size=32m' },
|
|
225
|
+
NetworkMode: 'none',
|
|
226
|
+
},
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
await container.start();
|
|
230
|
+
|
|
231
|
+
// Wait for the container to exit, with a timeout.
|
|
232
|
+
const waitPromise = container.wait();
|
|
233
|
+
const timeoutPromise = new Promise((_, reject) =>
|
|
234
|
+
setTimeout(() => reject(new Error('Validation timed out')), timeoutMs)
|
|
235
|
+
);
|
|
236
|
+
|
|
237
|
+
let exitResult;
|
|
238
|
+
try {
|
|
239
|
+
exitResult = await Promise.race([waitPromise, timeoutPromise]);
|
|
240
|
+
} catch (err) {
|
|
241
|
+
// Timed out — kill and remove
|
|
242
|
+
try { await container.stop({ t: 0 }); } catch (_) {}
|
|
243
|
+
try { await container.remove({ force: true }); } catch (_) {}
|
|
244
|
+
return { success: false, error: err.message || 'Container timeout' };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Collect stdout logs (validate.js writes JSON to stdout).
|
|
248
|
+
const logs = await container.logs({ stdout: true, stderr: true, follow: false });
|
|
249
|
+
const output = demuxDockerLogs(logs);
|
|
250
|
+
|
|
251
|
+
// Remove the container (equivalent of --rm).
|
|
252
|
+
try { await container.remove(); } catch (_) {}
|
|
253
|
+
|
|
254
|
+
if (!output.stdout) {
|
|
255
|
+
return {
|
|
256
|
+
success: false,
|
|
257
|
+
error: output.stderr ? output.stderr.trim().slice(-500) : 'No output from validation',
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
try {
|
|
262
|
+
return JSON.parse(output.stdout.trim());
|
|
263
|
+
} catch (parseErr) {
|
|
264
|
+
return {
|
|
265
|
+
success: false,
|
|
266
|
+
error: `Failed to parse validation output: ${output.stdout.slice(0, 200)}`,
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
} catch (err) {
|
|
270
|
+
// If container was created but we error out, try to clean up.
|
|
271
|
+
if (container) {
|
|
272
|
+
try { await container.stop({ t: 0 }); } catch (_) {}
|
|
273
|
+
try { await container.remove({ force: true }); } catch (_) {}
|
|
274
|
+
}
|
|
275
|
+
return {
|
|
276
|
+
success: false,
|
|
277
|
+
error: err.message || 'Container error',
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
// ---------------------------------------------------------------------------
|
|
283
|
+
// Stop and remove a running container by ID.
|
|
284
|
+
// ---------------------------------------------------------------------------
|
|
285
|
+
const stopContainer = async (containerId) => {
|
|
286
|
+
const container = docker.getContainer(containerId);
|
|
287
|
+
try { await container.stop({ t: 5 }); } catch (_) {}
|
|
288
|
+
try { await container.remove({ force: true }); } catch (_) {}
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
// ---------------------------------------------------------------------------
|
|
292
|
+
// Check if a container is still running.
|
|
293
|
+
// ---------------------------------------------------------------------------
|
|
294
|
+
const isContainerRunning = async (containerId) => {
|
|
295
|
+
try {
|
|
296
|
+
const info = await docker.getContainer(containerId).inspect();
|
|
297
|
+
return info.State.Running === true;
|
|
298
|
+
} catch (err) {
|
|
299
|
+
return false;
|
|
300
|
+
}
|
|
301
|
+
};
|
|
302
|
+
|
|
303
|
+
// ---------------------------------------------------------------------------
|
|
304
|
+
// Helpers
|
|
305
|
+
// ---------------------------------------------------------------------------
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Parse a Docker-style memory string ('256m', '1g', '512000') to bytes.
|
|
309
|
+
*/
|
|
310
|
+
const parseMemoryString = (mem) => {
|
|
311
|
+
if (typeof mem === 'number') return mem;
|
|
312
|
+
const str = String(mem).trim().toLowerCase();
|
|
313
|
+
const match = str.match(/^(\d+(?:\.\d+)?)\s*([kmgt]?)b?$/);
|
|
314
|
+
if (!match) return 256 * 1024 * 1024; // default 256MB
|
|
315
|
+
|
|
316
|
+
const value = parseFloat(match[1]);
|
|
317
|
+
const unit = match[2];
|
|
318
|
+
const multipliers = { '': 1, 'k': 1024, 'm': 1024 ** 2, 'g': 1024 ** 3, 't': 1024 ** 4 };
|
|
319
|
+
return Math.round(value * (multipliers[unit] || 1));
|
|
320
|
+
};
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Parse a CPU limit string ('1', '0.5', '2') to NanoCpus.
|
|
324
|
+
* Docker NanoCpus: 1 CPU = 1e9 nanoseconds.
|
|
325
|
+
*/
|
|
326
|
+
const parseCpuLimit = (cpu) => {
|
|
327
|
+
const value = parseFloat(cpu);
|
|
328
|
+
if (isNaN(value) || value <= 0) return 1e9;
|
|
329
|
+
return Math.round(value * 1e9);
|
|
330
|
+
};
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Demux Docker multiplexed stream output into stdout and stderr strings.
|
|
334
|
+
* Docker container logs use an 8-byte header per frame:
|
|
335
|
+
* byte 0: stream type (0=stdin, 1=stdout, 2=stderr)
|
|
336
|
+
* bytes 4-7: big-endian uint32 payload size
|
|
337
|
+
*/
|
|
338
|
+
const demuxDockerLogs = (buffer) => {
|
|
339
|
+
let stdout = '';
|
|
340
|
+
let stderr = '';
|
|
341
|
+
|
|
342
|
+
// If it's a string, dockerode sometimes returns raw strings for TTY containers.
|
|
343
|
+
if (typeof buffer === 'string') {
|
|
344
|
+
return { stdout: buffer, stderr: '' };
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const buf = Buffer.isBuffer(buffer) ? buffer : Buffer.from(buffer);
|
|
348
|
+
let offset = 0;
|
|
349
|
+
|
|
350
|
+
while (offset + 8 <= buf.length) {
|
|
351
|
+
const streamType = buf[offset];
|
|
352
|
+
const payloadSize = buf.readUInt32BE(offset + 4);
|
|
353
|
+
offset += 8;
|
|
354
|
+
|
|
355
|
+
if (offset + payloadSize > buf.length) break;
|
|
356
|
+
|
|
357
|
+
const payload = buf.slice(offset, offset + payloadSize).toString('utf-8');
|
|
358
|
+
if (streamType === 1) {
|
|
359
|
+
stdout += payload;
|
|
360
|
+
} else if (streamType === 2) {
|
|
361
|
+
stderr += payload;
|
|
362
|
+
}
|
|
363
|
+
offset += payloadSize;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
return { stdout, stderr };
|
|
367
|
+
};
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Stream logs from a running container.
|
|
371
|
+
* Returns a readable stream that emits { stream: 'stdout'|'stderr', data: string } objects.
|
|
372
|
+
* Caller should listen for 'data' and 'end'/'error' events.
|
|
373
|
+
*/
|
|
374
|
+
const streamContainerLogs = async (containerId) => {
|
|
375
|
+
const container = docker.getContainer(containerId);
|
|
376
|
+
const logStream = await container.logs({
|
|
377
|
+
follow: true,
|
|
378
|
+
stdout: true,
|
|
379
|
+
stderr: true,
|
|
380
|
+
timestamps: true,
|
|
381
|
+
since: 0,
|
|
382
|
+
});
|
|
383
|
+
return { logStream, demuxDockerLogs };
|
|
384
|
+
};
|
|
385
|
+
|
|
386
|
+
module.exports = {
|
|
387
|
+
isDockerAvailable,
|
|
388
|
+
isImageBuilt,
|
|
389
|
+
buildImage,
|
|
390
|
+
ensureImage,
|
|
391
|
+
runGameContainer,
|
|
392
|
+
validateGame,
|
|
393
|
+
stopContainer,
|
|
394
|
+
isContainerRunning,
|
|
395
|
+
streamContainerLogs,
|
|
396
|
+
};
|
package/game-loader.js
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const http = require('http');
|
|
4
|
+
const https = require('https');
|
|
5
|
+
const os = require('os');
|
|
6
|
+
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
// Canonical squish version → npm package mapping.
|
|
9
|
+
// SINGLE SOURCE OF TRUTH — all repos should import from here.
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
const squishMap = {
|
|
12
|
+
'061': 'squish-061',
|
|
13
|
+
'063': 'squish-063',
|
|
14
|
+
'0631': 'squish-0631',
|
|
15
|
+
'0632': 'squish-0632',
|
|
16
|
+
'0633': 'squish-0633',
|
|
17
|
+
'0756': 'squish-0756',
|
|
18
|
+
'0762': 'squish-0762',
|
|
19
|
+
'0765': 'squish-0765',
|
|
20
|
+
'0766': 'squish-0766',
|
|
21
|
+
'0767': 'squish-0767',
|
|
22
|
+
'1000': 'squish-1000',
|
|
23
|
+
'1004': 'squish-1004',
|
|
24
|
+
'1005': 'squish-1005',
|
|
25
|
+
'1006': 'squish-1006',
|
|
26
|
+
'1007': 'squish-1007',
|
|
27
|
+
'1008': 'squish-1008',
|
|
28
|
+
'1009': 'squish-1009',
|
|
29
|
+
'1010': 'squish-1010',
|
|
30
|
+
'110': 'squish-110',
|
|
31
|
+
'120': 'squish-120',
|
|
32
|
+
'130': 'squish-130',
|
|
33
|
+
'135': 'squish-135',
|
|
34
|
+
'136': 'squish-136',
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const DEFAULT_SQUISH_VERSION = '135';
|
|
38
|
+
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
// Parse squish version from source file using AST (reliable, needs acorn)
|
|
41
|
+
// Falls back to regex if acorn is unavailable or parsing fails.
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
const parseSquishVersion = (codePath) => {
|
|
44
|
+
const code = fs.readFileSync(codePath, 'utf-8');
|
|
45
|
+
|
|
46
|
+
const { Parser } = require('acorn');
|
|
47
|
+
const parsed = Parser.parse(code, { ecmaVersion: 'latest', sourceType: 'script' });
|
|
48
|
+
|
|
49
|
+
const foundGameClasses = parsed.body.filter(
|
|
50
|
+
n => n.type === 'ClassDeclaration' && n.superClass && n.superClass.name === 'Game'
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
if (foundGameClasses.length !== 1) {
|
|
54
|
+
throw new Error('Unable to parse squish version');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const metadataMethods = foundGameClasses[0].body.body.filter(
|
|
58
|
+
n => n.key && n.key.name === 'metadata' && (n.kind === 'method' || n.static)
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
let foundVersion = null;
|
|
62
|
+
|
|
63
|
+
metadataMethods[0].value.body.body.forEach(n => {
|
|
64
|
+
if (n.type === 'ReturnStatement' && n.argument && n.argument.properties) {
|
|
65
|
+
const versionNodes = n.argument.properties.filter(
|
|
66
|
+
p => p.key && p.key.name === 'squishVersion'
|
|
67
|
+
);
|
|
68
|
+
if (versionNodes.length === 1 && !foundVersion) {
|
|
69
|
+
foundVersion = String(versionNodes[0].value.value);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
return foundVersion;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
// Load a game class from a file path on disk.
|
|
79
|
+
// Clears require cache so re-loads get fresh code.
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
const loadGameClassFromPath = (gamePath) => {
|
|
82
|
+
const resolved = path.resolve(gamePath);
|
|
83
|
+
delete require.cache[require.resolve(resolved)];
|
|
84
|
+
return require(resolved);
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
// ---------------------------------------------------------------------------
|
|
88
|
+
// Load a game class from a source code string.
|
|
89
|
+
// Writes to a temp file, requires it, then cleans up.
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
let tmpCounter = 0;
|
|
92
|
+
|
|
93
|
+
const loadGameClass = (code, tmpDir) => {
|
|
94
|
+
const dir = tmpDir || path.join(os.tmpdir(), 'hg-game-loader');
|
|
95
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
96
|
+
|
|
97
|
+
const tmpPath = path.join(dir, `hg-game-${Date.now()}-${tmpCounter++}.js`);
|
|
98
|
+
fs.writeFileSync(tmpPath, code);
|
|
99
|
+
try {
|
|
100
|
+
delete require.cache[require.resolve(tmpPath)];
|
|
101
|
+
return require(tmpPath);
|
|
102
|
+
} finally {
|
|
103
|
+
try { fs.unlinkSync(tmpPath); } catch (e) { /* best-effort cleanup */ }
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
// ---------------------------------------------------------------------------
|
|
108
|
+
// Download a URL to a local file. Follows one level of redirects.
|
|
109
|
+
// ---------------------------------------------------------------------------
|
|
110
|
+
const downloadToFile = (url, destPath, headers = {}) => new Promise((resolve, reject) => {
|
|
111
|
+
const mod = url.startsWith('https') ? https : http;
|
|
112
|
+
const writeStream = fs.createWriteStream(destPath);
|
|
113
|
+
|
|
114
|
+
mod.get(url, { headers }, (res) => {
|
|
115
|
+
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
|
116
|
+
writeStream.close();
|
|
117
|
+
fs.unlinkSync(destPath);
|
|
118
|
+
downloadToFile(res.headers.location, destPath, headers).then(resolve).catch(reject);
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (res.statusCode < 200 || res.statusCode >= 300) {
|
|
123
|
+
writeStream.close();
|
|
124
|
+
reject(new Error(`HTTP ${res.statusCode} downloading ${url}`));
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
res.pipe(writeStream);
|
|
129
|
+
writeStream.on('finish', () => {
|
|
130
|
+
writeStream.close();
|
|
131
|
+
resolve();
|
|
132
|
+
});
|
|
133
|
+
writeStream.on('error', reject);
|
|
134
|
+
}).on('error', (err) => {
|
|
135
|
+
writeStream.close();
|
|
136
|
+
reject(err);
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
// ---------------------------------------------------------------------------
|
|
141
|
+
// Find the shallowest index.js in a list of extracted files.
|
|
142
|
+
// Used after decompressing a zip archive.
|
|
143
|
+
// ---------------------------------------------------------------------------
|
|
144
|
+
const findEntryPoint = (files, extractPath) => {
|
|
145
|
+
const indexFiles = files
|
|
146
|
+
.filter(f => f.type === 'file' && f.path.endsWith('index.js'))
|
|
147
|
+
.filter(f => !f.path.includes('node_modules'))
|
|
148
|
+
.sort((a, b) => a.path.split('/').length - b.path.split('/').length);
|
|
149
|
+
|
|
150
|
+
if (indexFiles.length === 0) {
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return path.join(extractPath, indexFiles[0].path);
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
// ---------------------------------------------------------------------------
|
|
158
|
+
// Fetch game source code from a Forgejo repository.
|
|
159
|
+
// Downloads the repo archive, extracts it, finds the entry point.
|
|
160
|
+
//
|
|
161
|
+
// Requires 'decompress' to be installed by the calling project.
|
|
162
|
+
//
|
|
163
|
+
// Returns: { dir, entryPath, squishVersion, cleanup() }
|
|
164
|
+
// ---------------------------------------------------------------------------
|
|
165
|
+
const fetchGameFromForgejo = ({ forgejoUrl, forgejoToken, owner, repo, ref }) => new Promise((resolve, reject) => {
|
|
166
|
+
const decompress = require('decompress');
|
|
167
|
+
const archiveRef = ref || 'main';
|
|
168
|
+
const archiveUrl = `${forgejoUrl}/api/v1/repos/${owner}/${repo}/archive/${archiveRef}.zip`;
|
|
169
|
+
|
|
170
|
+
const tmpDir = path.join(os.tmpdir(), `hg-forgejo-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
|
171
|
+
fs.mkdirSync(tmpDir, { recursive: true });
|
|
172
|
+
|
|
173
|
+
const zipPath = path.join(tmpDir, 'repo.zip');
|
|
174
|
+
const extractPath = path.join(tmpDir, 'repo');
|
|
175
|
+
|
|
176
|
+
const headers = {};
|
|
177
|
+
if (forgejoToken) {
|
|
178
|
+
headers['Authorization'] = `token ${forgejoToken}`;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
downloadToFile(archiveUrl, zipPath, headers)
|
|
182
|
+
.then(() => decompress(zipPath, extractPath))
|
|
183
|
+
.then((files) => {
|
|
184
|
+
const entryPath = findEntryPoint(files, extractPath);
|
|
185
|
+
|
|
186
|
+
if (!entryPath) {
|
|
187
|
+
reject(new Error('No index.js found in repository'));
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const squishVersion = parseSquishVersion(entryPath);
|
|
192
|
+
|
|
193
|
+
resolve({
|
|
194
|
+
dir: extractPath,
|
|
195
|
+
entryPath,
|
|
196
|
+
squishVersion,
|
|
197
|
+
cleanup: () => {
|
|
198
|
+
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch (e) { /* best-effort */ }
|
|
199
|
+
},
|
|
200
|
+
});
|
|
201
|
+
})
|
|
202
|
+
.catch(reject);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
module.exports = {
|
|
206
|
+
squishMap,
|
|
207
|
+
DEFAULT_SQUISH_VERSION,
|
|
208
|
+
parseSquishVersion,
|
|
209
|
+
loadGameClass,
|
|
210
|
+
loadGameClassFromPath,
|
|
211
|
+
downloadToFile,
|
|
212
|
+
findEntryPoint,
|
|
213
|
+
fetchGameFromForgejo,
|
|
214
|
+
};
|