Files
dotcom-arcade/docs/05-game-service/zip-manager.md
T

20 KiB
Raw Blame History

ZIP Manager

The ZIP Manager provides zero-extraction ZIP archive access using node-stream-zip, enabling efficient game file serving without disk space overhead.

Overview

The ZIP Manager mounts ZIP archives in-memory and provides streaming access to files without extracting them to disk. This approach is ideal for serving game content from compressed archives while conserving disk space.

Architecture

┌────────────────────────────────────────────┐
│         ZipManager (Singleton)             │
├────────────────────────────────────────────┤
│  mountedZips: Map<id, MountedZip>         │
│                                            │
│  mount(id, zipPath)                        │
│  unmount(id)                               │
│  getFile(id, filePath)                     │
│  findFile(relPath)                         │
│  listFiles(id, pattern)                    │
│  unmountAll()                              │
└────────────────────────────────────────────┘
          ↓
┌────────────────────────────────────────────┐
│        node-stream-zip Library             │
├────────────────────────────────────────────┤
│  StreamZip.async({ file: zipPath })       │
│  zip.entries()                             │
│  zip.entryData(path)                       │
│  zip.close()                               │
└────────────────────────────────────────────┘
          ↓
┌────────────────────────────────────────────┐
│         Physical ZIP Files                 │
│  D:/Flashpoint/Data/Games/Flash/G/*.zip   │
└────────────────────────────────────────────┘

Data Structures

MountedZip Interface

interface MountedZip {
  id: string; // Unique mount identifier (e.g., "game-123")
  zipPath: string; // Absolute path to ZIP file
  zip: StreamZip.StreamZipAsync; // node-stream-zip instance
  mountTime: Date; // When ZIP was mounted
}

ZipManager Class

export class ZipManager {
  private mountedZips: Map<string, MountedZip> = new Map();

  async mount(id: string, zipPath: string): Promise<void>;
  async unmount(id: string): Promise<boolean>;
  async getFile(id: string, filePath: string): Promise<Buffer | null>;
  async findFile(
    relPath: string
  ): Promise<{ data: Buffer; mountId: string } | null>;
  async listFiles(id: string, pattern?: string): Promise<string[]>;
  isMounted(id: string): boolean;
  getMountedZips(): Array<{ id; zipPath; mountTime; fileCount }>;
  async unmountAll(): Promise<void>;
}

Mounting ZIPs

Mount Process

async mount(id: string, zipPath: string): Promise<void> {
  // 1. Check if already mounted
  if (this.mountedZips.has(id)) {
    logger.debug(`ZIP already mounted: ${id}`);
    return; // Silent success
  }

  // 2. Verify ZIP file exists
  try {
    await fs.access(zipPath);
  } catch (error) {
    throw new Error(`ZIP file not found: ${zipPath}`);
  }

  logger.info(`Mounting ZIP: ${id} -> ${zipPath}`);

  // 3. Create StreamZip instance
  const zip = new StreamZip.async({ file: zipPath });

  // 4. Store in map
  this.mountedZips.set(id, {
    id,
    zipPath,
    zip,
    mountTime: new Date()
  });

  // 5. Log file count
  const fileCount = await this.getFileCount(id);
  logger.info(`✓ Mounted ZIP: ${id} (${fileCount} files)`);
}

Mount Examples

// Mount by game ID
await zipManager.mount('game-123', 'D:/Flashpoint/Data/Games/Flash/G/game.zip');

// Mount with custom ID
await zipManager.mount('custom-archive', 'D:/Custom/archive.zip');

// Mount multiple ZIPs
await zipManager.mount(
  'game-456',
  'D:/Flashpoint/Data/Games/Flash/A/another.zip'
);
await zipManager.mount(
  'game-789',
  'D:/Flashpoint/Data/Games/Unity/U/unity-game.zip'
);

Duplicate Mount Handling

If ZIP is already mounted, mount() returns silently without error:

await zipManager.mount('game-123', 'path/to/game.zip');
await zipManager.mount('game-123', 'path/to/game.zip'); // Silent success, no-op

Rationale: Idempotent operations simplify backend logic - no need to check mount status before mounting.

Unmounting ZIPs

Unmount Process

async unmount(id: string): Promise<boolean> {
  const mounted = this.mountedZips.get(id);
  if (!mounted) {
    logger.warn(`ZIP not mounted: ${id}`);
    return false;
  }

  logger.info(`Unmounting ZIP: ${id}`);

  try {
    // Close ZIP handle
    await mounted.zip.close();

    // Remove from map
    this.mountedZips.delete(id);

    logger.info(`✓ Unmounted ZIP: ${id}`);
    return true;
  } catch (error) {
    logger.error(`Error unmounting ZIP: ${id}`, error);
    return false;
  }
}

Unmount Examples

// Unmount specific ZIP
const success = await zipManager.unmount('game-123');
if (success) {
  console.log('Unmounted successfully');
}

// Unmount all ZIPs (on shutdown)
await zipManager.unmountAll();

Unmount All

Unmount all mounted ZIPs, typically called on server shutdown:

async unmountAll(): Promise<void> {
  logger.info(`Unmounting all ZIPs (${this.mountedZips.size})...`);

  const promises = Array.from(this.mountedZips.keys()).map(id => this.unmount(id));
  await Promise.all(promises);

  logger.info('✓ All ZIPs unmounted');
}

Called in server shutdown handler:

process.on('SIGTERM', async () => {
  await gameZipServer.stop(); // Calls zipManager.unmountAll()
  process.exit(0);
});

File Access

Get File from Specific ZIP

async getFile(id: string, filePath: string): Promise<Buffer | null> {
  const mounted = this.mountedZips.get(id);
  if (!mounted) {
    logger.debug(`ZIP not mounted: ${id}`);
    return null;
  }

  try {
    // Normalize path (remove leading slash)
    const normalizedPath = filePath.startsWith('/')
      ? filePath.substring(1)
      : filePath;

    logger.debug(`Reading from ZIP ${id}: ${normalizedPath}`);

    // Get file data from ZIP (streaming, no extraction)
    const data = await mounted.zip.entryData(normalizedPath);

    logger.debug(`✓ Read ${data.length} bytes from ${id}:${normalizedPath}`);

    return data;
  } catch (error) {
    // File not found in ZIP
    logger.debug(`File not found in ${id}: ${filePath}`);
    return null;
  }
}

Performance Improvement - findFile() Refactoring:

The findFile() method has been optimized for single-pass lookup:

  • Entries are read directly without redundant delegation through getFile()
  • Process: index check → entry existence check → size check → read
  • Eliminates intermediate function calls and extra lookups
  • Result: Faster file discovery with fewer function invocations

File Access Examples

// Get specific file
const data = await zipManager.getFile(
  'game-123',
  'content/www.example.com/game.swf'
);
if (data) {
  console.log(`Read ${data.length} bytes`);
}

// Try multiple paths
const paths = ['content/file.swf', 'htdocs/file.swf', 'file.swf'];
for (const path of paths) {
  const data = await zipManager.getFile('game-123', path);
  if (data) {
    console.log(`Found at: ${path}`);
    break;
  }
}

Find File Across All ZIPs

async findFile(relPath: string): Promise<{data: Buffer, mountId: string} | null> {
  // Path variations to try (in order)
  const pathsToTry = [
    `content/${relPath}`,       // Most common structure
    `htdocs/${relPath}`,        // Standard structure
    relPath,                     // No prefix
    `Legacy/htdocs/${relPath}`, // Full Flashpoint path
  ];

  // Search all mounted ZIPs
  for (const [id, mounted] of this.mountedZips) {
    for (const pathVariant of pathsToTry) {
      const data = await this.getFile(id, pathVariant);
      if (data) {
        logger.info(`✓ Found in ${id}: ${pathVariant}`);
        return { data, mountId: id };
      }
    }
  }

  return null;
}

Path Variation Strategy

Different ZIPs may have different internal structures:

Type 1: Content prefix

game.zip
  └── content/
      └── www.example.com/
          └── game.swf

Type 2: Htdocs prefix

game.zip
  └── htdocs/
      └── www.example.com/
          └── game.swf

Type 3: No prefix

game.zip
  └── www.example.com/
      └── game.swf

Type 4: Full path

game.zip
  └── Legacy/
      └── htdocs/
          └── www.example.com/
              └── game.swf

The findFile() method tries all variations automatically.

Search Performance

Complexity: O(m × p) where:

  • m = number of mounted ZIPs
  • p = number of path variations (4)

Typical case:

  • 10 ZIPs mounted
  • 4 path variations
  • First match on average: 2nd variation
  • Total attempts: ~20
  • Time: <10ms

Worst case:

  • 100 ZIPs mounted
  • File not found
  • Total attempts: 400
  • Time: ~100ms

Optimization (future): Index files on mount, use hash table lookup

File Listing

List Files in ZIP

async listFiles(id: string, pattern?: string): Promise<string[]> {
  const mounted = this.mountedZips.get(id);
  if (!mounted) return [];

  try {
    // Get all entries
    const entries = await mounted.zip.entries();
    let files = Object.keys(entries);

    // Apply pattern filter if provided
    if (pattern) {
      const regex = new RegExp(pattern, 'i');
      files = files.filter(f => regex.test(f));
    }

    return files;
  } catch (error) {
    logger.error(`Error listing files in ${id}:`, error);
    return [];
  }
}

Listing Examples

// List all files
const allFiles = await zipManager.listFiles('game-123');
console.log(`Total files: ${allFiles.length}`);

// List SWF files
const swfFiles = await zipManager.listFiles('game-123', '\\.swf$');
console.log(`SWF files: ${swfFiles.length}`);

// List files in specific directory
const contentFiles = await zipManager.listFiles(
  'game-123',
  '^content/www\\.example\\.com/'
);
console.log(`Content files: ${contentFiles.length}`);

Performance Considerations

entries() Cost:

  • First call: Loads ZIP index (~10-100ms)
  • Cached internally by node-stream-zip
  • Subsequent calls: O(1) hash table access

Memory Usage:

  • Small ZIP (100 files): ~1MB for index
  • Medium ZIP (1,000 files): ~5MB
  • Large ZIP (10,000 files): ~50MB
  • Very large ZIP (100,000 files): ~500MB

Recommendation: Don't list files on every request, use sparingly for debugging.

Mount Status

Check if ZIP is Mounted

isMounted(id: string): boolean {
  return this.mountedZips.has(id);
}

Get Mounted ZIP Info

getMountedZips(): Array<{id, zipPath, mountTime, fileCount}> {
  const result = [];

  for (const [id, mounted] of this.mountedZips) {
    result.push({
      id,
      zipPath: mounted.zipPath,
      mountTime: mounted.mountTime,
      fileCount: 0 // Populated async, 0 for now
    });
  }

  return result;
}

Status Examples

// Check mount status
if (zipManager.isMounted('game-123')) {
  console.log('ZIP is mounted');
}

// Get all mounts
const mounts = zipManager.getMountedZips();
console.log(`${mounts.length} ZIPs mounted`);

for (const mount of mounts) {
  console.log(`- ${mount.id}: ${mount.zipPath}`);
  console.log(`  Mounted: ${mount.mountTime}`);
}

Streaming vs. Extraction

Streaming (Current Approach)

Process:

  1. Open ZIP file handle
  2. Read central directory index
  3. Locate file entry
  4. Seek to file position
  5. Read compressed data
  6. Decompress in memory
  7. Return Buffer

Advantages:

  • No disk space used for extraction
  • Fast for small files (<1MB)
  • No cleanup required
  • Multiple files accessed independently

Disadvantages:

  • Each access requires decompression
  • CPU overhead for deflate
  • Memory spike for large files
  • Random access slower than sequential

Extraction (Alternative)

Process:

  1. Extract entire ZIP to disk
  2. Read files from extracted directory
  3. Cleanup on unmount

Advantages:

  • Fast sequential access
  • No decompression overhead
  • Better for large files
  • OS filesystem cache benefits

Disadvantages:

  • Requires disk space (2x ZIP size)
  • Extraction time on mount
  • Cleanup complexity
  • Disk I/O overhead

When to Use Each

Streaming (recommended for):

  • Many small files
  • Random access patterns
  • Limited disk space
  • Short-lived access

Extraction (recommended for):

  • Few large files
  • Sequential access patterns
  • Abundant disk space
  • Long-lived access

Memory Management

Memory Usage Breakdown

Per mounted ZIP:

  • ZIP handle: ~1KB
  • File index: ~5KB per 100 files
  • Example: 1,000 files = ~50KB

Per file access:

  • File buffer: File size in memory
  • Decompression buffer: ~2x file size temporarily
  • Example: 1MB file = ~3MB peak memory

Total with 10 ZIPs (10,000 files each), 5 concurrent requests (1MB each):

  • ZIP indexes: 10 × 500KB = 5MB
  • File buffers: 5 × 1MB = 5MB
  • Decompression: 5 × 1MB = 5MB
  • Total: ~15MB

Memory Limits

Current implementation: No limits

Recommended limits (future):

const MAX_MOUNTED_ZIPS = 100;
const MAX_FILE_SIZE = 100 * 1024 * 1024; // 100MB
const MAX_CONCURRENT_READS = 50;

Memory Monitoring

Monitor memory usage:

const usage = process.memoryUsage();
console.log(`Heap used: ${(usage.heapUsed / 1024 / 1024).toFixed(2)} MB`);
console.log(`Heap total: ${(usage.heapTotal / 1024 / 1024).toFixed(2)} MB`);
console.log(`External: ${(usage.external / 1024 / 1024).toFixed(2)} MB`);

Error Handling

Mount Errors

ZIP file not found:

await zipManager.mount('game-123', '/invalid/path.zip');
// Throws: Error: ZIP file not found: /invalid/path.zip

ZIP corrupted:

await zipManager.mount('game-123', 'corrupted.zip');
// Throws: Error from node-stream-zip

Permission denied:

await zipManager.mount('game-123', 'no-permission.zip');
// Throws: EACCES: permission denied

File Access Errors

File not in ZIP:

const data = await zipManager.getFile('game-123', 'missing.swf');
// Returns: null (not an error)

ZIP not mounted:

const data = await zipManager.getFile('not-mounted', 'file.swf');
// Returns: null (not an error)

File too large:

const data = await zipManager.getFile('game-123', 'huge-file.bin');
// Returns: null if file > 50MB (prevents memory exhaustion)

Unmount Errors

ZIP not mounted:

const success = await zipManager.unmount('not-mounted');
// Returns: false (not an error)

Unmount failed:

const success = await zipManager.unmount('game-123');
// Returns: false, logs error

Testing

Testing with Backend

ZipManager is not directly accessible. Test through the backend proxy:

# 1. Start backend
cd backend && npm run dev

# 2. Request file through proxy (GameZip is called internally)
curl http://localhost:3100/game-proxy/http://www.example.com/file.swf

# Backend logs will show ZipManager operations

For unit testing, test GameZip methods directly:

Unit Testing (Future)

describe('ZipManager', () => {
  it('should mount ZIP successfully', async () => {
    await zipManager.mount('test', 'test.zip');
    expect(zipManager.isMounted('test')).toBe(true);
  });

  it('should return null for missing file', async () => {
    const data = await zipManager.getFile('test', 'missing.swf');
    expect(data).toBeNull();
  });

  it('should find file with path variation', async () => {
    const result = await zipManager.findFile('www.example.com/file.swf');
    expect(result).not.toBeNull();
    expect(result!.mountId).toBe('test');
  });

  it('should unmount ZIP successfully', async () => {
    const success = await zipManager.unmount('test');
    expect(success).toBe(true);
    expect(zipManager.isMounted('test')).toBe(false);
  });
});

Logging

Mount/Unmount

[ZipManager] Mounting ZIP: game-123 -> D:/Flashpoint/Data/Games/game.zip
[ZipManager] ✓ Mounted ZIP: game-123 (1234 files)
[ZipManager] Unmounting ZIP: game-123
[ZipManager] ✓ Unmounted ZIP: game-123

File Access

[ZipManager] Reading from ZIP game-123: content/www.example.com/game.swf
[ZipManager] ✓ Read 123456 bytes from game-123:content/www.example.com/game.swf
[ZipManager] ✓ Found in game-123: content/www.example.com/game.swf

Errors

[ZipManager] ZIP not mounted: game-999
[ZipManager] File not found in game-123: missing.swf
[ZipManager] Error unmounting ZIP: game-123 [Error: ...]

Performance Benchmarks

Typical performance on modern SSD:

Mount time:

  • Small ZIP (<100 files): 10-50ms
  • Medium ZIP (<1,000 files): 50-200ms
  • Large ZIP (<10,000 files): 200-1000ms
  • Very large ZIP (>10,000 files): 1-5 seconds

File access time:

  • Small file (<100KB): 2-5ms
  • Medium file (<1MB): 5-20ms
  • Large file (<10MB): 20-100ms
  • Very large file (>10MB): 100-500ms

Memory usage:

  • Per ZIP: 50KB - 500KB (index)
  • Per file: File size + decompression overhead

Race Condition Protection

The ZIP manager prevents concurrent mount race conditions through the mountingInProgress Set:

private mountingInProgress = new Set<string>();

async mount(id: string, zipPath: string): Promise<void> {
  // Check if already mounting
  if (this.mountingInProgress.has(id)) {
    logger.debug(`ZIP mount already in progress: ${id}`);
    return;
  }

  this.mountingInProgress.add(id);
  try {
    // Mount the ZIP
  } finally {
    this.mountingInProgress.delete(id);
  }
}

Protection: Prevents duplicate file handles from being created when multiple concurrent mount requests arrive for the same ZIP. The second request returns immediately instead of creating redundant ZipManager entries.

ZIP Entry Size Limits

The getFile() method checks entry size before reading to prevent memory exhaustion:

const entry = entries[normalizedPath];
if (entry && entry.size > 50 * 1024 * 1024) {
  logger.warn(`File too large to buffer: ${normalizedPath} (${entry.size} bytes)`);
  return null;
}

Limit: Files exceeding 50MB are rejected.

Rationale: Large files are buffered entirely into memory. Without limits, malicious or misconfigured ZIPs could exhaust available memory.

Future: Implement streaming for large files instead of buffering.

Future Enhancements

  1. Auto-Unmount: Unmount inactive ZIPs after timeout (already implemented via LRU TTL)
  2. LRU Cache: Cache frequently accessed files in memory
  3. Streaming API: Stream large files instead of loading into memory
  4. Compression Detection: Skip decompression for stored (uncompressed) files
  5. Health Monitoring: Track memory usage, cache hit rate
  6. Metrics: Expose mount count, file access count, cache hit rate
  7. Pre-Warming: Mount popular ZIPs on startup

References