@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
@@ -1,14 +1,18 @@
1
1
  /**
2
2
  * Replay Render Server
3
- *
3
+ *
4
4
  * HTTP API for rendering replays to video
5
- *
5
+ *
6
6
  * Endpoints:
7
7
  * - POST /render - Start a render job
8
8
  * - GET /jobs - List all jobs
9
9
  * - GET /status/:jobId - Get job status
10
10
  * - GET /download/:jobId - Download completed video
11
11
  * - GET /bundles - List available game bundles
12
+ * - POST /bundles/preload - Preload a bundle
13
+ * - GET /bundles/stats - Get cache statistics
14
+ * - GET /bundles/:bundleId - Get specific bundle info
15
+ * - DELETE /bundles/:bundleId - Remove bundle from cache
12
16
  */
13
17
 
14
18
  import express, { type Express } from 'express';
@@ -20,6 +24,8 @@ import { fileURLToPath } from 'url';
20
24
  import { dirname } from 'path';
21
25
  import { execSync } from 'child_process';
22
26
  import { PuppeteerRenderer, RenderProgress } from '../renderer/PuppeteerRenderer.js';
27
+ import { OSSClient, createOSSClient } from '../services/OSSClient.js';
28
+ import { BundleManager, createBundleManager, BundleManagerError } from '../services/BundleManager.js';
23
29
  import type { ReplayManifestV1 } from '@agent-foundry/replay';
24
30
 
25
31
  const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -33,50 +39,184 @@ const PORT = process.env.PORT || 3001;
33
39
  const GAME_URL = process.env.GAME_URL || 'http://localhost:5173';
34
40
  const OUTPUT_DIR = process.env.OUTPUT_DIR || path.join(process.cwd(), 'output');
35
41
  const BUNDLES_DIR = process.env.BUNDLES_DIR || path.join(__dirname, '../../bundles');
42
+ const BFF_BASE_URL = process.env.BFF_BASE_URL || 'http://localhost:11001';
43
+ const BFF_SERVICE_TOKEN = process.env.BFF_SERVICE_TOKEN;
44
+ const MAX_CACHE_SIZE = parseInt(process.env.MAX_CACHE_SIZE || '10737418240'); // 10GB default
45
+
46
+ // Initialize OSS Client (for downloading bundles)
47
+ const ossClient = createOSSClient({
48
+ bffBaseUrl: BFF_BASE_URL,
49
+ serviceToken: BFF_SERVICE_TOKEN,
50
+ timeout: 30000,
51
+ });
52
+
53
+ // Initialize Bundle Manager
54
+ const bundleManager = createBundleManager({
55
+ bundlesDir: BUNDLES_DIR,
56
+ maxCacheSize: MAX_CACHE_SIZE,
57
+ ossClient,
58
+ });
36
59
 
37
60
  /**
38
- * GET /bundles
61
+ * GET /api/bundles
39
62
  * List available game bundles
40
- * This route must be registered BEFORE the static middleware to handle /bundles requests
41
63
  */
