Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: deploy to Netlify #548

Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions app/components/workbench/Workbench.client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,33 @@ export const Workbench = memo(({ chatStarted, isStreaming }: WorkspaceProps) =>
{isSyncing ? <div className="i-ph:spinner" /> : <div className="i-ph:cloud-arrow-down" />}
{isSyncing ? 'Syncing...' : 'Sync Files'}
</PanelHeaderButton>
<PanelHeaderButton
className="mr-1 text-sm"
onClick={() => {
const accessToken = prompt('Please enter your Netlify access token:');
if (!accessToken) {
alert('Netlify access token is required. Deployment cancelled.');
return;
}

const siteId = prompt('Please enter your Netlify site ID:');
if (!siteId) {
alert('Netlify site ID is required. Deployment cancelled.');
return;
}

workbenchStore.deployToNetlify(accessToken)
.then((result: { url: string }) => {
toast.success(`Deployed successfully! Site URL: ${result.url}`);
})
.catch((error: Error) => {
toast.error(`Failed to deploy: ${error.message}`);
});
}}
>
<div className="i-ph:cloud-arrow-up" />
Deploy to Netlify
</PanelHeaderButton>
<PanelHeaderButton
className="mr-1 text-sm"
onClick={() => {
Expand Down
176 changes: 176 additions & 0 deletions app/lib/netlify.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
interface NetlifySite {
id: string;
url: string;
}

interface NetlifyDeployResponse {
id: string;
required?: string[];
}

interface NetlifyDeployStatus {
state: string;
url: string;
}

export class NetlifyDeploy {
private accessToken: string;
private siteId?: string;
private baseURL: string;

constructor(accessToken: string) {
this.accessToken = accessToken;
this.baseURL = 'https://api.netlify.com/api/v1';
}

private async createSite(name?: string): Promise<NetlifySite> {
try {
const response = await this.fetchWithAuth('/sites', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
name: name || `site-${Date.now()}`,
}),
});
const site = response as NetlifySite;
this.siteId = site.id;
return site;
} catch (error: any) {
console.error('Error creating site:', error.message);
throw error;
}
}

private async fetchWithAuth(endpoint: string, options: RequestInit = {}) {
const url = `${this.baseURL}${endpoint}`;
const response = await fetch(url, {
...options,
headers: {
Authorization: `Bearer ${this.accessToken}`,
...options.headers,
},
});
if (!response.ok) {
const error = await response.json().catch(() => ({ message: 'Unknown error' })) as { message: string };
throw new Error(error.message || `HTTP error! status: ${response.status}`);
}
return response.json();
}

private async calculateSHA1(content: string): Promise<string> {
const msgUint8 = new TextEncoder().encode(content);
const hashBuffer = await crypto.subtle.digest('SHA-1', msgUint8);
const hashArray = Array.from(new Uint8Array(hashBuffer));
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
}

private async getFileList(files: Record<string, { content: string; type: 'file' | 'folder' }>) {
const fileHashes: Record<string, string> = {};

for (const [filePath, file] of Object.entries(files)) {
if (file.type === 'file') {
const sha1 = await this.calculateSHA1(file.content);
fileHashes['/' + filePath] = sha1;
}
}

return fileHashes;
}

private async createDeploy(files: Record<string, string>): Promise<NetlifyDeployResponse> {
if (!this.siteId) {
throw new Error('No site ID available. Make sure to create a site first.');
}

try {
const response = await this.fetchWithAuth(`/sites/${this.siteId}/deploys`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
files,
async: true
}),
});
return response as NetlifyDeployResponse;
} catch (error: any) {
console.error('Error creating deploy:', error.message);
throw error;
}
}

