c1adb94abb
CI has been running `prettier --check` against a tree that was never formatted, so the check reported 63 files and failed every run. Nothing here is a behaviour change: `pnpm lint` and the four typecheck builds pass exactly as before. conduit-bringup-artifacts is added to .prettierignore instead. Those files are captured bring-up reports, not maintained sources; reflowing them would only churn a record of what happened.
196 lines
5.5 KiB
TypeScript
196 lines
5.5 KiB
TypeScript
import { CdnClient, CdnError, type FileInfo } from '@source/cdn';
|
|
import { AppError } from './errors.js';
|
|
|
|
const DEFAULT_PLUGIN_BUCKET = 'gamepanel-plugin-artifacts';
|
|
const DEFAULT_ARTIFACT_ACCESS_TTL_SECONDS = 900;
|
|
const ARTIFACT_POINTER_PREFIX = 'cdn://file/';
|
|
|
|
let cachedClient: CdnClient | null = null;
|
|
let cachedFingerprint: string | null = null;
|
|
|
|
function envValue(name: string): string | null {
|
|
const value = process.env[name];
|
|
if (!value) return null;
|
|
const trimmed = value.trim();
|
|
return trimmed.length > 0 ? trimmed : null;
|
|
}
|
|
|
|
function getCdnConfig(): { baseUrl: string; apiKey: string } | null {
|
|
const baseUrl = envValue('CDN_BASE_URL');
|
|
const apiKey = envValue('CDN_API_KEY');
|
|
if (!baseUrl || !apiKey) return null;
|
|
return { baseUrl, apiKey };
|
|
}
|
|
|
|
function getArtifactAccessTtlSeconds(): number {
|
|
const raw = Number(
|
|
process.env.CDN_PLUGIN_ARTIFACT_TTL_SECONDS ?? DEFAULT_ARTIFACT_ACCESS_TTL_SECONDS,
|
|
);
|
|
if (!Number.isFinite(raw) || raw <= 0) return DEFAULT_ARTIFACT_ACCESS_TTL_SECONDS;
|
|
return Math.floor(raw);
|
|
}
|
|
|
|
function getOrCreateClient(): CdnClient | null {
|
|
const config = getCdnConfig();
|
|
if (!config) return null;
|
|
|
|
const fingerprint = `${config.baseUrl}::${config.apiKey}`;
|
|
if (cachedClient && cachedFingerprint === fingerprint) return cachedClient;
|
|
|
|
cachedClient = new CdnClient({
|
|
baseUrl: config.baseUrl,
|
|
apiKey: config.apiKey,
|
|
timeoutMs: 45_000,
|
|
retry: {
|
|
retries: 2,
|
|
retryDelayMs: 250,
|
|
maxRetryDelayMs: 2_000,
|
|
},
|
|
});
|
|
cachedFingerprint = fingerprint;
|
|
return cachedClient;
|
|
}
|
|
|
|
function requireClient(): CdnClient {
|
|
const client = getOrCreateClient();
|
|
if (!client) {
|
|
throw new AppError(
|
|
500,
|
|
'CDN configuration is missing. Set CDN_BASE_URL and CDN_API_KEY.',
|
|
'CDN_NOT_CONFIGURED',
|
|
);
|
|
}
|
|
return client;
|
|
}
|
|
|
|
function toCdnAppError(error: unknown, fallbackMessage: string, fallbackCode: string): AppError {
|
|
if (error instanceof AppError) return error;
|
|
if (error instanceof CdnError) {
|
|
return new AppError(502, `CDN error: ${error.message}`, fallbackCode);
|
|
}
|
|
return new AppError(502, fallbackMessage, fallbackCode);
|
|
}
|
|
|
|
export function getPluginBucketName(): string {
|
|
return envValue('CDN_PLUGIN_BUCKET') ?? DEFAULT_PLUGIN_BUCKET;
|
|
}
|
|
|
|
export async function ensurePrivatePluginBucket(): Promise<string> {
|
|
const client = requireClient();
|
|
const bucketName = getPluginBucketName();
|
|
|
|
try {
|
|
const bucket = await client.getBucket(bucketName);
|
|
if (bucket.isPublic) {
|
|
await client.updateBucket(bucketName, { isPublic: false });
|
|
}
|
|
return bucketName;
|
|
} catch (error) {
|
|
if (error instanceof CdnError && error.statusCode === 404) {
|
|
try {
|
|
await client.createBucket(bucketName, {
|
|
description: 'GamePanel plugin artifacts',
|
|
isPublic: false,
|
|
});
|
|
return bucketName;
|
|
} catch (createError) {
|
|
throw toCdnAppError(
|
|
createError,
|
|
'Failed to create CDN plugin bucket',
|
|
'CDN_BUCKET_CREATE_FAILED',
|
|
);
|
|
}
|
|
}
|
|
|
|
throw toCdnAppError(error, 'Failed to fetch CDN plugin bucket', 'CDN_BUCKET_READ_FAILED');
|
|
}
|
|
}
|
|
|
|
export function buildCdnArtifactPointer(fileId: string): string {
|
|
return `${ARTIFACT_POINTER_PREFIX}${fileId}`;
|
|
}
|
|
|
|
export function parseCdnArtifactPointer(value: string): string | null {
|
|
const trimmed = value.trim();
|
|
if (!trimmed) return null;
|
|
|
|
if (trimmed.startsWith(ARTIFACT_POINTER_PREFIX)) {
|
|
const id = trimmed.slice(ARTIFACT_POINTER_PREFIX.length).trim();
|
|
return id.length > 0 ? id : null;
|
|
}
|
|
|
|
try {
|
|
const parsed = new URL(trimmed);
|
|
if (parsed.protocol === 'cdn:' && parsed.hostname === 'file') {
|
|
const candidate = parsed.pathname.replace(/^\/+/, '').trim();
|
|
return candidate.length > 0 ? candidate : null;
|
|
}
|
|
} catch {
|
|
return null;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
export async function uploadPluginArtifact(
|
|
content: Uint8Array,
|
|
filename: string,
|
|
metadata: Record<string, unknown> = {},
|
|
): Promise<{ bucket: string; file: FileInfo; artifactPointer: string }> {
|
|
const client = requireClient();
|
|
const bucket = await ensurePrivatePluginBucket();
|
|
|
|
try {
|
|
const file = await client.upload(content, {
|
|
bucket,
|
|
filename,
|
|
metadata,
|
|
});
|
|
|
|
return {
|
|
bucket,
|
|
file,
|
|
artifactPointer: buildCdnArtifactPointer(file.id),
|
|
};
|
|
} catch (error) {
|
|
throw toCdnAppError(error, 'Failed to upload artifact to CDN', 'CDN_UPLOAD_FAILED');
|
|
}
|
|
}
|
|
|
|
export async function resolveArtifactDownloadUrl(artifactUrl: string): Promise<string> {
|
|
const fileId = parseCdnArtifactPointer(artifactUrl);
|
|
if (!fileId) return artifactUrl;
|
|
|
|
const client = requireClient();
|
|
const config = getCdnConfig();
|
|
const ttl = getArtifactAccessTtlSeconds();
|
|
|
|
try {
|
|
const access = await client.getFileAccessUrl(fileId, ttl);
|
|
if (!access.url || typeof access.url !== 'string') {
|
|
throw new AppError(502, 'CDN access URL is empty', 'CDN_ACCESS_URL_EMPTY');
|
|
}
|
|
|
|
const resolvedUrl = access.url.trim();
|
|
if (!resolvedUrl) {
|
|
throw new AppError(502, 'CDN access URL is empty', 'CDN_ACCESS_URL_EMPTY');
|
|
}
|
|
|
|
if (/^https?:\/\//i.test(resolvedUrl)) {
|
|
return resolvedUrl;
|
|
}
|
|
|
|
if (!config) {
|
|
throw new AppError(
|
|
500,
|
|
'CDN configuration is missing. Set CDN_BASE_URL and CDN_API_KEY.',
|
|
'CDN_NOT_CONFIGURED',
|
|
);
|
|
}
|
|
|
|
return new URL(resolvedUrl, config.baseUrl).toString();
|
|
} catch (error) {
|
|
throw toCdnAppError(error, 'Failed to get temporary CDN access URL', 'CDN_ACCESS_URL_FAILED');
|
|
}
|
|
}
|