import { BadRequestException, Inject, Injectable, NotFoundException, } from '@nestjs/common'; import { customAlphabet } from 'nanoid'; import type Redis from 'ioredis'; import { VALKEY } from '../valkey.provider'; // URL-safe, unambiguous-ish alphabet (no 0/O/1/I/l). 8 chars = 38^8 ≈ 4.4e12 codes. const nanoid = customAlphabet( '23456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz', 8, ); const MAX_BYTES = 16 * 1024; // 16 KB cap per shared build const TTL_SECONDS = 60 * 60 * 24 * 365; // 1 year @Injectable() export class BuildsService { constructor(@Inject(VALKEY) private readonly redis: Redis) {} async save(payload: unknown): Promise<{ code: string }> { const json = JSON.stringify(payload); if (!json || json === 'null') { throw new BadRequestException('empty build'); } if (Buffer.byteLength(json, 'utf8') > MAX_BYTES) { throw new BadRequestException(`build too large (>${MAX_BYTES} bytes)`); } // Up to 5 retries on collision. for (let i = 0; i < 5; i++) { const code = nanoid(); const key = `build:${code}`; const ok = await this.redis.set(key, json, 'EX', TTL_SECONDS, 'NX'); if (ok === 'OK') return { code }; } throw new BadRequestException('failed to allocate code'); } async load(code: string): Promise { if (!/^[2-9A-HJ-NP-Za-km-z]{8}$/.test(code)) { throw new BadRequestException('invalid code'); } const json = await this.redis.get(`build:${code}`); if (!json) throw new NotFoundException('build not found'); // refresh TTL so popular builds stay alive await this.redis.expire(`build:${code}`, TTL_SECONDS); try { return JSON.parse(json); } catch { throw new BadRequestException('corrupt build'); } } }