42
- app.get('/bundles', (req, res) => {
43
- console.log('[GET /bundles] Request received');
64
+ app.get('/api/bundles', (req, res) => {
65
+ console.log('[GET /api/bundles] Request received');
44
66
  try {
45
- // Ensure bundles directory exists
46
- if (!fs.existsSync(BUNDLES_DIR)) {
47
- return res.json({ bundles: [], count: 0 });
48
- }
67
+ const bundles = bundleManager.listBundles().map(info => ({
68
+ bundleId: info.bundleId,
69
+ status: info.status,
70
+ size: info.size,
71
+ cachedAt: info.cachedAt,
72
+ lastAccessedAt: info.lastAccessedAt,
73
+ url: info.status === 'ready'
74
+ ? `http://localhost:${PORT}/bundles/${info.bundleId}/`
75
+ : undefined,
76
+ ready: info.status === 'ready',
77
+ progress: info.progress,
78
+ error: info.error,
79
+ }));
49
80
 
50
- // List directories in bundles folder
51
- const entries = fs.readdirSync(BUNDLES_DIR, { withFileTypes: true });
52
- const bundles = entries
53
- .filter(entry => entry.isDirectory())
54
- .map(entry => {
55
- const bundlePath = path.join(BUNDLES_DIR, entry.name);
56
- const indexPath = path.join(bundlePath, 'index.html');
57
- const hasIndex = fs.existsSync(indexPath);
58
-
59
- return {
60
- bundleId: entry.name,
61
- path: bundlePath,
62
- url: `http://localhost:${PORT}/bundles/${entry.name}/`,
63
- ready: hasIndex,
64
- };
65
- });
81
+ const cacheStats = bundleManager.getCacheStats();
66
82
 
67
- console.log('[GET /bundles] Found bundles:', bundles.length);
83
+ console.log('[GET /api/bundles] Found bundles:', bundles.length);
68
84
  res.json({
69
85
  bundles,
70
86
  count: bundles.length,
71
- bundlesDir: BUNDLES_DIR,
87
+ cache: cacheStats,
72
88
  });
73
89
  } catch (error) {
74
- console.error('[GET /bundles] Error listing bundles:', error);
90
+ console.error('[GET /api/bundles] Error listing bundles:', error);
75
91
  res.status(500).json({ error: 'Failed to list bundles' });
76
92
  }
77
93
  });
78
94
 
79
- // Serve static game bundles (AFTER API route to allow /bundles/<bundleId>/ paths)
95
+ /**
96
+ * GET /api/bundles/stats
97
+ * Get cache and download statistics
98
+ */
99
+ app.get('/api/bundles/stats', (req, res) => {
100
+ console.log('[GET /api/bundles/stats] Request received');
101
+ try {
102
+ const cacheStats = bundleManager.getCacheStats();
103
+ const downloadStats = bundleManager.getDownloadStats();
104
+
105
+ res.json({
106
+ cache: cacheStats,
107
+ downloads: downloadStats,
108
+ });
109
+ } catch (error) {
110
+ console.error('[GET /api/bundles/stats] Error getting stats:', error);
111
+ res.status(500).json({ error: 'Failed to get stats' });
112
+ }
113
+ });
114
+
115
+ /**
116
+ * POST /api/bundles/preload
117
+ * Preload a bundle (async download)
118
+ */
119
+ app.post('/api/bundles/preload', async (req, res) => {
120
+ console.log('[POST /api/bundles/preload] Request received');
121
+ try {
122
+ const { bundleId, bundleUrl } = req.body;
123
+
124
+ if (!bundleId) {
125
+ return res.status(400).json({ error: 'bundleId is required' });
126
+ }
127
+
128
+ if (!bundleUrl) {
129
+ return res.status(400).json({ error: 'bundleUrl is required' });
130
+ }
131
+
132
+ // Check if already cached
133
+ if (bundleManager.isCached(bundleId)) {
134
+ return res.json({
135
+ bundleId,
136
+ status: 'ready',
137
+ message: 'Bundle already cached',
138
+ });
139
+ }
140
+
141
+ // Start download in background (non-blocking)
142
+ bundleManager.ensureBundle(bundleId, bundleUrl).catch(error => {
143
+ console.error(`[POST /api/bundles/preload] Failed to download ${bundleId}:`, error);
144
+ });
145
+
146
+ res.json({
147
+ bundleId,
148
+ status: 'downloading',
149
+ message: 'Bundle download started',
150
+ });
151
+ } catch (error) {
152
+ console.error('[POST /api/bundles/preload] Error:', error);
153
+ res.status(500).json({ error: 'Failed to start bundle download' });
154
+ }
155
+ });
156
+
157
+ /**
158
+ * GET /api/bundles/:bundleId
159
+ * Get specific bundle info
160
+ */
161
+ app.get('/api/bundles/:bundleId', (req, res) => {
162
+ const { bundleId } = req.params;
163
+ console.log(`[GET /api/bundles/${bundleId}] Request received`);
164
+
165
+ try {
166
+ const info = bundleManager.getBundleInfo(bundleId);
167
+
168
+ if (!info) {
169
+ return res.status(404).json({ error: 'Bundle not found' });
170
+ }
171
+
172
+ res.json({
173
+ bundleId: info.bundleId,
174
+ status: info.status,
175
+ size: info.size,
176
+ cachedAt: info.cachedAt,
177
+ lastAccessedAt: info.lastAccessedAt,
178
+ url: info.status === 'ready'
179
+ ? `http://localhost:${PORT}/bundles/${info.bundleId}/`
180
+ : undefined,
181
+ ready: info.status === 'ready',
182
+ progress: info.progress,
183
+ error: info.error,
184
+ });
185
+ } catch (error) {
186
+ console.error(`[GET /api/bundles/${bundleId}] Error:`, error);
187
+ res.status(500).json({ error: 'Failed to get bundle info' });
188
+ }
189
+ });
190
+
191
+ /**
192
+ * DELETE /api/bundles/:bundleId
193
+ * Remove bundle from cache
194
+ */
195
+ app.delete('/api/bundles/:bundleId', (req, res) => {
196
+ const { bundleId } = req.params;
197
+ console.log(`[DELETE /api/bundles/${bundleId}] Request received`);
198
+
199
+ try {
200
+ const info = bundleManager.getBundleInfo(bundleId);
201
+
202
+ if (!info) {
203
+ return res.status(404).json({ error: 'Bundle not found' });
204
+ }
205
+
206
+ bundleManager.removeBundle(bundleId);
207
+
208
+ res.json({
209
+ bundleId,
210
+ message: 'Bundle removed from cache',
211
+ });
212
+ } catch (error) {
213
+ console.error(`[DELETE /api/bundles/${bundleId}] Error:`, error);
214
+ res.status(500).json({ error: 'Failed to remove bundle' });
215
+ }
216
+ });
217
+
218
+ // Serve static game bundles at /bundles/<bundleId>/
219
+ // API routes are at /api/bundles/* to avoid conflicts
80
220
  app.use('/bundles', express.static(BUNDLES_DIR, {
81
221
  index: 'index.html',
82
222
  extensions: ['html']
@@ -91,37 +231,37 @@ app.get('/debug/chrome', async (req, res) => {
91
231
  executablePath: process.env.PUPPETEER_EXECUTABLE_PATH || '/usr/bin/chromium',
92
232
  tests: {}
93
233
  };
94
-
234
+
95
235
  try {
96
236
  // Test 1: Check if chromium exists
97
- const version = execSync(`${results.executablePath} --version`, {
237
+ const version = execSync(`${results.executablePath} --version`, {
98
238
  encoding: 'utf8',
99
- timeout: 5000
239
+ timeout: 5000
100
240
  });
101
241
  results.tests.version = { ok: true, output: version.trim() };
102
242
  } catch (e: any) {
103
- results.tests.version = {
104
- ok: false,
243
+ results.tests.version = {
244
+ ok: false,
105
245
  error: e.message,
106
- stderr: e.stderr?.toString()
246
+ stderr: e.stderr?.toString()
107
247
  };
108
248
  }
109
-
249
+
110
250
  try {
111
251
  // Test 2: Check library dependencies
112
- const ldd = execSync(`ldd ${results.executablePath}`, {
252
+ const ldd = execSync(`ldd ${results.executablePath}`, {
113
253
  encoding: 'utf8',
114
- timeout: 5000
254
+ timeout: 5000
115
255
  });
116
256
  results.tests.dependencies = { ok: true, summary: 'All libraries found' };
117
257
  } catch (e: any) {
118
- results.tests.dependencies = {
119
- ok: false,
258
+ results.tests.dependencies = {
259
+ ok: false,
120
260
  error: e.message,
121
- stderr: e.stderr?.toString()
261
+ stderr: e.stderr?.toString()
122
262
  };
123
263
  }
124
-
264
+
125
265
  try {
126
266
  // Test 3: Check if Chinese fonts are available
127
267
  const fonts = execSync(`fc-list :lang=zh-cn`, {
@@ -140,28 +280,45 @@ app.get('/debug/chrome', async (req, res) => {
140
280
  error: 'fontconfig not available or no Chinese fonts found'
141
281
  };
142
282
  }
143
-
283
+
144
284
  res.json(results);
145
285
  });
146
286
 
147
287
  /**
148
288
  * Resolve game URL from manifest bundleId or config
289
+ * Now supports dynamic bundle downloading
149
290
  */
150
- function resolveGameUrl(manifest: ReplayManifestV1, configGameUrl?: string): string {
291
+ async function resolveGameUrl(
292
+ manifest: { bundleId?: string; bundleUrl?: string; [key: string]: unknown },
293
+ config?: { gameUrl?: string; bundleUrl?: string }
294
+ ): Promise<string> {
151
295
  // 1. Explicit gameUrl in config takes precedence
152
- if (configGameUrl) {
153
- return configGameUrl;
296
+ if (config?.gameUrl) {
297
+ return config.gameUrl;
154
298
  }
155
-
299
+
156
300
  // 2. Use bundleId from manifest to serve from local bundles
157
301
  if (manifest.bundleId) {
158
- const bundlePath = path.join(BUNDLES_DIR, manifest.bundleId);
159
- if (!fs.existsSync(bundlePath)) {
160
- throw new Error(`Bundle not found: ${manifest.bundleId}`);
302
+ // Get bundleUrl from manifest or config
303
+ const bundleUrl = manifest.bundleUrl || config?.bundleUrl;
304
+
305
+ // Ensure bundle is available (download if needed)
306
+ try {
307
+ await bundleManager.ensureBundle(manifest.bundleId, bundleUrl);
308
+ } catch (error) {
309
+ if (error instanceof BundleManagerError && error.code === 'BUNDLE_URL_REQUIRED') {
310
+ // Bundle not cached and no URL provided - check if it exists locally
311
+ if (!bundleManager.isCached(manifest.bundleId)) {
312
+ throw new Error(`Bundle ${manifest.bundleId} not found and no bundleUrl provided`);
313
+ }
314
+ } else {
315
+ throw error;
316
+ }
161
317
  }
318
+
162
319
  return `http://localhost:${PORT}/bundles/${manifest.bundleId}/`;
163
320
  }
164
-
321
+
165
322
  // 3. Fall back to default GAME_URL
166
323
  return GAME_URL;
167
324
  }
@@ -175,6 +332,7 @@ interface RenderJob {
175
332
  error: string | null;
176
333
  createdAt: Date;
177
334
  completedAt: Date | null;
335
+ bundleId?: string;
178
336
  }
179
337
 
180
338
  const jobs = new Map<string, RenderJob>();
@@ -192,7 +350,13 @@ app.post('/render', async (req, res) => {
192
350
  const { manifest, manifestUrl, config } = req.body;
193
351
 
194
352
  // Get manifest from body or URL
195
- let replayManifest: ReplayManifestV1;
353
+ let replayManifest: {
354
+ schema: string;
355
+ bundleId?: string;
356
+ bundleUrl?: string;
357
+ gameId: string;
358
+ [key: string]: unknown;
359
+ };
196
360
 
197
361
  if (manifest) {
198
362
  replayManifest = manifest;
@@ -208,10 +372,17 @@ app.post('/render', async (req, res) => {
208
372
  return res.status(400).json({ error: 'Either manifest or manifestUrl is required' });
209
373
  }
210
374
 
211
- // Validate manifest
212
- if (replayManifest.schema !== 'lifeRestart.replay.v1') {
213
- console.error('[POST /render] Invalid manifest schema:', replayManifest.schema);
214
- return res.status(400).json({ error: 'Invalid manifest schema' });
375
+ // Validate manifest - basic structure only (schema-agnostic)
376
+ console.log('[POST /render] Manifest schema:', replayManifest.schema);
377
+
378
+ if (!replayManifest.schema || typeof replayManifest.schema !== 'string') {
379
+ console.error('[POST /render] Missing or invalid schema field');
380
+ return res.status(400).json({ error: 'Manifest must have a schema field' });
381
+ }
382
+
383
+ if (!replayManifest.gameId || typeof replayManifest.gameId !== 'string') {
384
+ console.error('[POST /render] Missing or invalid gameId field');
385
+ return res.status(400).json({ error: 'Manifest must have a gameId field' });
215
386
  }
216
387
 
217
388
  // Create job
@@ -226,20 +397,27 @@ app.post('/render', async (req, res) => {
226
397
  error: null,
227
398
  createdAt: new Date(),
228
399
  completedAt: null,
400
+ bundleId: replayManifest.bundleId,
229
401
  };
230
402
 
231
403
  jobs.set(jobId, job);
232
404
 
233
- // Resolve game URL from bundleId or config
405
+ // Resolve game URL from bundleId or config (async - may download bundle)
234
406
  let gameUrl: string;
235
407
  try {
236
- gameUrl = resolveGameUrl(replayManifest, config?.gameUrl);
408
+ gameUrl = await resolveGameUrl(replayManifest, config);
237
409
  } catch (error) {
238
410
  const errorMessage = error instanceof Error ? error.message : String(error);
239
411
  console.error('[POST /render] Failed to resolve game URL:', errorMessage);
412
+ jobs.delete(jobId);
240
413
  return res.status(400).json({ error: errorMessage });
241
414
  }
242
415
 
416
+ // Mark bundle as in use during rendering
417
+ if (replayManifest.bundleId) {
418
+ bundleManager.markInUse(replayManifest.bundleId);
419
+ }
420
+
243
421
  // Start rendering in background
244
422
  processJob(job, replayManifest, {
245
423
  gameUrl,
@@ -340,18 +518,15 @@ app.get('/download/:jobId', (req, res) => {
340
518
  * Health check endpoint
341
519
  */
342
520
  app.get('/health', (req, res) => {
343
- // Count available bundles
344
- let bundleCount = 0;
345
- if (fs.existsSync(BUNDLES_DIR)) {
346
- const entries = fs.readdirSync(BUNDLES_DIR, { withFileTypes: true });
347
- bundleCount = entries.filter(e => e.isDirectory()).length;
348
- }
521
+ const cacheStats = bundleManager.getCacheStats();
349
522
 
350
523
  res.json({
351
524
  status: 'ok',
352
525
  gameUrl: GAME_URL,
353
526
  bundlesDir: BUNDLES_DIR,
354
- bundleCount,
527
+ bundleCount: cacheStats.bundleCount,
528
+ cache: cacheStats,
529
+ bffBaseUrl: BFF_BASE_URL,
355
530
  });
356
531
  });
357
532
 
@@ -360,7 +535,7 @@ app.get('/health', (req, res) => {
360
535
  */
361
536
  async function processJob(
362
537
  job: RenderJob,
363
- manifest: ReplayManifestV1,
538
+ manifest: { schema: string; gameId: string; [key: string]: unknown },
364
539
  config: {
365
540
  gameUrl: string;
366
541
  outputPath: string;
@@ -395,6 +570,11 @@ async function processJob(
395
570
  }
396
571
 
397
572
  job.completedAt = new Date();
573
+
574
+ // Mark bundle as no longer in use
575
+ if (job.bundleId) {
576
+ bundleManager.markNotInUse(job.bundleId);
577
+ }
398
578
  }
399
579
 
400
580
  // Start server
@@ -403,5 +583,6 @@ app.listen(PORT, () => {
403
583
  console.log(`📺 Default Game URL: ${GAME_URL}`);
404
584
  console.log(`📦 Bundles directory: ${BUNDLES_DIR}`);
405
585
  console.log(`📁 Output directory: ${OUTPUT_DIR}`);
586
+ console.log(`🔗 BFF Base URL: ${BFF_BASE_URL}`);
587
+ console.log(`💾 Max cache size: ${(MAX_CACHE_SIZE / 1024 / 1024 / 1024).toFixed(2)} GB`);
406
588
  });
407
-