Skip to content

Commit

Permalink
Move domain and add better error handling (#1035)
Browse files Browse the repository at this point in the history
* Add next.js error checks

* Update typescript config for build

* Preview button 

* Handling create link failed
  • Loading branch information
Kitenite authored Jan 13, 2025
1 parent ec43833 commit f3532e1
Show file tree
Hide file tree
Showing 14 changed files with 350 additions and 139 deletions.
11 changes: 7 additions & 4 deletions apps/studio/electron/main/events/hosting.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,11 @@ export function listenForHostingMessages() {
return await hostingManager.deploy(folderPath, buildScript, url);
});

ipcMain.handle(MainChannels.UNPUBLISH_HOSTING_ENV, (e: Electron.IpcMainInvokeEvent, args) => {
const { url } = args;
return hostingManager.unpublish(url);
});
ipcMain.handle(
MainChannels.UNPUBLISH_HOSTING_ENV,
async (e: Electron.IpcMainInvokeEvent, args) => {
const { url } = args;
return await hostingManager.unpublish(url);
},
);
}
40 changes: 34 additions & 6 deletions apps/studio/electron/main/hosting/helpers.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { addStandaloneConfig } from '@onlook/foundation';
import { addNextBuildConfig } from '@onlook/foundation';
import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync } from 'fs';
import { isBinary } from 'istextorbinary';
import { exec } from 'node:child_process';
Expand Down Expand Up @@ -46,10 +46,31 @@ export function serializeFiles(currentDir: string, basePath: string = ''): FileR
return files;
}

