import { mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises'; import path from 'node:path'; import process from 'node:process'; type StaticAssetManifestItem = { url: string; target: string; }; type StaticAssetManifestGroup = { target_dir: string; files: Array<{ url: string; filename: string; }>; }; type StaticAssetConfig = { rootDir: string; manifestPath: string; strict: boolean; requestTimeoutMs: number; }; async function main() { const config = loadConfig(process.cwd()); const items = await readManifest(config.manifestPath); console.log('[sync-static-assets] start'); console.log(`[sync-static-assets] manifest: ${config.manifestPath}`); if (items.length === 0) { console.log('[sync-static-assets] no assets configured, skip'); return; } let failureCount = 0; for (const item of items) { try { await syncAsset(item, config); } catch (error) { failureCount += 1; const message = error instanceof Error ? error.message : String(error); if (config.strict) { throw new Error(`[sync-static-assets] failed: ${message}`); } console.warn(`[sync-static-assets] skip failed asset: ${message}`); } } console.log( `[sync-static-assets] done: total=${items.length} failed=${failureCount} strict=${config.strict}`, ); } function loadConfig(rootDir: string): StaticAssetConfig { return { rootDir, manifestPath: resolveFromRoot( rootDir, process.env.STATIC_ASSET_MANIFEST ?? 'src/content/static-assets/index.json', ), strict: getBooleanEnv('STATIC_ASSET_SYNC_STRICT', false), requestTimeoutMs: getNumberEnv('STATIC_ASSET_REQUEST_TIMEOUT_MS', 15000), }; } async function readManifest(filePath: string): Promise { try { const raw = await readFile(filePath, 'utf-8'); const parsed = JSON.parse(raw) as unknown; if (!Array.isArray(parsed)) { throw new Error('manifest must be a JSON array'); } return parsed.flatMap((item, index) => normalizeManifestEntry(item, index)); } catch (error) { if (isMissingFileError(error)) { return []; } throw error; } } function normalizeManifestEntry(item: unknown, index: number): StaticAssetManifestItem[] { if (!item || typeof item !== 'object') { throw new Error(`manifest item #${index + 1} must be an object`); } const record = item as Record; const targetDir = typeof record.target_dir === 'string' ? record.target_dir.trim() : ''; const files = Array.isArray(record.files) ? record.files : null; if (targetDir || files) { return normalizeManifestGroup({ target_dir: targetDir, files: files ?? [] }, index); } return [normalizeLegacyManifestItem(record, index)]; } function normalizeLegacyManifestItem( record: Record, index: number, ): StaticAssetManifestItem { const url = typeof record.url === 'string' ? record.url.trim() : ''; const target = typeof record.target === 'string' ? record.target.trim() : ''; if (!url) { throw new Error(`manifest item #${index + 1} is missing url`); } if (!target) { throw new Error(`manifest item #${index + 1} is missing target`); } return { url, target }; } function normalizeManifestGroup( group: StaticAssetManifestGroup, index: number, ): StaticAssetManifestItem[] { const targetDir = group.target_dir.trim(); if (!targetDir) { throw new Error(`manifest group #${index + 1} is missing target_dir`); } if (!Array.isArray(group.files)) { throw new Error(`manifest group #${index + 1} files must be an array`); } return group.files.map((item, fileIndex) => { if (!item || typeof item !== 'object') { throw new Error(`manifest group #${index + 1} file #${fileIndex + 1} must be an object`); } const record = item as Record; const url = typeof record.url === 'string' ? record.url.trim() : ''; const filename = typeof record.filename === 'string' ? record.filename.trim() : ''; if (!url) { throw new Error(`manifest group #${index + 1} file #${fileIndex + 1} is missing url`); } if (!filename) { throw new Error( `manifest group #${index + 1} file #${fileIndex + 1} is missing filename`, ); } return { url, target: path.posix.join(targetDir.replace(/\\/g, '/'), filename), }; }); } async function syncAsset(item: StaticAssetManifestItem, config: StaticAssetConfig) { const targetPath = ensurePathInsideRoot(config.rootDir, item.target); await mkdir(path.dirname(targetPath), { recursive: true }); const tempPath = `${targetPath}.tmp`; const response = await fetchWithTimeout(item.url, config.requestTimeoutMs); try { const arrayBuffer = await response.arrayBuffer(); const buffer = Buffer.from(arrayBuffer); await writeFile(tempPath, buffer); await rename(tempPath, targetPath); console.log( `[sync-static-assets] wrote ${path.relative(config.rootDir, targetPath)} <- ${item.url}`, ); } catch (error) { await safeRemove(tempPath); throw error; } } function ensurePathInsideRoot(rootDir: string, target: string) { const resolvedRoot = path.resolve(rootDir); const resolvedTarget = path.resolve(rootDir, target); const relative = path.relative(resolvedRoot, resolvedTarget); if (relative.startsWith('..') || path.isAbsolute(relative)) { throw new Error(`target path escapes repo root: ${target}`); } return resolvedTarget; } async function fetchWithTimeout(url: string, timeoutMs: number) { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), timeoutMs); try { const response = await fetch(url, { method: 'GET', signal: controller.signal, }); if (!response.ok) { const body = await safeReadText(response); throw new Error(`HTTP ${response.status} ${response.statusText}${body ? ` - ${body}` : ''}`); } return response; } finally { clearTimeout(timeout); } } function getBooleanEnv(name: string, fallback: boolean) { const value = process.env[name]; if (value == null || value.trim() === '') { return fallback; } return ['1', 'true', 'yes', 'on'].includes(value.trim().toLowerCase()); } function getNumberEnv(name: string, fallback: number) { const value = process.env[name]; if (value == null || value.trim() === '') { return fallback; } const parsed = Number(value); return Number.isFinite(parsed) ? parsed : fallback; } function resolveFromRoot(rootDir: string, targetPath: string) { return path.isAbsolute(targetPath) ? targetPath : path.join(rootDir, targetPath); } function isMissingFileError(error: unknown) { return typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT'; } async function safeReadText(response: Response) { try { return (await response.text()).slice(0, 300); } catch { return ''; } } async function safeRemove(filePath: string) { try { await unlink(filePath); } catch { // Ignore temp cleanup errors. } } main().catch((error) => { console.error('[sync-static-assets] failed'); console.error(error); process.exitCode = 1; });