268 lines
7.1 KiB
TypeScript
268 lines
7.1 KiB
TypeScript
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<StaticAssetManifestItem[]> {
|
|
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<string, unknown>;
|
|
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<string, unknown>,
|
|
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<string, unknown>;
|
|
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;
|
|
});
|