bolt.diy/app/routes/api.github-template.ts
KevIsDev 41e604c1dc fix: add Cloudflare-compatible GitHub repo fetching
Implement two different methods for fetching repository contents:
1. A new Cloudflare-compatible method using GitHub Contents API with batch processing
2. The existing zip-based method for non-Cloudflare environments

The changes include better error handling, environment detection, and support for GitHub tokens from both Cloudflare context and process.env. Also added size limits and filtering for large files while allowing lock files.
2025-06-02 15:46:05 +01:00

242 lines
7.1 KiB
TypeScript

import { json } from '@remix-run/cloudflare';
import JSZip from 'jszip';
// Function to detect if we're running in Cloudflare
function isCloudflareEnvironment(context: any): boolean {
// Check if we're in production AND have Cloudflare Pages specific env vars
const isProduction = process.env.NODE_ENV === 'production';
const hasCfPagesVars = !!(
context?.cloudflare?.env?.CF_PAGES ||
context?.cloudflare?.env?.CF_PAGES_URL ||
context?.cloudflare?.env?.CF_PAGES_COMMIT_SHA
);
return isProduction && hasCfPagesVars;
}
// Cloudflare-compatible method using GitHub Contents API
async function fetchRepoContentsCloudflare(repo: string, githubToken?: string) {
const baseUrl = 'https://api.github.com';
// Get repository info to find default branch
const repoResponse = await fetch(`${baseUrl}/repos/${repo}`, {
headers: {
Accept: 'application/vnd.github.v3+json',
'User-Agent': 'bolt.diy-app',
...(githubToken ? { Authorization: `Bearer ${githubToken}` } : {}),
},
});
if (!repoResponse.ok) {
throw new Error(`Repository not found: ${repo}`);
}
const repoData = (await repoResponse.json()) as any;
const defaultBranch = repoData.default_branch;
// Get the tree recursively
const treeResponse = await fetch(`${baseUrl}/repos/${repo}/git/trees/${defaultBranch}?recursive=1`, {
headers: {
Accept: 'application/vnd.github.v3+json',
'User-Agent': 'bolt.diy-app',
...(githubToken ? { Authorization: `Bearer ${githubToken}` } : {}),
},
});
if (!treeResponse.ok) {
throw new Error(`Failed to fetch repository tree: ${treeResponse.status}`);
}
const treeData = (await treeResponse.json()) as any;
// Filter for files only (not directories) and limit size
const files = treeData.tree.filter((item: any) => {
if (item.type !== 'blob') {
return false;
}
if (item.path.startsWith('.git/')) {
return false;
}
// Allow lock files even if they're large
const isLockFile =
item.path.endsWith('package-lock.json') ||
item.path.endsWith('yarn.lock') ||
item.path.endsWith('pnpm-lock.yaml');
// For non-lock files, limit size to 100KB
if (!isLockFile && item.size >= 100000) {
return false;
}
return true;
});
// Fetch file contents in batches to avoid overwhelming the API
const batchSize = 10;
const fileContents = [];
for (let i = 0; i < files.length; i += batchSize) {
const batch = files.slice(i, i + batchSize);
const batchPromises = batch.map(async (file: any) => {
try {
const contentResponse = await fetch(`${baseUrl}/repos/${repo}/contents/${file.path}`, {
headers: {
Accept: 'application/vnd.github.v3+json',
'User-Agent': 'bolt.diy-app',
...(githubToken ? { Authorization: `Bearer ${githubToken}` } : {}),
},
});
if (!contentResponse.ok) {
console.warn(`Failed to fetch ${file.path}: ${contentResponse.status}`);
return null;
}
const contentData = (await contentResponse.json()) as any;
const content = atob(contentData.content.replace(/\s/g, ''));
return {
name: file.path.split('/').pop() || '',
path: file.path,
content,
};
} catch (error) {
console.warn(`Error fetching ${file.path}:`, error);
return null;
}
});
const batchResults = await Promise.all(batchPromises);
fileContents.push(...batchResults.filter(Boolean));
// Add a small delay between batches to be respectful to the API
if (i + batchSize < files.length) {
await new Promise((resolve) => setTimeout(resolve, 100));
}
}
return fileContents;
}
// Your existing method for non-Cloudflare environments
async function fetchRepoContentsZip(repo: string, githubToken?: string) {
const baseUrl = 'https://api.github.com';
// Get the latest release
const releaseResponse = await fetch(`${baseUrl}/repos/${repo}/releases/latest`, {
headers: {
Accept: 'application/vnd.github.v3+json',
'User-Agent': 'bolt.diy-app',
...(githubToken ? { Authorization: `Bearer ${githubToken}` } : {}),
},
});
if (!releaseResponse.ok) {
throw new Error(`GitHub API error: ${releaseResponse.status} - ${releaseResponse.statusText}`);
}
const releaseData = (await releaseResponse.json()) as any;
const zipballUrl = releaseData.zipball_url;
// Fetch the zipball
const zipResponse = await fetch(zipballUrl, {
headers: {
...(githubToken ? { Authorization: `Bearer ${githubToken}` } : {}),
},
});
if (!zipResponse.ok) {
throw new Error(`Failed to fetch release zipball: ${zipResponse.status}`);
}
// Get the zip content as ArrayBuffer
const zipArrayBuffer = await zipResponse.arrayBuffer();
// Use JSZip to extract the contents
const zip = await JSZip.loadAsync(zipArrayBuffer);
// Find the root folder name
let rootFolderName = '';
zip.forEach((relativePath) => {
if (!rootFolderName && relativePath.includes('/')) {
rootFolderName = relativePath.split('/')[0];
}
});
// Extract all files
const promises = Object.keys(zip.files).map(async (filename) => {
const zipEntry = zip.files[filename];
// Skip directories
if (zipEntry.dir) {
return null;
}
// Skip the root folder itself
if (filename === rootFolderName) {
return null;
}
// Remove the root folder from the path
let normalizedPath = filename;
if (rootFolderName && filename.startsWith(rootFolderName + '/')) {
normalizedPath = filename.substring(rootFolderName.length + 1);
}
// Get the file content
const content = await zipEntry.async('string');
return {
name: normalizedPath.split('/').pop() || '',
path: normalizedPath,
content,
};
});
const results = await Promise.all(promises);
return results.filter(Boolean);
}
export async function loader({ request, context }: { request: Request; context: any }) {
const url = new URL(request.url);
const repo = url.searchParams.get('repo');
if (!repo) {
return json({ error: 'Repository name is required' }, { status: 400 });
}
try {
// Access environment variables from Cloudflare context or process.env
const githubToken = context?.cloudflare?.env?.GITHUB_TOKEN || process.env.GITHUB_TOKEN;
let fileList;
if (isCloudflareEnvironment(context)) {
fileList = await fetchRepoContentsCloudflare(repo, githubToken);
} else {
fileList = await fetchRepoContentsZip(repo, githubToken);
}
// Filter out .git files for both methods
const filteredFiles = fileList.filter((file: any) => !file.path.startsWith('.git'));
return json(filteredFiles);
} catch (error) {
console.error('Error processing GitHub template:', error);
console.error('Repository:', repo);
console.error('Error details:', error instanceof Error ? error.message : String(error));
return json(
{
error: 'Failed to fetch template files',
details: error instanceof Error ? error.message : String(error),
},
{ status: 500 },
);
}
}