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

Support publishing to custom domain #1453

Merged
merged 15 commits into from
Feb 22, 2025
45 changes: 24 additions & 21 deletions apps/studio/electron/main/events/hosting.ts
Original file line number Diff line number Diff line change
@@ -1,39 +1,42 @@
import { MainChannels } from '@onlook/models/constants';
import type { PublishRequest, PublishResponse, UnpublishRequest } from '@onlook/models/hosting';
import { ipcMain } from 'electron';
import hostingManager from '../hosting';
import { getCustomDomains, createDomainVerification, verifyDomain } from '../hosting/domains';
import { createDomainVerification, getCustomDomains, verifyDomain } from '../hosting/domains';

export function listenForHostingMessages() {
ipcMain.handle(MainChannels.START_DEPLOYMENT, async (e: Electron.IpcMainInvokeEvent, args) => {
const { folderPath, buildScript, urls, skipBuild } = args;
return await hostingManager.deploy(folderPath, buildScript, urls, skipBuild);
});
ipcMain.handle(
MainChannels.PUBLISH_TO_DOMAIN,
async (_e: Electron.IpcMainInvokeEvent, args: PublishRequest): Promise<PublishResponse> => {
return await hostingManager.publish(args);
},
);

ipcMain.handle(
MainChannels.UNPUBLISH_DOMAIN,
async (
e: Electron.IpcMainInvokeEvent,
args: UnpublishRequest,
): Promise<PublishResponse> => {
const { urls } = args;
return await hostingManager.unpublish(urls);
},
);

ipcMain.handle(
MainChannels.CREATE_DOMAIN_VERIFICATION,
async (e: Electron.IpcMainInvokeEvent, args) => {
async (_e: Electron.IpcMainInvokeEvent, args) => {
const { domain } = args;
return await createDomainVerification(domain);
},
);

ipcMain.handle(MainChannels.VERIFY_DOMAIN, async (e: Electron.IpcMainInvokeEvent, args) => {
ipcMain.handle(MainChannels.VERIFY_DOMAIN, async (_e: Electron.IpcMainInvokeEvent, args) => {
const { domain } = args;
return await verifyDomain(domain);
});

ipcMain.handle(
MainChannels.GET_CUSTOM_DOMAINS,
async (e: Electron.IpcMainInvokeEvent, args) => {
return await getCustomDomains();
},
);

ipcMain.handle(
MainChannels.UNPUBLISH_HOSTING_ENV,
async (e: Electron.IpcMainInvokeEvent, args) => {
const { urls } = args;
return await hostingManager.unpublish(urls);
},
);
ipcMain.handle(MainChannels.GET_CUSTOM_DOMAINS, async (_e: Electron.IpcMainInvokeEvent) => {
return await getCustomDomains();
});
}
50 changes: 22 additions & 28 deletions apps/studio/electron/main/hosting/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import {
FUNCTIONS_ROUTE,
MainChannels,
} from '@onlook/models/constants';
import { HostingStatus } from '@onlook/models/hosting';
import { PublishStatus, type PublishRequest, type PublishResponse } from '@onlook/models/hosting';
import {
type FreestyleDeployWebConfiguration,
type FreestyleDeployWebSuccessResponse,
Expand Down Expand Up @@ -33,26 +33,23 @@ class HostingManager {
return HostingManager.instance;
}

async deploy(
folderPath: string,
buildScript: string,
urls: string[],
skipBuild: boolean = false,
): Promise<{
state: HostingStatus;
message?: string;
}> {
async publish({
folderPath,
buildScript,
urls,
skipBuild,
}: PublishRequest): Promise<PublishResponse> {
try {
const timer = new LogTimer('Deployment');
this.emitState(HostingStatus.DEPLOYING, 'Preparing project...');
this.emitState(PublishStatus.LOADING, 'Preparing project...');

await this.runPrepareStep(folderPath);
this.emitState(HostingStatus.DEPLOYING, 'Creating optimized build...');
this.emitState(PublishStatus.LOADING, 'Creating optimized build...');
timer.log('Prepare completed');

// Run the build script
await this.runBuildStep(folderPath, buildScript, skipBuild);
this.emitState(HostingStatus.DEPLOYING, 'Preparing project for deployment...');
this.emitState(PublishStatus.LOADING, 'Preparing project for deployment...');
timer.log('Build completed');

// Postprocess the project for deployment
Expand All @@ -70,26 +67,26 @@ class HostingManager {
const NEXT_BUILD_OUTPUT_PATH = `${folderPath}/${CUSTOM_OUTPUT_DIR}/standalone`;
const files: FileRecord = serializeFiles(NEXT_BUILD_OUTPUT_PATH);

this.emitState(HostingStatus.DEPLOYING, 'Deploying project...');
this.emitState(PublishStatus.LOADING, 'Deploying project...');
timer.log('Files serialized, sending to Freestyle...');

const id = await this.sendHostingPostRequest(files, urls);
timer.log('Deployment completed');

this.emitState(HostingStatus.READY, 'Deployment successful, deployment ID: ' + id);
this.emitState(PublishStatus.PUBLISHED, 'Deployment successful, deployment ID: ' + id);

return {
state: HostingStatus.READY,
success: true,
message: 'Deployment successful, deployment ID: ' + id,
};
} catch (error) {
console.error('Failed to deploy to preview environment', error);
this.emitState(HostingStatus.ERROR, 'Deployment failed with error: ' + error);
this.emitState(PublishStatus.ERROR, 'Deployment failed with error: ' + error);
analytics.trackError('Failed to deploy to preview environment', {
error,
});
return {
state: HostingStatus.ERROR,
success: false,
message: 'Deployment failed with error: ' + error,
};
}
Expand Down Expand Up @@ -124,16 +121,16 @@ class HostingManager {
} = await runBuildScript(folderPath, BUILD_SCRIPT_NO_LINT);

if (!buildSuccess) {
this.emitState(HostingStatus.ERROR, `Build failed with error: ${buildError}`);
this.emitState(PublishStatus.ERROR, `Build failed with error: ${buildError}`);
throw new Error(`Build failed with error: ${buildError}`);
} else {
console.log('Build succeeded with output: ', buildOutput);
}
}

emitState(state: HostingStatus, message?: string) {
emitState(state: PublishStatus, message?: string) {
console.log(`Deployment state: ${state} - ${message}`);
mainWindow?.webContents.send(MainChannels.DEPLOY_STATE_CHANGED, {
mainWindow?.webContents.send(MainChannels.PUBLISH_STATE_CHANGED, {
state,
message,
});
Expand All @@ -143,16 +140,13 @@ class HostingManager {
});
}

async unpublish(urls: string[]): Promise<{
success: boolean;
message?: string;
}> {
async unpublish(urls: string[]): Promise<PublishResponse> {
try {
const id = await this.sendHostingPostRequest({}, urls);
this.emitState(HostingStatus.NO_ENV, 'Deployment deleted with ID: ' + id);
this.emitState(PublishStatus.UNPUBLISHED, 'Deployment deleted with ID: ' + id);

analytics.track('hosting unpublish', {
state: HostingStatus.NO_ENV,
state: PublishStatus.UNPUBLISHED,
message: 'Deployment deleted with ID: ' + id,
});
return {
Expand All @@ -161,7 +155,7 @@ class HostingManager {
};
} catch (error) {
console.error('Failed to delete deployment', error);
this.emitState(HostingStatus.ERROR, 'Failed to delete deployment');
this.emitState(PublishStatus.ERROR, 'Failed to delete deployment');
analytics.trackError('Failed to delete deployment', {
error,
});
Expand Down
155 changes: 155 additions & 0 deletions apps/studio/src/lib/projects/domains/hosting.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
import { DefaultSettings, MainChannels } from '@onlook/models/constants';
import {
PublishStatus,
type CustomDomain,
type PublishRequest,
type PublishResponse,
type PublishState,
} from '@onlook/models/hosting';
import { DomainType, type DomainSettings, type Project } from '@onlook/models/projects';
import { getPublishUrls } from '@onlook/utility';
import { makeAutoObservable } from 'mobx';
import { invokeMainChannel, sendAnalytics, sendAnalyticsError } from '../../utils/index.ts';
import type { ProjectsManager } from '../index.ts';

const DEFAULT_STATE: PublishState = {
status: PublishStatus.UNPUBLISHED,
message: null,
};

export class HostingManager {
private stateChangeListener: ((...args: any[]) => void) | null = null;
state: PublishState = DEFAULT_STATE;

constructor(
private projectsManager: ProjectsManager,
private project: Project,
private domain: DomainSettings,
) {
makeAutoObservable(this);
this.listenForStateChanges();
if (domain.publishedAt) {
this.updateState({ status: PublishStatus.PUBLISHED, message: null });
}
}

async listenForStateChanges() {
this.stateChangeListener = async (args: any) => {
const state: PublishState = args;
this.updateState(state);
};
window.api.on(MainChannels.PUBLISH_STATE_CHANGED, this.stateChangeListener);
}

private updateDomain(domain: DomainSettings) {
const domains = { base: null, custom: null, ...this.project.domains };

if (domain.type === DomainType.BASE) {
domains.base = domain;
} else if (domain.type === DomainType.CUSTOM) {
domains.custom = domain;
}

this.updateProject({ domains });
}

private removeDomain(type: DomainType) {
const domains = { base: null, custom: null, ...this.project.domains };
if (type === DomainType.BASE) {
domains.base = null;
} else if (type === DomainType.CUSTOM) {
domains.custom = null;
}

this.updateProject({ domains });
}

private updateProject(project: Partial<Project>) {
const newProject = { ...this.project, ...project };
this.projectsManager.updateProject(newProject);
this.project = newProject;
}

private updateState(partialState: Partial<PublishState>) {
this.state = { ...this.state, ...partialState };
}

async publish(skipBuild: boolean = false): Promise<boolean> {
sendAnalytics('hosting publish');
this.updateState({ status: PublishStatus.LOADING, message: 'Creating deployment...' });

const request: PublishRequest = {
folderPath: this.project.folderPath,
buildScript: this.project.commands?.build || DefaultSettings.COMMANDS.build,
urls: getPublishUrls(this.domain.url),
skipBuild,
};

const res: PublishResponse | null = await invokeMainChannel(
MainChannels.PUBLISH_TO_DOMAIN,
request,
);

if (!res || !res.success) {
const error = `Failed to publish hosting environment: ${res?.message || 'client error'}`;
console.error(error);
this.updateState({
status: PublishStatus.ERROR,
message: error,
});
sendAnalyticsError('Failed to publish', {
message: error,
});
return false;
}

sendAnalytics('hosting publish success', {
urls: request.urls,
});

this.updateState({ status: PublishStatus.PUBLISHED, message: res.message });
return true;
}

async unpublish() {
this.updateState({ status: PublishStatus.LOADING, message: 'Deleting deployment...' });
sendAnalytics('hosting unpublish');

const urls = getPublishUrls(this.domain.url);
const res: PublishResponse = await invokeMainChannel(MainChannels.UNPUBLISH_DOMAIN, {
urls,
});

if (!res.success) {
const error = `Failed to unpublish hosting environment: ${res?.message || 'client error'}`;
console.error(error);
this.updateState({
status: PublishStatus.ERROR,
message: error,
});
sendAnalyticsError('Failed to unpublish', {
message: error,
});
return;
}

this.removeDomain(this.domain.type);
this.updateState({ status: PublishStatus.UNPUBLISHED, message: null });
sendAnalytics('hosting unpublish success');
}

async getCustomDomains(): Promise<CustomDomain[]> {
const res: CustomDomain[] = await invokeMainChannel(MainChannels.GET_CUSTOM_DOMAINS);
return res;
}

async dispose() {
if (this.stateChangeListener) {
window.api.removeListener(MainChannels.PUBLISH_STATE_CHANGED, this.stateChangeListener);
}
}

refresh() {
this.updateState(DEFAULT_STATE);
}
}
Loading