@agent-foundry/replay-server 1.0.0 → 1.0.2

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.
Files changed (46) hide show
  1. package/.cursor/dev.mdc +941 -0
  2. package/.cursor/project.mdc +17 -2
  3. package/.env +30 -0
  4. package/Dockerfile +6 -0
  5. package/README.md +297 -27
  6. package/dist/cli/render.js +14 -4
  7. package/dist/cli/render.js.map +1 -1
  8. package/dist/renderer/PuppeteerRenderer.d.ts +28 -2
  9. package/dist/renderer/PuppeteerRenderer.d.ts.map +1 -1
  10. package/dist/renderer/PuppeteerRenderer.js +134 -36
  11. package/dist/renderer/PuppeteerRenderer.js.map +1 -1
  12. package/dist/server/index.d.ts +4 -0
  13. package/dist/server/index.d.ts.map +1 -1
  14. package/dist/server/index.js +200 -46
  15. package/dist/server/index.js.map +1 -1
  16. package/dist/services/BundleManager.d.ts +99 -0
  17. package/dist/services/BundleManager.d.ts.map +1 -0
  18. package/dist/services/BundleManager.js +410 -0
  19. package/dist/services/BundleManager.js.map +1 -0
  20. package/dist/services/OSSClient.d.ts +51 -0
  21. package/dist/services/OSSClient.d.ts.map +1 -0
  22. package/dist/services/OSSClient.js +207 -0
  23. package/dist/services/OSSClient.js.map +1 -0
  24. package/dist/services/index.d.ts +7 -0
  25. package/dist/services/index.d.ts.map +1 -0
  26. package/dist/services/index.js +7 -0
  27. package/dist/services/index.js.map +1 -0
  28. package/dist/services/types.d.ts +73 -0
  29. package/dist/services/types.d.ts.map +1 -0
  30. package/dist/services/types.js +5 -0
  31. package/dist/services/types.js.map +1 -0
  32. package/docker-compose.local.yml +10 -0
  33. package/env.example +30 -0
  34. package/package.json +7 -3
  35. package/restart.sh +5 -0
  36. package/samples/jump_arena_5_ja-mksi5fku-qgk5iq.json +1952 -0
  37. package/scripts/render-pipeline.sh +657 -0
  38. package/scripts/test-bundle-preload.sh +20 -0
  39. package/scripts/test-service-sts.sh +176 -0
  40. package/src/cli/render.ts +18 -7
  41. package/src/renderer/PuppeteerRenderer.ts +192 -39
  42. package/src/server/index.ts +249 -68
  43. package/src/services/BundleManager.ts +503 -0
  44. package/src/services/OSSClient.ts +286 -0
  45. package/src/services/index.ts +7 -0
  46. package/src/services/types.ts +78 -0
