feat: optimize JSON sync with 1 API call + raw URLs
- Replace GitHub API calls (390+) with 1 API call + raw URL downloads - Create GitHubJsonService for efficient JSON file syncing - Reduce API rate limiting issues by 99.7% - Add automatic page reload after successful sync - Update tests to use new service - Maintain same functionality with better performance Performance improvement: - Before: 390+ GitHub API calls (1 per JSON file) - After: 1 GitHub API call + 389 raw URL downloads - Raw URLs have no rate limits, making sync much more reliable
This commit is contained in:
185
src/server/services/githubJsonService.ts
Normal file
185
src/server/services/githubJsonService.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
import { writeFile, mkdir } from 'fs/promises';
|
||||
import { join } from 'path';
|
||||
import { env } from '~/env.js';
|
||||
import type { Script, ScriptCard, GitHubFile } from '~/types/script';
|
||||
|
||||
export class GitHubJsonService {
|
||||
private baseUrl: string;
|
||||
private repoUrl: string;
|
||||
private branch: string;
|
||||
private jsonFolder: string;
|
||||
private localJsonDirectory: string;
|
||||
|
||||
constructor() {
|
||||
this.repoUrl = env.REPO_URL ?? "";
|
||||
this.branch = env.REPO_BRANCH;
|
||||
this.jsonFolder = env.JSON_FOLDER;
|
||||
this.localJsonDirectory = join(process.cwd(), 'scripts', 'json');
|
||||
|
||||
// Only validate GitHub URL if it's provided
|
||||
if (this.repoUrl) {
|
||||
// Extract owner and repo from the URL
|
||||
const urlMatch = /github\.com\/([^\/]+)\/([^\/]+)/.exec(this.repoUrl);
|
||||
if (!urlMatch) {
|
||||
throw new Error(`Invalid GitHub repository URL: ${this.repoUrl}`);
|
||||
}
|
||||
|
||||
const [, owner, repo] = urlMatch;
|
||||
this.baseUrl = `https://api.github.com/repos/${owner}/${repo}`;
|
||||
} else {
|
||||
// Set a dummy base URL if no REPO_URL is provided
|
||||
this.baseUrl = "";
|
||||
}
|
||||
}
|
||||
|
||||
private async fetchFromGitHub<T>(endpoint: string): Promise<T> {
|
||||
const response = await fetch(`${this.baseUrl}${endpoint}`, {
|
||||
headers: {
|
||||
'Accept': 'application/vnd.github.v3+json',
|
||||
'User-Agent': 'PVEScripts-Local/1.0',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`GitHub API error: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
private async downloadJsonFile(filePath: string): Promise<Script> {
|
||||
const rawUrl = `https://raw.githubusercontent.com/${this.extractRepoPath()}/${this.branch}/${filePath}`;
|
||||
|
||||
const response = await fetch(rawUrl);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to download ${filePath}: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
const content = await response.text();
|
||||
return JSON.parse(content) as Script;
|
||||
}
|
||||
|
||||
private extractRepoPath(): string {
|
||||
const match = /github\.com\/([^\/]+)\/([^\/]+)/.exec(this.repoUrl);
|
||||
if (!match) {
|
||||
throw new Error('Invalid GitHub repository URL');
|
||||
}
|
||||
return `${match[1]}/${match[2]}`;
|
||||
}
|
||||
|
||||
async getJsonFiles(): Promise<GitHubFile[]> {
|
||||
if (!this.repoUrl) {
|
||||
throw new Error('REPO_URL environment variable is not set. Cannot fetch from GitHub.');
|
||||
}
|
||||
|
||||
try {
|
||||
const files = await this.fetchFromGitHub<GitHubFile[]>(
|
||||
`/contents/${this.jsonFolder}?ref=${this.branch}`
|
||||
);
|
||||
|
||||
// Filter for JSON files only
|
||||
return files.filter(file => file.name.endsWith('.json'));
|
||||
} catch (error) {
|
||||
console.error('Error fetching JSON files from GitHub:', error);
|
||||
throw new Error('Failed to fetch script files from repository');
|
||||
}
|
||||
}
|
||||
|
||||
async getAllScripts(): Promise<Script[]> {
|
||||
try {
|
||||
// First, get the list of JSON files (1 API call)
|
||||
const jsonFiles = await this.getJsonFiles();
|
||||
const scripts: Script[] = [];
|
||||
|
||||
// Then download each JSON file using raw URLs (no rate limit)
|
||||
for (const file of jsonFiles) {
|
||||
try {
|
||||
const script = await this.downloadJsonFile(file.path);
|
||||
scripts.push(script);
|
||||
} catch (error) {
|
||||
console.error(`Failed to download script ${file.name}:`, error);
|
||||
// Continue with other files even if one fails
|
||||
}
|
||||
}
|
||||
|
||||
return scripts;
|
||||
} catch (error) {
|
||||
console.error('Error fetching all scripts:', error);
|
||||
throw new Error('Failed to fetch scripts from repository');
|
||||
}
|
||||
}
|
||||
|
||||
async getScriptCards(): Promise<ScriptCard[]> {
|
||||
try {
|
||||
const scripts = await this.getAllScripts();
|
||||
|
||||
return scripts.map(script => ({
|
||||
name: script.name,
|
||||
slug: script.slug,
|
||||
description: script.description,
|
||||
logo: script.logo,
|
||||
type: script.type,
|
||||
updateable: script.updateable,
|
||||
website: script.website,
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('Error creating script cards:', error);
|
||||
throw new Error('Failed to create script cards');
|
||||
}
|
||||
}
|
||||
|
||||
async getScriptBySlug(slug: string): Promise<Script | null> {
|
||||
try {
|
||||
const scripts = await this.getAllScripts();
|
||||
return scripts.find(script => script.slug === slug) ?? null;
|
||||
} catch (error) {
|
||||
console.error('Error fetching script by slug:', error);
|
||||
throw new Error(`Failed to fetch script: ${slug}`);
|
||||
}
|
||||
}
|
||||
|
||||
async syncJsonFiles(): Promise<{ success: boolean; message: string; count: number }> {
|
||||
try {
|
||||
// Get all scripts from GitHub (1 API call + raw downloads)
|
||||
const scripts = await this.getAllScripts();
|
||||
|
||||
// Save scripts to local directory
|
||||
await this.saveScriptsLocally(scripts);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Successfully synced ${scripts.length} scripts from GitHub using 1 API call + raw downloads`,
|
||||
count: scripts.length
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error syncing JSON files:', error);
|
||||
return {
|
||||
success: false,
|
||||
message: `Failed to sync JSON files: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
count: 0
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async saveScriptsLocally(scripts: Script[]): Promise<void> {
|
||||
try {
|
||||
// Ensure the directory exists
|
||||
await mkdir(this.localJsonDirectory, { recursive: true });
|
||||
|
||||
// Save each script as a JSON file
|
||||
for (const script of scripts) {
|
||||
const filename = `${script.slug}.json`;
|
||||
const filePath = join(this.localJsonDirectory, filename);
|
||||
const content = JSON.stringify(script, null, 2);
|
||||
await writeFile(filePath, content, 'utf-8');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error saving scripts locally:', error);
|
||||
throw new Error('Failed to save scripts locally');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
export const githubJsonService = new GitHubJsonService();
|
||||
Reference in New Issue
Block a user