mirror of
https://github.com/stackblitz-labs/bolt.diy
synced 2025-03-09 21:50:36 +00:00
Merge branch 'stackblitz-labs:main' into ACT_BoltDYI_BUGFIX_PREVIEW
This commit is contained in:
commit
9652c0f2e7
43
.github/workflows/docker.yaml
vendored
43
.github/workflows/docker.yaml
vendored
@ -2,11 +2,14 @@ name: Docker Publish
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- stable
|
||||
branches: [main, stable]
|
||||
tags: ['v*', '*.*.*']
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
packages: write
|
||||
contents: read
|
||||
@ -18,13 +21,14 @@ env:
|
||||
jobs:
|
||||
docker-build-publish:
|
||||
runs-on: ubuntu-latest
|
||||
# timeout-minutes: 30
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
@ -37,25 +41,22 @@ jobs:
|
||||
uses: docker/metadata-action@v4
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}
|
||||
type=raw,value=stable,enable=${{ github.ref == 'refs/heads/stable' }}
|
||||
type=ref,event=tag
|
||||
type=sha,format=short
|
||||
type=raw,value=${{ github.ref_name }},enable=${{ startsWith(github.ref, 'refs/tags/') || github.ref == 'refs/heads/main' || github.ref == 'refs/heads/stable' }}
|
||||
|
||||
- name: Build and push Docker image for main
|
||||
if: github.ref == 'refs/heads/main'
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
target: bolt-ai-production
|
||||
push: true
|
||||
tags: |
|
||||
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
|
||||
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
- name: Build and push Docker image for stable
|
||||
if: github.ref == 'refs/heads/stable'
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: |
|
||||
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:stable
|
||||
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
- name: Check manifest
|
||||
run: docker buildx imagetools inspect ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.meta.outputs.version }}
|
@ -1,2 +0,0 @@
|
||||
nodejs 20.15.1
|
||||
pnpm 9.4.0
|
@ -77,6 +77,46 @@ export function GithubConnection() {
|
||||
const [isFetchingStats, setIsFetchingStats] = useState(false);
|
||||
const [isStatsExpanded, setIsStatsExpanded] = useState(false);
|
||||
|
||||
const fetchGithubUser = async (token: string) => {
|
||||
try {
|
||||
setIsConnecting(true);
|
||||
|
||||
const response = await fetch('https://api.github.com/user', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Invalid token or unauthorized');
|
||||
}
|
||||
|
||||
const data = (await response.json()) as GitHubUserResponse;
|
||||
const newConnection: GitHubConnection = {
|
||||
user: data,
|
||||
token,
|
||||
tokenType: connection.tokenType,
|
||||
};
|
||||
|
||||
localStorage.setItem('github_connection', JSON.stringify(newConnection));
|
||||
Cookies.set('githubToken', token);
|
||||
Cookies.set('githubUsername', data.login);
|
||||
Cookies.set('git:github.com', JSON.stringify({ username: token, password: 'x-oauth-basic' }));
|
||||
|
||||
setConnection(newConnection);
|
||||
|
||||
await fetchGitHubStats(token);
|
||||
|
||||
toast.success('Successfully connected to GitHub');
|
||||
} catch (error) {
|
||||
logStore.logError('Failed to authenticate with GitHub', { error });
|
||||
toast.error('Failed to connect to GitHub');
|
||||
setConnection({ user: null, token: '', tokenType: 'classic' });
|
||||
} finally {
|
||||
setIsConnecting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchGitHubStats = async (token: string) => {
|
||||
try {
|
||||
setIsFetchingStats(true);
|
||||
@ -182,51 +222,25 @@ export function GithubConnection() {
|
||||
|
||||
setIsLoading(false);
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
if (!connection) {
|
||||
return;
|
||||
}
|
||||
|
||||
const token = connection.token;
|
||||
const data = connection.user;
|
||||
Cookies.set('githubToken', token);
|
||||
Cookies.set('git:github.com', JSON.stringify({ username: token, password: 'x-oauth-basic' }));
|
||||
|
||||
if (data) {
|
||||
Cookies.set('githubUsername', data.login);
|
||||
}
|
||||
}, [connection]);
|
||||
|
||||
if (isLoading || isConnecting || isFetchingStats) {
|
||||
return <LoadingSpinner />;
|
||||
}
|
||||
|
||||
const fetchGithubUser = async (token: string) => {
|
||||
try {
|
||||
setIsConnecting(true);
|
||||
|
||||
const response = await fetch('https://api.github.com/user', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Invalid token or unauthorized');
|
||||
}
|
||||
|
||||
const data = (await response.json()) as GitHubUserResponse;
|
||||
const newConnection: GitHubConnection = {
|
||||
user: data,
|
||||
token,
|
||||
tokenType: connection.tokenType,
|
||||
};
|
||||
|
||||
localStorage.setItem('github_connection', JSON.stringify(newConnection));
|
||||
Cookies.set('githubToken', token);
|
||||
Cookies.set('githubUsername', data.login);
|
||||
Cookies.set('git:github.com', JSON.stringify({ username: token, password: 'x-oauth-basic' }));
|
||||
|
||||
setConnection(newConnection);
|
||||
|
||||
await fetchGitHubStats(token);
|
||||
|
||||
toast.success('Successfully connected to GitHub');
|
||||
} catch (error) {
|
||||
logStore.logError('Failed to authenticate with GitHub', { error });
|
||||
toast.error('Failed to connect to GitHub');
|
||||
setConnection({ user: null, token: '', tokenType: 'classic' });
|
||||
} finally {
|
||||
setIsConnecting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConnect = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
await fetchGithubUser(connection.token);
|
||||
|
@ -292,11 +292,24 @@ export function RepositorySelectionDialog({ isOpen, onClose, onSelect }: Reposit
|
||||
|
||||
const connection = getLocalStorage('github_connection');
|
||||
const headers: HeadersInit = connection?.token ? { Authorization: `Bearer ${connection.token}` } : {};
|
||||
|
||||
// Fetch repository tree
|
||||
const treeResponse = await fetch(`https://api.github.com/repos/${owner}/${repo}/git/trees/main?recursive=1`, {
|
||||
const repoObjResponse = await fetch(`https://api.github.com/repos/${owner}/${repo}`, {
|
||||
headers,
|
||||
});
|
||||
const repoObjData = (await repoObjResponse.json()) as any;
|
||||
|
||||
if (!repoObjData.default_branch) {
|
||||
throw new Error('Failed to fetch repository branch');
|
||||
}
|
||||
|
||||
const defaultBranch = repoObjData.default_branch;
|
||||
|
||||
// Fetch repository tree
|
||||
const treeResponse = await fetch(
|
||||
`https://api.github.com/repos/${owner}/${repo}/git/trees/${defaultBranch}?recursive=1`,
|
||||
{
|
||||
headers,
|
||||
},
|
||||
);
|
||||
|
||||
if (!treeResponse.ok) {
|
||||
throw new Error('Failed to fetch repository structure');
|
||||
|
@ -556,7 +556,25 @@ const InlineDiffComparison = memo(({ beforeCode, afterCode, filename, language }
|
||||
useEffect(() => {
|
||||
getHighlighter({
|
||||
themes: ['github-dark', 'github-light'],
|
||||
langs: ['typescript', 'javascript', 'json', 'html', 'css', 'jsx', 'tsx'],
|
||||
langs: [
|
||||
'typescript',
|
||||
'javascript',
|
||||
'json',
|
||||
'html',
|
||||
'css',
|
||||
'jsx',
|
||||
'tsx',
|
||||
'python',
|
||||
'php',
|
||||
'java',
|
||||
'c',
|
||||
'cpp',
|
||||
'csharp',
|
||||
'go',
|
||||
'ruby',
|
||||
'rust',
|
||||
'plaintext',
|
||||
],
|
||||
}).then(setHighlighter);
|
||||
}, []);
|
||||
|
||||
|
@ -13,6 +13,12 @@ export default class AnthropicProvider extends BaseProvider {
|
||||
};
|
||||
|
||||
staticModels: ModelInfo[] = [
|
||||
{
|
||||
name: 'claude-3-7-sonnet-20250219',
|
||||
label: 'Claude 3.7 Sonnet',
|
||||
provider: 'Anthropic',
|
||||
maxTokenAllowed: 8000,
|
||||
},
|
||||
{
|
||||
name: 'claude-3-5-sonnet-latest',
|
||||
label: 'Claude 3.5 Sonnet (new)',
|
||||
@ -46,7 +52,7 @@ export default class AnthropicProvider extends BaseProvider {
|
||||
providerSettings: settings,
|
||||
serverEnv: serverEnv as any,
|
||||
defaultBaseUrlKey: '',
|
||||
defaultApiTokenKey: 'OPENAI_API_KEY',
|
||||
defaultApiTokenKey: 'ANTHROPIC_API_KEY',
|
||||
});
|
||||
|
||||
if (!apiKey) {
|
||||
|
7
app/lib/persistence/types.ts
Normal file
7
app/lib/persistence/types.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import type { FileMap } from '~/lib/stores/files';
|
||||
|
||||
export interface Snapshot {
|
||||
chatIndex: string;
|
||||
files: FileMap;
|
||||
summary?: string;
|
||||
}
|
@ -1,7 +1,7 @@
|
||||
import { useLoaderData, useNavigate, useSearchParams } from '@remix-run/react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { atom } from 'nanostores';
|
||||
import type { Message } from 'ai';
|
||||
import { generateId, type JSONValue, type Message } from 'ai';
|
||||
import { toast } from 'react-toastify';
|
||||
import { workbenchStore } from '~/lib/stores/workbench';
|
||||
import { logStore } from '~/lib/stores/logs'; // Import logStore
|
||||
@ -15,6 +15,11 @@ import {
|
||||
createChatFromMessages,
|
||||
type IChatMetadata,
|
||||
} from './db';
|
||||
import type { FileMap } from '~/lib/stores/files';
|
||||
import type { Snapshot } from './types';
|
||||
import { webcontainer } from '~/lib/webcontainer';
|
||||
import { createCommandsMessage, detectProjectCommands } from '~/utils/projectCommands';
|
||||
import type { ContextAnnotation } from '~/types/context';
|
||||
|
||||
export interface ChatHistoryItem {
|
||||
id: string;
|
||||
@ -37,6 +42,7 @@ export function useChatHistory() {
|
||||
const { id: mixedId } = useLoaderData<{ id?: string }>();
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
const [archivedMessages, setArchivedMessages] = useState<Message[]>([]);
|
||||
const [initialMessages, setInitialMessages] = useState<Message[]>([]);
|
||||
const [ready, setReady] = useState<boolean>(false);
|
||||
const [urlId, setUrlId] = useState<string | undefined>();
|
||||
@ -56,14 +62,128 @@ export function useChatHistory() {
|
||||
|
||||
if (mixedId) {
|
||||
getMessages(db, mixedId)
|
||||
.then((storedMessages) => {
|
||||
.then(async (storedMessages) => {
|
||||
if (storedMessages && storedMessages.messages.length > 0) {
|
||||
const snapshotStr = localStorage.getItem(`snapshot:${mixedId}`);
|
||||
const snapshot: Snapshot = snapshotStr ? JSON.parse(snapshotStr) : { chatIndex: 0, files: {} };
|
||||
const summary = snapshot.summary;
|
||||
|
||||
const rewindId = searchParams.get('rewindTo');
|
||||
const filteredMessages = rewindId
|
||||
? storedMessages.messages.slice(0, storedMessages.messages.findIndex((m) => m.id === rewindId) + 1)
|
||||
: storedMessages.messages;
|
||||
let startingIdx = -1;
|
||||
const endingIdx = rewindId
|
||||
? storedMessages.messages.findIndex((m) => m.id === rewindId) + 1
|
||||
: storedMessages.messages.length;
|
||||
const snapshotIndex = storedMessages.messages.findIndex((m) => m.id === snapshot.chatIndex);
|
||||
|
||||
if (snapshotIndex >= 0 && snapshotIndex < endingIdx) {
|
||||
startingIdx = snapshotIndex;
|
||||
}
|
||||
|
||||
if (snapshotIndex > 0 && storedMessages.messages[snapshotIndex].id == rewindId) {
|
||||
startingIdx = -1;
|
||||
}
|
||||
|
||||
let filteredMessages = storedMessages.messages.slice(startingIdx + 1, endingIdx);
|
||||
let archivedMessages: Message[] = [];
|
||||
|
||||
if (startingIdx >= 0) {
|
||||
archivedMessages = storedMessages.messages.slice(0, startingIdx + 1);
|
||||
}
|
||||
|
||||
setArchivedMessages(archivedMessages);
|
||||
|
||||
if (startingIdx > 0) {
|
||||
const files = Object.entries(snapshot?.files || {})
|
||||
.map(([key, value]) => {
|
||||
if (value?.type !== 'file') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
content: value.content,
|
||||
path: key,
|
||||
};
|
||||
})
|
||||
.filter((x) => !!x);
|
||||
const projectCommands = await detectProjectCommands(files);
|
||||
const commands = createCommandsMessage(projectCommands);
|
||||
|
||||
filteredMessages = [
|
||||
{
|
||||
id: generateId(),
|
||||
role: 'user',
|
||||
content: `Restore project from snapshot
|
||||
`,
|
||||
annotations: ['no-store', 'hidden'],
|
||||
},
|
||||
{
|
||||
id: storedMessages.messages[snapshotIndex].id,
|
||||
role: 'assistant',
|
||||
content: ` 📦 Chat Restored from snapshot, You can revert this message to load the full chat history
|
||||
<boltArtifact id="imported-files" title="Project Files Snapshot" type="bundled">
|
||||
${Object.entries(snapshot?.files || {})
|
||||
.filter((x) => !x[0].endsWith('lock.json'))
|
||||
.map(([key, value]) => {
|
||||
if (value?.type === 'file') {
|
||||
return `
|
||||
<boltAction type="file" filePath="${key}">
|
||||
${value.content}
|
||||
</boltAction>
|
||||
`;
|
||||
} else {
|
||||
return ``;
|
||||
}
|
||||
})
|
||||
.join('\n')}
|
||||
</boltArtifact>
|
||||
`,
|
||||
annotations: [
|
||||
'no-store',
|
||||
...(summary
|
||||
? [
|
||||
{
|
||||
chatId: storedMessages.messages[snapshotIndex].id,
|
||||
type: 'chatSummary',
|
||||
summary,
|
||||
} satisfies ContextAnnotation,
|
||||
]
|
||||
: []),
|
||||
],
|
||||
},
|
||||
...(commands !== null
|
||||
? [
|
||||
{
|
||||
id: `${storedMessages.messages[snapshotIndex].id}-2`,
|
||||
role: 'user' as const,
|
||||
content: `setup project`,
|
||||
annotations: ['no-store', 'hidden'],
|
||||
},
|
||||
{
|
||||
...commands,
|
||||
id: `${storedMessages.messages[snapshotIndex].id}-3`,
|
||||
annotations: [
|
||||
'no-store',
|
||||
...(commands.annotations || []),
|
||||
...(summary
|
||||
? [
|
||||
{
|
||||
chatId: `${storedMessages.messages[snapshotIndex].id}-3`,
|
||||
type: 'chatSummary',
|
||||
summary,
|
||||
} satisfies ContextAnnotation,
|
||||
]
|
||||
: []),
|
||||
],
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...filteredMessages,
|
||||
];
|
||||
restoreSnapshot(mixedId);
|
||||
}
|
||||
|
||||
setInitialMessages(filteredMessages);
|
||||
|
||||
setUrlId(storedMessages.urlId);
|
||||
description.set(storedMessages.description);
|
||||
chatId.set(storedMessages.id);
|
||||
@ -75,10 +195,64 @@ export function useChatHistory() {
|
||||
setReady(true);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
|
||||
logStore.logError('Failed to load chat messages', error);
|
||||
toast.error(error.message);
|
||||
});
|
||||
}
|
||||
}, [mixedId]);
|
||||
|
||||
const takeSnapshot = useCallback(
|
||||
async (chatIdx: string, files: FileMap, _chatId?: string | undefined, chatSummary?: string) => {
|
||||
const id = _chatId || chatId;
|
||||
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
|
||||
const snapshot: Snapshot = {
|
||||
chatIndex: chatIdx,
|
||||
files,
|
||||
summary: chatSummary,
|
||||
};
|
||||
localStorage.setItem(`snapshot:${id}`, JSON.stringify(snapshot));
|
||||
},
|
||||
[chatId],
|
||||
);
|
||||
|
||||
const restoreSnapshot = useCallback(async (id: string) => {
|
||||
const snapshotStr = localStorage.getItem(`snapshot:${id}`);
|
||||
const container = await webcontainer;
|
||||
|
||||
// if (snapshotStr)setSnapshot(JSON.parse(snapshotStr));
|
||||
const snapshot: Snapshot = snapshotStr ? JSON.parse(snapshotStr) : { chatIndex: 0, files: {} };
|
||||
|
||||
if (!snapshot?.files) {
|
||||
return;
|
||||
}
|
||||
|
||||
Object.entries(snapshot.files).forEach(async ([key, value]) => {
|
||||
if (key.startsWith(container.workdir)) {
|
||||
key = key.replace(container.workdir, '');
|
||||
}
|
||||
|
||||
if (value?.type === 'folder') {
|
||||
await container.fs.mkdir(key, { recursive: true });
|
||||
}
|
||||
});
|
||||
Object.entries(snapshot.files).forEach(async ([key, value]) => {
|
||||
if (value?.type === 'file') {
|
||||
if (key.startsWith(container.workdir)) {
|
||||
key = key.replace(container.workdir, '');
|
||||
}
|
||||
|
||||
await container.fs.writeFile(key, value.content, { encoding: value.isBinary ? undefined : 'utf8' });
|
||||
} else {
|
||||
}
|
||||
});
|
||||
|
||||
// workbenchStore.files.setKey(snapshot?.files)
|
||||
}, []);
|
||||
|
||||
return {
|
||||
@ -105,14 +279,34 @@ export function useChatHistory() {
|
||||
}
|
||||
|
||||
const { firstArtifact } = workbenchStore;
|
||||
messages = messages.filter((m) => !m.annotations?.includes('no-store'));
|
||||
|
||||
let _urlId = urlId;
|
||||
|
||||
if (!urlId && firstArtifact?.id) {
|
||||
const urlId = await getUrlId(db, firstArtifact.id);
|
||||
|
||||
_urlId = urlId;
|
||||
navigateChat(urlId);
|
||||
setUrlId(urlId);
|
||||
}
|
||||
|
||||
let chatSummary: string | undefined = undefined;
|
||||
const lastMessage = messages[messages.length - 1];
|
||||
|
||||
if (lastMessage.role === 'assistant') {
|
||||
const annotations = lastMessage.annotations as JSONValue[];
|
||||
const filteredAnnotations = (annotations?.filter(
|
||||
(annotation: JSONValue) =>
|
||||
annotation && typeof annotation === 'object' && Object.keys(annotation).includes('type'),
|
||||
) || []) as { type: string; value: any } & { [key: string]: any }[];
|
||||
|
||||
if (filteredAnnotations.find((annotation) => annotation.type === 'chatSummary')) {
|
||||
chatSummary = filteredAnnotations.find((annotation) => annotation.type === 'chatSummary')?.summary;
|
||||
}
|
||||
}
|
||||
|
||||
takeSnapshot(messages[messages.length - 1].id, workbenchStore.files.get(), _urlId, chatSummary);
|
||||
|
||||
if (!description.get() && firstArtifact?.title) {
|
||||
description.set(firstArtifact?.title);
|
||||
}
|
||||
@ -127,7 +321,15 @@ export function useChatHistory() {
|
||||
}
|
||||
}
|
||||
|
||||
await setMessages(db, chatId.get() as string, messages, urlId, description.get(), undefined, chatMetadata.get());
|
||||
await setMessages(
|
||||
db,
|
||||
chatId.get() as string,
|
||||
[...archivedMessages, ...messages],
|
||||
urlId,
|
||||
description.get(),
|
||||
undefined,
|
||||
chatMetadata.get(),
|
||||
);
|
||||
},
|
||||
duplicateCurrentChat: async (listItemId: string) => {
|
||||
if (!db || (!mixedId && !listItemId)) {
|
||||
|
@ -1,6 +1,47 @@
|
||||
import { json } from '@remix-run/cloudflare';
|
||||
import type { ActionFunctionArgs, LoaderFunctionArgs } from '@remix-run/cloudflare';
|
||||
|
||||
// Allowed headers to forward to the target server
|
||||
const ALLOW_HEADERS = [
|
||||
'accept-encoding',
|
||||
'accept-language',
|
||||
'accept',
|
||||
'access-control-allow-origin',
|
||||
'authorization',
|
||||
'cache-control',
|
||||
'connection',
|
||||
'content-length',
|
||||
'content-type',
|
||||
'dnt',
|
||||
'pragma',
|
||||
'range',
|
||||
'referer',
|
||||
'user-agent',
|
||||
'x-authorization',
|
||||
'x-http-method-override',
|
||||
'x-requested-with',
|
||||
];
|
||||
|
||||
// Headers to expose from the target server's response
|
||||
const EXPOSE_HEADERS = [
|
||||
'accept-ranges',
|
||||
'age',
|
||||
'cache-control',
|
||||
'content-length',
|
||||
'content-language',
|
||||
'content-type',
|
||||
'date',
|
||||
'etag',
|
||||
'expires',
|
||||
'last-modified',
|
||||
'pragma',
|
||||
'server',
|
||||
'transfer-encoding',
|
||||
'vary',
|
||||
'x-github-request-id',
|
||||
'x-redirected-url',
|
||||
];
|
||||
|
||||
// Handle all HTTP methods
|
||||
export async function action({ request, params }: ActionFunctionArgs) {
|
||||
return handleProxyRequest(request, params['*']);
|
||||
@ -16,50 +57,117 @@ async function handleProxyRequest(request: Request, path: string | undefined) {
|
||||
return json({ error: 'Invalid proxy URL format' }, { status: 400 });
|
||||
}
|
||||
|
||||
const url = new URL(request.url);
|
||||
|
||||
// Reconstruct the target URL
|
||||
const targetURL = `https://${path}${url.search}`;
|
||||
|
||||
// Forward the request to the target URL
|
||||
const response = await fetch(targetURL, {
|
||||
method: request.method,
|
||||
headers: {
|
||||
...Object.fromEntries(request.headers),
|
||||
|
||||
// Override host header with the target host
|
||||
host: new URL(targetURL).host,
|
||||
},
|
||||
body: ['GET', 'HEAD'].includes(request.method) ? null : await request.arrayBuffer(),
|
||||
});
|
||||
|
||||
// Create response with CORS headers
|
||||
const corsHeaders = {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
|
||||
'Access-Control-Allow-Headers': '*',
|
||||
};
|
||||
|
||||
// Handle preflight requests
|
||||
// Handle CORS preflight request
|
||||
if (request.method === 'OPTIONS') {
|
||||
return new Response(null, {
|
||||
headers: corsHeaders,
|
||||
status: 204,
|
||||
status: 200,
|
||||
headers: {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'POST, GET, OPTIONS',
|
||||
'Access-Control-Allow-Headers': ALLOW_HEADERS.join(', '),
|
||||
'Access-Control-Expose-Headers': EXPOSE_HEADERS.join(', '),
|
||||
'Access-Control-Max-Age': '86400',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Forward the response with CORS headers
|
||||
const responseHeaders = new Headers(response.headers);
|
||||
Object.entries(corsHeaders).forEach(([key, value]) => {
|
||||
responseHeaders.set(key, value);
|
||||
});
|
||||
// Extract domain and remaining path
|
||||
const parts = path.match(/([^\/]+)\/?(.*)/);
|
||||
|
||||
if (!parts) {
|
||||
return json({ error: 'Invalid path format' }, { status: 400 });
|
||||
}
|
||||
|
||||
const domain = parts[1];
|
||||
const remainingPath = parts[2] || '';
|
||||
|
||||
// Reconstruct the target URL with query parameters
|
||||
const url = new URL(request.url);
|
||||
const targetURL = `https://${domain}/${remainingPath}${url.search}`;
|
||||
|
||||
console.log('Target URL:', targetURL);
|
||||
|
||||
// Filter and prepare headers
|
||||
const headers = new Headers();
|
||||
|
||||
// Only forward allowed headers
|
||||
for (const header of ALLOW_HEADERS) {
|
||||
if (request.headers.has(header)) {
|
||||
headers.set(header, request.headers.get(header)!);
|
||||
}
|
||||
}
|
||||
|
||||
// Set the host header
|
||||
headers.set('Host', domain);
|
||||
|
||||
// Set Git user agent if not already present
|
||||
if (!headers.has('user-agent') || !headers.get('user-agent')?.startsWith('git/')) {
|
||||
headers.set('User-Agent', 'git/@isomorphic-git/cors-proxy');
|
||||
}
|
||||
|
||||
console.log('Request headers:', Object.fromEntries(headers.entries()));
|
||||
|
||||
// Prepare fetch options
|
||||
const fetchOptions: RequestInit = {
|
||||
method: request.method,
|
||||
headers,
|
||||
redirect: 'follow',
|
||||
};
|
||||
|
||||
// Add body and duplex option for non-GET/HEAD requests
|
||||
if (!['GET', 'HEAD'].includes(request.method)) {
|
||||
fetchOptions.body = request.body;
|
||||
fetchOptions.duplex = 'half'; // This fixes the "duplex option is required when sending a body" error
|
||||
}
|
||||
|
||||
// Forward the request to the target URL
|
||||
const response = await fetch(targetURL, fetchOptions);
|
||||
|
||||
console.log('Response status:', response.status);
|
||||
|
||||
// Create response headers
|
||||
const responseHeaders = new Headers();
|
||||
|
||||
// Add CORS headers
|
||||
responseHeaders.set('Access-Control-Allow-Origin', '*');
|
||||
responseHeaders.set('Access-Control-Allow-Methods', 'POST, GET, OPTIONS');
|
||||
responseHeaders.set('Access-Control-Allow-Headers', ALLOW_HEADERS.join(', '));
|
||||
responseHeaders.set('Access-Control-Expose-Headers', EXPOSE_HEADERS.join(', '));
|
||||
|
||||
// Copy exposed headers from the target response
|
||||
for (const header of EXPOSE_HEADERS) {
|
||||
// Skip content-length as we'll use the original response's content-length
|
||||
if (header === 'content-length') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (response.headers.has(header)) {
|
||||
responseHeaders.set(header, response.headers.get(header)!);
|
||||
}
|
||||
}
|
||||
|
||||
// If the response was redirected, add the x-redirected-url header
|
||||
if (response.redirected) {
|
||||
responseHeaders.set('x-redirected-url', response.url);
|
||||
}
|
||||
|
||||
console.log('Response headers:', Object.fromEntries(responseHeaders.entries()));
|
||||
|
||||
// Return the response with the target's body stream piped directly
|
||||
return new Response(response.body, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: responseHeaders,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Git proxy error:', error);
|
||||
return json({ error: 'Proxy error' }, { status: 500 });
|
||||
console.error('Proxy error:', error);
|
||||
return json(
|
||||
{
|
||||
error: 'Proxy error',
|
||||
message: error instanceof Error ? error.message : 'Unknown error',
|
||||
url: path ? `https://${path}` : 'Invalid URL',
|
||||
},
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user