private async uploadFile(deployId: string, filePath: string, content: string) {
try {
await this.fetchWithAuth(`/deploys/${deployId}/files${filePath}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/octet-stream',
},
body: content,
});
} catch (error: any) {
console.error(`Error uploading file ${filePath}:`, error.message);
throw error;
}
}

private async waitForDeploy(deployId: string): Promise<NetlifyDeployStatus> {
if (!this.siteId) {
throw new Error('No site ID available.');
}

while (true) {
const data = await this.fetchWithAuth(`/sites/${this.siteId}/deploys/${deployId}`) as NetlifyDeployStatus;
const { state } = data;

if (state === 'ready') {
return data;
} else if (state === 'error') {
throw new Error('Deploy failed');
}

await new Promise(resolve => setTimeout(resolve, 1000));
}
}

async deploy(files: Record<string, { content: string; type: 'file' | 'folder' }>, siteName?: string): Promise<NetlifyDeployStatus> {
try {
if (!this.siteId) {
console.log('Creating new site...');
const site = await this.createSite(siteName);
console.log('Site created:', site.url);
}

console.log('Calculating file hashes...');
const fileHashes = await this.getFileList(files);

console.log('Creating deploy...');
const deploy = await this.createDeploy(fileHashes);

const requiredFiles = deploy.required || [];
if (requiredFiles.length > 0) {
console.log('Uploading files...');
for (const sha1 of requiredFiles) {
const filePath = Object.entries(fileHashes).find(([_, hash]) => hash === sha1)?.[0];
if (filePath) {
console.log(`Uploading ${filePath}...`);
await this.uploadFile(deploy.id, filePath, files[filePath.slice(1)].content);
}
}
}

console.log('Waiting for deploy to finish...');
const result = await this.waitForDeploy(deploy.id);

console.log('Deploy successful!');
console.log('URL:', result.url);
return result;
} catch (error: any) {
console.error('Deploy failed:', error.message);
throw error;
}
}
}
30 changes: 30 additions & 0 deletions app/lib/stores/workbench.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { Octokit, type RestEndpointMethodTypes } from '@octokit/rest';
import * as nodePath from 'node:path';
import { extractRelativePath } from '~/utils/diff';
import { description } from '~/lib/persistence';
import { NetlifyDeploy } from '../netlify';

export interface ArtifactState {
id: string;
Expand Down Expand Up @@ -502,6 +503,35 @@ export class WorkbenchStore {
console.error('Error pushing to GitHub:', error instanceof Error ? error.message : String(error));
}
}

async deployToNetlify(accessToken: string) {
try {
const files = this.files.get();
const netlifyDeploy = new NetlifyDeploy(accessToken);

// Convert FileMap to the format expected by NetlifyDeploy
const deployFiles: Record<string, { content: string; type: 'file' | 'folder' }> = {};
for (const [path, dirent] of Object.entries(files)) {
if (dirent?.type === 'file' && !dirent.isBinary) {
deployFiles[path] = {
content: dirent.content,
type: 'file'
};
} else if (dirent?.type === 'folder') {
deployFiles[path] = {
content: '',
type: 'folder'
};
}
}

const result = await netlifyDeploy.deploy(deployFiles);
return result;
} catch (error) {
console.error('Failed to deploy to Netlify:', error);
throw error;
}
}
}

export const workbenchStore = new WorkbenchStore();
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -62,13 +62,15 @@
"@remix-run/cloudflare": "^2.15.0",
"@remix-run/cloudflare-pages": "^2.15.0",
"@remix-run/react": "^2.15.0",
"@types/node": "^22.10.1",
"@uiw/codemirror-theme-vscode": "^4.23.6",
"@unocss/reset": "^0.61.9",
"@webcontainer/api": "1.3.0-internal.10",
"@xterm/addon-fit": "^0.10.0",
"@xterm/addon-web-links": "^0.11.0",
"@xterm/xterm": "^5.5.0",
"ai": "^3.4.33",
"axios": "^1.7.9",
"date-fns": "^3.6.0",
"diff": "^5.2.0",
"file-saver": "^2.0.5",
Expand Down
Loading
Loading