export async function prepareNextProject(projectDir: string) {
const res = await addStandaloneConfig(projectDir);
export async function preprocessNextBuild(projectDir: string): Promise<{
success: boolean;
error?: string;
}> {
const res = await addNextBuildConfig(projectDir);
if (!res) {
return false;
return {
success: false,
error: 'Failed to add standalone config to Next.js project. Make sure project is Next.js and next.config.{js|ts|mjs|cjs} is present',
};
}

return { success: true };
}

export async function postprocessNextBuild(projectDir: string): Promise<{
success: boolean;
error?: string;
}> {
const entrypointExists = await checkEntrypointExists(projectDir);
if (!entrypointExists) {
return {
success: false,
error: 'Failed to find entrypoint server.js in .next/standalone',
};
}

copyDir(projectDir + '/public', projectDir + '/.next/standalone/public');
Expand All @@ -58,11 +79,18 @@ export async function prepareNextProject(projectDir: string) {
for (const lockFile of SUPPORTED_LOCK_FILES) {
if (existsSync(projectDir + '/' + lockFile)) {
copyFileSync(projectDir + '/' + lockFile, projectDir + '/.next/standalone/' + lockFile);
return true;
return { success: true };
}
}

return false;
return {
success: false,
error: 'Failed to find lock file. Supported lock files: ' + SUPPORTED_LOCK_FILES.join(', '),
};
}

async function checkEntrypointExists(projectDir: string) {
return existsSync(join(projectDir, '/.next/standalone/server.js'));
}

export function copyDir(src: string, dest: string) {
Expand Down
87 changes: 58 additions & 29 deletions apps/studio/electron/main/hosting/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,19 @@ import { HostingStatus } from '@onlook/models/hosting';
import { FreestyleSandboxes, type FreestyleDeployWebSuccessResponse } from 'freestyle-sandboxes';
import { mainWindow } from '..';
import analytics from '../analytics';
import { PersistentStorage } from '../storage';
import { prepareNextProject, runBuildScript, serializeFiles } from './helpers';
import {
postprocessNextBuild,
preprocessNextBuild,
runBuildScript,
serializeFiles,
} from './helpers';
import { LogTimer } from '/common/helpers/timer';

class HostingManager {
private static instance: HostingManager;
private freestyle: FreestyleSandboxes | null = null;
private userId: string | null = null;

private constructor() {
this.restoreSettings();
this.freestyle = this.initFreestyleClient();
}

Expand All @@ -34,11 +36,6 @@ class HostingManager {
return HostingManager.instance;
}

private restoreSettings() {
const settings = PersistentStorage.USER_SETTINGS.read() || {};
this.userId = settings.id || null;
}

async deploy(
folderPath: string,
buildScript: string,
Expand All @@ -55,44 +52,61 @@ class HostingManager {
return { state: HostingStatus.ERROR, message: 'Hosting client not initialized' };
}

// TODO: Check if project is a Next.js project
try {
this.emitState(HostingStatus.DEPLOYING, 'Preparing project...');

const BUILD_OUTPUT_PATH = folderPath + '/.next';
const BUILD_SCRIPT_NO_LINT = buildScript + ' -- --no-lint';
const { success: preprocessSuccess, error: preprocessError } =
await preprocessNextBuild(folderPath);

if (!preprocessSuccess) {
this.emitState(
HostingStatus.ERROR,
'Failed to prepare project for deployment, error: ' + preprocessError,
);
return {
state: HostingStatus.ERROR,
message: 'Failed to prepare project for deployment, error: ' + preprocessError,
};
}

try {
this.emitState(HostingStatus.DEPLOYING, 'Creating optimized build...');
timer.log('Starting build');

const STANDALONE_PATH = BUILD_OUTPUT_PATH + '/standalone';
const { success, error } = await runBuildScript(folderPath, BUILD_SCRIPT_NO_LINT);
const BUILD_SCRIPT_NO_LINT = buildScript + ' -- --no-lint';
const { success: buildSuccess, error: buildError } = await runBuildScript(
folderPath,
BUILD_SCRIPT_NO_LINT,
);
timer.log('Build completed');

if (!success) {
this.emitState(HostingStatus.ERROR, `Build failed with error: ${error}`);
if (!buildSuccess) {
this.emitState(HostingStatus.ERROR, `Build failed with error: ${buildError}`);
return {
state: HostingStatus.ERROR,
message: `Build failed with error: ${error}`,
message: `Build failed with error: ${buildError}`,
};
}

this.emitState(HostingStatus.DEPLOYING, 'Preparing project...');
this.emitState(HostingStatus.DEPLOYING, 'Preparing project for deployment...');

const preparedResult = await prepareNextProject(folderPath);
const { success: postprocessSuccess, error: postprocessError } =
await postprocessNextBuild(folderPath);
timer.log('Project preparation completed');

if (!preparedResult) {
if (!postprocessSuccess) {
this.emitState(
HostingStatus.ERROR,
'Failed to prepare project for deployment, no lock file found',
'Failed to postprocess project for deployment, error: ' + postprocessError,
);
return {
state: HostingStatus.ERROR,
message: 'Failed to prepare project for deployment, no lock file found',
message:
'Failed to postprocess project for deployment, error: ' + postprocessError,
};
}

const files = serializeFiles(STANDALONE_PATH);
const NEXT_BUILD_OUTPUT_PATH = folderPath + '/.next/standalone';
const files = serializeFiles(NEXT_BUILD_OUTPUT_PATH);
timer.log('Files serialized');

const config = {
Expand Down Expand Up @@ -151,10 +165,16 @@ class HostingManager {
});
}

async unpublish(url: string) {
async unpublish(url: string): Promise<{
success: boolean;
message?: string;
}> {
if (!this.freestyle) {
console.error('Freestyle client not initialized');
return;
return {
success: false,
message: 'Freestyle client not initialized',
};
}

const config = {
Expand All @@ -169,7 +189,10 @@ class HostingManager {

if (!res.projectId) {
console.error('Failed to delete deployment', res);
return false;
return {
success: false,
message: 'Failed to delete deployment. ' + res,
};
}

this.emitState(HostingStatus.NO_ENV, 'Deployment deleted');
Expand All @@ -178,14 +201,20 @@ class HostingManager {
state: HostingStatus.NO_ENV,
message: 'Deployment deleted',
});
return true;
return {
success: true,
message: 'Deployment deleted',
};
} catch (error) {
console.error('Failed to delete deployment', error);
this.emitState(HostingStatus.ERROR, 'Failed to delete deployment');
analytics.trackError('Failed to delete deployment', {
error,
});
return false;
return {
success: false,
message: 'Failed to delete deployment. ' + error,
};
}
}
}
Expand Down
2 changes: 1 addition & 1 deletion apps/studio/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@
"embla-carousel-wheel-gestures": "^8.0.1",
"fflate": "^0.8.2",
"fix-path": "^4.0.0",
"freestyle-sandboxes": "^0.0.7",
"freestyle-sandboxes": "^0.0.11",
"istextorbinary": "^9.5.0",
"js-string-escape": "^1.0.1",
"langfuse-vercel": "^3.29.1",
Expand Down
57 changes: 37 additions & 20 deletions apps/studio/src/lib/projects/hosting.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { MainChannels } from '@onlook/models/constants';
import { HOSTING_DOMAIN, MainChannels } from '@onlook/models/constants';
import { HostingStatus } from '@onlook/models/hosting';
import type { Project } from '@onlook/models/projects';
import { makeAutoObservable } from 'mobx';
Expand Down Expand Up @@ -72,29 +72,38 @@ export class HostingManager {
.replace(/^-|-$/g, '');
}

createLink() {
const newUrl = `${this.createProjectSubdomain(this.project.id)}.onlook.live`;
async createLink(): Promise<boolean> {
const newUrl = `${this.createProjectSubdomain(this.project.id)}.${HOSTING_DOMAIN}`;
sendAnalytics('hosting create link', {
url: newUrl,
});
this.updateProject({
hosting: {
url: newUrl,
},
});
this.updateState({ url: newUrl, status: HostingStatus.READY });
sendAnalytics('hosting create link', {
url: newUrl,
});
this.publish();
const success = await this.publish();
if (!success) {
this.updateProject({
hosting: {
url: null,
},
});
this.updateState({ url: null, status: HostingStatus.NO_ENV });
return false;
}
return true;
}

async publish() {
async publish(): Promise<boolean> {
sendAnalytics('hosting publish');
const folderPath = this.project.folderPath;
if (!folderPath) {
console.error('Failed to publish hosting environment, missing folder path');
sendAnalyticsError('Failed to publish', {
message: 'Failed to publish hosting environment, missing folder path',
});
return;
return false;
}

const buildScript: string = this.project.commands?.build || 'npm run build';
Expand All @@ -103,7 +112,7 @@ export class HostingManager {
sendAnalyticsError('Failed to publish', {
message: 'Failed to publish hosting environment, missing build script',
});
return;
return false;
}

const url = this.project.hosting?.url;
Expand All @@ -112,7 +121,7 @@ export class HostingManager {
sendAnalyticsError('Failed to publish', {
message: 'Failed to publish hosting environment, missing url',
});
return;
return false;
}

this.updateState({ status: HostingStatus.DEPLOYING, message: 'Creating deployment...' });
Expand All @@ -126,7 +135,7 @@ export class HostingManager {
url,
});

if (!res) {
if (!res || res.state === HostingStatus.ERROR) {
console.error('Failed to publish hosting environment');
this.updateState({
status: HostingStatus.ERROR,
Expand All @@ -135,7 +144,7 @@ export class HostingManager {
sendAnalyticsError('Failed to publish', {
message: 'Failed to publish hosting environment, no response from client',
});
return;
return false;
}

sendAnalytics('hosting publish success', {
Expand All @@ -144,23 +153,27 @@ export class HostingManager {
});

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

async unpublish() {
this.updateState({ status: HostingStatus.DELETING, message: 'Deleting deployment...' });
sendAnalytics('hosting unpublish');
const res: boolean = await invokeMainChannel(MainChannels.UNPUBLISH_HOSTING_ENV, {
const res: {
success: boolean;
message?: string;
} = await invokeMainChannel(MainChannels.UNPUBLISH_HOSTING_ENV, {
url: this.state.url,
});

if (!res) {
console.error('Failed to unpublish hosting environment');
if (!res.success) {
console.error('Failed to unpublish hosting environment', res);
this.updateState({
status: HostingStatus.ERROR,
message: 'Failed to unpublish hosting environment',
message: res.message || 'Failed to unpublish hosting environment',
});
sendAnalyticsError('Failed to unpublish', {
message: 'Failed to unpublish hosting environment',
message: res.message || 'Failed to unpublish hosting environment',
});
return;
}
Expand All @@ -181,6 +194,10 @@ export class HostingManager {
}

refresh() {
this.updateState({ status: HostingStatus.READY, message: null });
if (this.state.url) {
this.updateState({ status: HostingStatus.READY, message: null });
} else {
this.updateState({ status: HostingStatus.NO_ENV, message: null, url: null });
}
}
}
2 changes: 1 addition & 1 deletion apps/studio/src/routes/editor/EditPanel/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ const EditPanel = observer(() => {
value={EditorTabValue.CHAT}
>
<Icons.MagicWand className="mr-2" />
{'Chat (beta)'}
{'Chat'}
</TabsTrigger>
</div>
{selectedTab === EditorTabValue.CHAT && <ChatControls />}
Expand Down
Loading

0 comments on commit f3532e1

Please sign in to comment.