@@ -0,0 +1,503 @@
1
+ /**
2
+ * Bundle Manager
3
+ *
4
+ * Manages bundle lifecycle: download, extraction, caching, and cleanup.
5
+ * Uses LRU eviction to manage cache size.
6
+ */
7
+
8
+ import * as fs from 'fs';
9
+ import * as path from 'path';
10
+ import { createGunzip } from 'zlib';
11
+ import { pipeline } from 'stream/promises';
12
+ import { extract } from 'tar-stream';
13
+ import type { OSSClient } from './OSSClient.js';
14
+ import type { BundleInfo, BundleManagerConfig, CacheStats, DownloadStats } from './types.js';
15
+
16
+ /**
17
+ * Error thrown by bundle manager operations
18
+ */
19
+ export class BundleManagerError extends Error {
20
+ constructor(
21
+ message: string,
22
+ public readonly code: string,
23
+ public readonly bundleId?: string
24
+ ) {
25
+ super(message);
26
+ this.name = 'BundleManagerError';
27
+ }
28
+ }
29
+
30
+ /**
31
+ * Bundle Manager for downloading, caching, and serving game bundles
32
+ */
33
+ export class BundleManager {
34
+ private readonly bundlesDir: string;
35
+ private readonly maxCacheSize: number;
36
+ private readonly ossClient: OSSClient;
37
+
38
+ private bundles = new Map<string, BundleInfo>();
39
+ private downloadPromises = new Map<string, Promise<string>>();
40
+ private bundlesInUse = new Set<string>();
41
+
42
+ private downloadStats: DownloadStats = {
43
+ active: 0,
44
+ completed: 0,
45
+ failed: 0,
46
+ };
47
+
48
+ constructor(config: BundleManagerConfig & { ossClient: OSSClient }) {
49
+ this.bundlesDir = config.bundlesDir;
50
+ this.maxCacheSize = config.maxCacheSize;
51
+ this.ossClient = config.ossClient;
52
+
53
+ // Ensure bundles directory exists
54
+ fs.mkdirSync(this.bundlesDir, { recursive: true });
55
+
56
+ // Load existing bundles from disk
57
+ this.loadCachedBundles();
58
+ }
59
+
60
+ /**
61
+ * Ensure a bundle is available (download if needed)
62
+ * Returns the path to the bundle directory
63
+ */
64
+ async ensureBundle(bundleId: string, bundleUrl?: string): Promise<string> {
65
+ const bundlePath = path.join(this.bundlesDir, bundleId);
66
+
67
+ // Check if already cached
68
+ if (this.isCached(bundleId)) {
69
+ // Update last accessed time
70
+ const info = this.bundles.get(bundleId);
71
+ if (info) {
72
+ info.lastAccessedAt = new Date();
73
+ }
74
+ return bundlePath;
75
+ }
76
+
77
+ // Check if already downloading
78
+ if (this.downloadPromises.has(bundleId)) {
79
+ console.log(`[BundleManager] Waiting for existing download: ${bundleId}`);
80
+ return this.downloadPromises.get(bundleId)!;
81
+ }
82
+
83
+ // Need to download
84
+ if (!bundleUrl) {
85
+ throw new BundleManagerError(
86
+ `Bundle ${bundleId} not cached and no bundleUrl provided`,
87
+ 'BUNDLE_URL_REQUIRED',
88
+ bundleId
89
+ );
90
+ }
91
+
92
+ // Start download
93
+ const downloadPromise = this.downloadBundle(bundleId, bundleUrl);
94
+ this.downloadPromises.set(bundleId, downloadPromise);
95
+
96
+ try {
97
+ const result = await downloadPromise;
98
+ return result;
99
+ } finally {
100
+ this.downloadPromises.delete(bundleId);
101
+ }
102
+ }
103
+
104
+ /**
105
+ * Check if a bundle is cached and ready
106
+ */
107
+ isCached(bundleId: string): boolean {
108
+ const bundlePath = path.join(this.bundlesDir, bundleId);
109
+ const indexPath = path.join(bundlePath, 'index.html');
110
+ return fs.existsSync(bundlePath) && fs.existsSync(indexPath);
111
+ }
112
+
113
+ /**
114
+ * Mark bundle as in use (prevents eviction during rendering)
115
+ */
116
+ markInUse(bundleId: string): void {
117
+ this.bundlesInUse.add(bundleId);
118
+ }
119
+
120
+ /**
121
+ * Mark bundle as no longer in use
122
+ */
123
+ markNotInUse(bundleId: string): void {
124
+ this.bundlesInUse.delete(bundleId);
125
+ }
126
+
127
+ /**
128
+ * Download and extract a bundle
129
+ */
130
+ private async downloadBundle(bundleId: string, bundleUrl: string): Promise<string> {
131
+ const bundlePath = path.join(this.bundlesDir, bundleId);
132
+ const tempPath = `${bundlePath}.downloading`;
133
+ const archivePath = `${bundlePath}.tar.gz`;
134
+
135
+ // Initialize bundle info
136
+ this.bundles.set(bundleId, {
137
+ bundleId,
138
+ bundleUrl,
139
+ status: 'downloading',
140
+ progress: 0,
141
+ });
142
+
143
+ this.downloadStats.active++;
144
+
145
+ try {
146
+ console.log(`[BundleManager] Downloading bundle ${bundleId} from ${bundleUrl}`);
147
+
148
+ // Ensure cache has space
149
+ await this.ensureCacheSpace(100 * 1024 * 1024); // Reserve 100MB
150
+
151
+ // Download archive
152
+ await this.ossClient.downloadFile(bundleUrl, archivePath, (percent) => {
153
+ const info = this.bundles.get(bundleId);
154
+ if (info) {
155
+ info.progress = percent;
156
+ }
157
+ });
158
+
159
+ console.log(`[BundleManager] Extracting bundle ${bundleId}...`);
160
+
161
+ // Create temp directory for extraction
162
+ fs.mkdirSync(tempPath, { recursive: true });
163
+
164
+ // Extract TAR.GZ
165
+ await this.extractTarGz(archivePath, tempPath);
166
+
167
+ // Clean up archive
168
+ fs.unlinkSync(archivePath);
169
+
170
+ // Move temp to final location
171
+ if (fs.existsSync(bundlePath)) {
172
+ fs.rmSync(bundlePath, { recursive: true });
173
+ }
174
+ fs.renameSync(tempPath, bundlePath);
175
+
176
+ // Verify extraction
177
+ const indexPath = path.join(bundlePath, 'index.html');
178
+ if (!fs.existsSync(indexPath)) {
179
+ throw new BundleManagerError(
180
+ `Bundle extraction failed: index.html not found`,
181
+ 'EXTRACTION_INVALID',
182
+ bundleId
183
+ );
184
+ }
185
+
186
+ // Calculate bundle size
187
+ const size = this.getDirectorySize(bundlePath);
188
+
189
+ // Update bundle info
190
+ this.bundles.set(bundleId, {
191
+ bundleId,
192
+ bundleUrl,
193
+ status: 'ready',
194
+ size,
195
+ cachedAt: new Date(),
196
+ lastAccessedAt: new Date(),
197
+ });
198
+
199
+ this.downloadStats.active--;
200
+ this.downloadStats.completed++;
201
+
202
+ console.log(`[BundleManager] Bundle ${bundleId} ready (${(size / 1024 / 1024).toFixed(2)} MB)`);
203
+
204
+ return bundlePath;
205
+ } catch (error) {
206
+ // Cleanup on error
207
+ for (const p of [tempPath, archivePath, bundlePath]) {
208
+ if (fs.existsSync(p)) {
209
+ try {
210
+ fs.rmSync(p, { recursive: true });
211
+ } catch {
212
+ // Ignore cleanup errors
213
+ }
214
+ }
215
+ }
216
+
217
+ const errorMessage = error instanceof Error ? error.message : String(error);
218
+
219
+ this.bundles.set(bundleId, {
220
+ bundleId,
221
+ bundleUrl,
222
+ status: 'error',
223
+ error: errorMessage,
224
+ });
225
+
226
+ this.downloadStats.active--;
227
+ this.downloadStats.failed++;
228
+
229
+ throw new BundleManagerError(
230
+ `Failed to download bundle ${bundleId}: ${errorMessage}`,
231
+ 'DOWNLOAD_FAILED',
232
+ bundleId
233
+ );
234
+ }
235
+ }
236
+
237
+ /**
238
+ * Extract TAR.GZ archive to destination directory
239
+ */
240
+ private async extractTarGz(archivePath: string, destPath: string): Promise<void> {
241
+ return new Promise((resolve, reject) => {
242
+ const extractStream = extract();
243
+ const entries: string[] = [];
244
+
245
+ extractStream.on('entry', (header, stream, next) => {
246
+ let filePath = path.join(destPath, header.name);
247
+
248
+ // Handle case where archive has a single root directory
249
+ // (common with GitHub releases: bundle-v1.0.0/index.html)
250
+ entries.push(header.name);
251
+
252
+ if (header.type === 'directory') {
253
+ fs.mkdirSync(filePath, { recursive: true });
254
+ stream.resume();
255
+ next();
256
+ } else if (header.type === 'file') {
257
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
258
+ const writeStream = fs.createWriteStream(filePath);
259
+ stream.pipe(writeStream);
260
+ stream.on('end', () => {
261
+ writeStream.end();
262
+ next();
263
+ });
264
+ stream.on('error', reject);
265
+ writeStream.on('error', reject);
266
+ } else {
267
+ // Skip other types (symlinks, etc.)
268
+ stream.resume();
269
+ next();
270
+ }
271
+ });
272
+
273
+ extractStream.on('finish', () => {
274
+ // Check if all files are in a subdirectory
275
+ // If so, move them up one level
276
+ const subdirs = fs.readdirSync(destPath);
277
+ if (subdirs.length === 1) {
278
+ const subdir = path.join(destPath, subdirs[0]);
279
+ if (fs.statSync(subdir).isDirectory()) {
280
+ // Check if index.html is in subdirectory
281
+ if (fs.existsSync(path.join(subdir, 'index.html'))) {
282
+ // Move all files up
283
+ const files = fs.readdirSync(subdir);
284
+ for (const file of files) {
285
+ fs.renameSync(
286
+ path.join(subdir, file),
287
+ path.join(destPath, file)
288
+ );
289
+ }
290
+ fs.rmdirSync(subdir);
291
+ }
292
+ }
293
+ }
294
+ resolve();
295
+ });
296
+
297
+ extractStream.on('error', reject);
298
+
299
+ const gunzip = createGunzip();
300
+ gunzip.on('error', reject);
301
+
302
+ const readStream = fs.createReadStream(archivePath);
303
+ readStream.on('error', reject);
304
+
305
+ readStream.pipe(gunzip).pipe(extractStream);
306
+ });
307
+ }
308
+
309
+ /**
310
+ * Ensure cache has enough space by evicting old bundles
311
+ */
312
+ private async ensureCacheSpace(requiredBytes: number): Promise<void> {
313
+ const stats = this.getCacheStats();
314
+ const availableBytes = this.maxCacheSize - stats.used;
315
+
316
+ if (availableBytes >= requiredBytes) {
317
+ return;
318
+ }
319
+
320
+ const bytesToFree = requiredBytes - availableBytes;
321
+ let freedBytes = 0;
322
+
323
+ // Get bundles sorted by last accessed time (oldest first)
324
+ const evictionCandidates = Array.from(this.bundles.entries())
325
+ .filter(([id, info]) => {
326
+ return (
327
+ info.status === 'ready' &&
328
+ !this.bundlesInUse.has(id) &&
329
+ !this.downloadPromises.has(id)
330
+ );
331
+ })
332
+ .sort(([, a], [, b]) => {
333
+ const aTime = a.lastAccessedAt?.getTime() || 0;
334
+ const bTime = b.lastAccessedAt?.getTime() || 0;
335
+ return aTime - bTime;
336
+ });
337
+
338
+ for (const [bundleId, info] of evictionCandidates) {
339
+ if (freedBytes >= bytesToFree) {
340
+ break;
341
+ }
342
+
343
+ console.log(`[BundleManager] Evicting bundle ${bundleId} to free space`);
344
+ this.removeBundle(bundleId);
345
+ freedBytes += info.size || 0;
346
+ }
347
+
348
+ if (freedBytes < bytesToFree) {
349
+ throw new BundleManagerError(
350
+ `Unable to free enough cache space (needed: ${bytesToFree}, freed: ${freedBytes})`,
351
+ 'CACHE_FULL'
352
+ );
353
+ }
354
+ }
355
+
356
+ /**
357
+ * Load existing bundles from disk
358
+ */
359
+ private loadCachedBundles(): void {
360
+ if (!fs.existsSync(this.bundlesDir)) {
361
+ return;
362
+ }
363
+
364
+ const entries = fs.readdirSync(this.bundlesDir, { withFileTypes: true });
365
+
366
+ for (const entry of entries) {
367
+ if (!entry.isDirectory()) {
368
+ continue;
369
+ }
370
+
371
+ // Skip temp directories
372
+ if (entry.name.endsWith('.downloading') || entry.name.endsWith('.tar.gz')) {
373
+ // Clean up orphaned temp files
374
+ try {
375
+ fs.rmSync(path.join(this.bundlesDir, entry.name), { recursive: true });
376
+ } catch {
377
+ // Ignore
378
+ }
379
+ continue;
380
+ }
381
+
382
+ const bundlePath = path.join(this.bundlesDir, entry.name);
383
+ const indexPath = path.join(bundlePath, 'index.html');
384
+
385
+ if (fs.existsSync(indexPath)) {
386
+ const stats = fs.statSync(bundlePath);
387
+ const size = this.getDirectorySize(bundlePath);
388
+
389
+ this.bundles.set(entry.name, {
390
+ bundleId: entry.name,
391
+ status: 'ready',
392
+ size,
393
+ cachedAt: stats.mtime,
394
+ lastAccessedAt: stats.mtime,
395
+ });
396
+ }
397
+ }
398
+
399
+ console.log(`[BundleManager] Loaded ${this.bundles.size} cached bundles`);
400
+ }
401
+
402
+ /**
403
+ * Get directory size recursively
404
+ */
405
+ private getDirectorySize(dirPath: string): number {
406
+ let size = 0;
407
+
408
+ try {
409
+ const files = fs.readdirSync(dirPath);
410
+
411
+ for (const file of files) {
412
+ const filePath = path.join(dirPath, file);
413
+ const stats = fs.statSync(filePath);
414
+
415
+ if (stats.isDirectory()) {
416
+ size += this.getDirectorySize(filePath);
417
+ } else {
418
+ size += stats.size;
419
+ }
420
+ }
421
+ } catch {
422
+ // Ignore errors
423
+ }
424
+
425
+ return size;
426
+ }
427
+
428
+ /**
429
+ * List all bundles
430
+ */
431
+ listBundles(): BundleInfo[] {
432
+ return Array.from(this.bundles.values());
433
+ }
434
+
435
+ /**
436
+ * Get bundle info
437
+ */
438
+ getBundleInfo(bundleId: string): BundleInfo | undefined {
439
+ return this.bundles.get(bundleId);
440
+ }
441
+
442
+ /**
443
+ * Remove bundle from cache
444
+ */
445
+ removeBundle(bundleId: string): void {
446
+ const bundlePath = path.join(this.bundlesDir, bundleId);
447
+
448
+ if (fs.existsSync(bundlePath)) {
449
+ try {
450
+ fs.rmSync(bundlePath, { recursive: true });
451
+ } catch (error) {
452
+ console.error(`[BundleManager] Failed to remove bundle ${bundleId}:`, error);
453
+ }
454
+ }
455
+
456
+ this.bundles.delete(bundleId);
457
+ }
458
+
459
+ /**
460
+ * Get cache statistics
461
+ */
462
+ getCacheStats(): CacheStats {
463
+ let used = 0;
464
+ let count = 0;
465
+
466
+ for (const info of this.bundles.values()) {
467
+ if (info.status === 'ready' && info.size) {
468
+ used += info.size;
469
+ count++;
470
+ }
471
+ }
472
+
473
+ return {
474
+ used,
475
+ max: this.maxCacheSize,
476
+ usedPercent: Math.round((used / this.maxCacheSize) * 100),
477
+ bundleCount: count,
478
+ };
479
+ }
480
+
481
+ /**
482
+ * Get download statistics
483
+ */
484
+ getDownloadStats(): DownloadStats {
485
+ return { ...this.downloadStats };
486
+ }
487
+
488
+ /**
489
+ * Get bundles directory path
490
+ */
491
+ getBundlesDir(): string {
492
+ return this.bundlesDir;
493
+ }
494
+ }
495
+
496
+ /**
497
+ * Create a bundle manager instance
498
+ */
499
+ export function createBundleManager(
500
+ config: BundleManagerConfig & { ossClient: OSSClient }
501
+ ): BundleManager {
502
+ return new BundleManager(config);
503
+ }