/** Read-only access to the raw document archive (MinIO/S3). Keys are `raw///..zst|gz`. */ import { S3Client, GetObjectCommand, HeadBucketCommand } from "@aws-sdk/client-s3"; import * as zlib from "node:zlib"; import { getEnv } from "./env.js"; let _client: S3Client | null = null; export function getS3(): S3Client { if (_client) return _client; const env = getEnv(); _client = new S3Client({ endpoint: env.s3Endpoint, region: env.s3Region, forcePathStyle: true, credentials: { accessKeyId: env.s3AccessKey, secretAccessKey: env.s3SecretKey }, requestHandler: { requestTimeout: 20_000, connectionTimeout: 3_000 } }); return _client; } const zstd = zlib as unknown as { zstdDecompressSync?: (b: Buffer) => Buffer }; /** Decompress by key suffix; when the suffix lies, try zstd then gzip then raw. */ export function decompress(key: string, body: Buffer): Buffer { const tryZstd = () => (zstd.zstdDecompressSync ? zstd.zstdDecompressSync(body) : null); const tryGz = () => zlib.gunzipSync(body); if (key.endsWith(".zst")) { try { const r = tryZstd(); if (r) return r; } catch { /* fall through */ } try { return tryGz(); } catch { return body; } } if (key.endsWith(".gz")) { try { return tryGz(); } catch { try { const r = tryZstd(); if (r) return r; } catch { /* */ } return body; } } try { const r = tryZstd(); if (r) return r; } catch { /* */ } try { return tryGz(); } catch { return body; } } export async function getRawObject(key: string, maxBytes = 5 * 1024 * 1024): Promise<{ body: Buffer; contentType: string | null; truncated: boolean } | null> { try { const r = await getS3().send(new GetObjectCommand({ Bucket: getEnv().s3Bucket, Key: key })); const bytes = r.Body ? Buffer.from(await r.Body.transformToByteArray()) : Buffer.alloc(0); const full = decompress(key, bytes); const truncated = full.length > maxBytes; return { body: truncated ? full.subarray(0, maxBytes) : full, contentType: r.Metadata?.["content-type"] ?? null, truncated }; } catch (e) { const err = e as { name?: string; $metadata?: { httpStatusCode?: number } }; if (err.name === "NoSuchKey" || err.name === "NotFound" || err.$metadata?.httpStatusCode === 404) return null; throw e; } } export async function storageOk(): Promise { try { await Promise.race([getS3().send(new HeadBucketCommand({ Bucket: getEnv().s3Bucket })), new Promise((_, rej) => setTimeout(() => rej(new Error("timeout")), 2_500))]); return true; } catch { return false; } }