Merge pull request #322 from transformerlab/demo/webapp

Add start:cloud target to serve TransformerLab on web
This commit is contained in:
Tony Salomone
2025-03-19 10:31:11 -04:00
committed by GitHub
10 changed files with 764 additions and 314 deletions

View File

@@ -0,0 +1,215 @@
import 'webpack-dev-server';
import path from 'path';
import fs from 'fs';
import webpack from 'webpack';
import HtmlWebpackPlugin from 'html-webpack-plugin';
import chalk from 'chalk';
import { merge } from 'webpack-merge';
import { execSync, spawn } from 'child_process';
import ReactRefreshWebpackPlugin from '@pmmmwh/react-refresh-webpack-plugin';
import baseConfig from './webpack.config.base';
import webpackPaths from './webpack.paths';
import checkNodeEnv from '../scripts/check-node-env';
// When an ESLint server is running, we can't set the NODE_ENV so we'll check if it's
// at the dev webpack config is not accidentally run in a production environment
if (process.env.NODE_ENV === 'production') {
checkNodeEnv('development');
}
const port = process.env.PORT || 1212;
const manifest = path.resolve(webpackPaths.dllPath, 'renderer.json');
const skipDLLs =
module.parent?.filename.includes('webpack.config.renderer.dev.dll') ||
module.parent?.filename.includes('webpack.config.eslint');
/**
* Warn if the DLL is not built
*/
if (
!skipDLLs &&
!(fs.existsSync(webpackPaths.dllPath) && fs.existsSync(manifest))
) {
console.log(
chalk.black.bgYellow.bold(
'The DLL files are missing. Sit back while we build them for you with "npm run build-dll"',
),
);
execSync('npm run postinstall');
}
const configuration: webpack.Configuration = {
devtool: 'inline-source-map',
mode: 'development',
target: ['web', 'electron-renderer'],
entry: [
`webpack-dev-server/client?http://localhost:${port}/dist`,
'webpack/hot/only-dev-server',
path.join(webpackPaths.srcMainPath, 'preload-cloud.ts'),
path.join(webpackPaths.srcRendererPath, 'index.tsx'),
],
output: {
path: webpackPaths.distRendererPath,
publicPath: '/',
filename: 'renderer.dev.js',
library: {
type: 'umd',
},
},
module: {
rules: [
{
test: /\.s?(c|a)ss$/,
use: [
'style-loader',
{
loader: 'css-loader',
options: {
modules: true,
sourceMap: true,
importLoaders: 1,
},
},
'sass-loader',
],
include: /\.module\.s?(c|a)ss$/,
},
{
test: /\.s?css$/,
use: ['style-loader', 'css-loader', 'sass-loader'],
exclude: /\.module\.s?(c|a)ss$/,
},
// Fonts
{
test: /\.(woff|woff2|eot|ttf|otf)$/i,
type: 'asset/resource',
},
// Images
{
test: /\.(png|jpg|jpeg|gif)$/i,
type: 'asset/resource',
},
// SVG
{
test: /\.svg$/,
use: [
{
loader: '@svgr/webpack',
options: {
prettier: false,
svgo: false,
svgoConfig: {
plugins: [{ removeViewBox: false }],
},
titleProp: true,
ref: true,
},
},
'file-loader',
],
},
],
},
plugins: [
...(skipDLLs
? []
: [
new webpack.DllReferencePlugin({
context: webpackPaths.dllPath,
manifest: require(manifest),
sourceType: 'var',
}),
]),
new webpack.NoEmitOnErrorsPlugin(),
/**
* Create global constants which can be configured at compile time.
*
* Useful for allowing different behaviour between development builds and
* release builds
*
* NODE_ENV should be production so that modules do not perform certain
* development checks
*
* By default, use 'development' as NODE_ENV. This can be overriden with
* 'staging', for example, by changing the ENV variables in the npm scripts
*/
new webpack.EnvironmentPlugin({
NODE_ENV: 'development',
}),
new webpack.LoaderOptionsPlugin({
debug: true,
}),
new ReactRefreshWebpackPlugin(),
new HtmlWebpackPlugin({
filename: path.join('index.html'),
template: path.join(webpackPaths.srcRendererPath, 'index.ejs'),
minify: {
collapseWhitespace: true,
removeAttributeQuotes: true,
removeComments: true,
},
isBrowser: false,
env: process.env.NODE_ENV,
isDevelopment: process.env.NODE_ENV !== 'production',
nodeModules: webpackPaths.appNodeModulesPath,
}),
],
node: {
__dirname: false,
__filename: false,
},
devServer: {
port,
compress: true,
hot: true,
headers: { 'Access-Control-Allow-Origin': '*' },
static: {
publicPath: '/',
},
historyApiFallback: {
verbose: true,
},
setupMiddlewares(middlewares) {
console.log('Starting preload-cloud.js builder...');
console.log(path.join(webpackPaths.srcMainPath, 'preload-cloud.ts'));
const preloadProcess = spawn('npm', ['run', 'start:preload-cloud'], {
shell: true,
stdio: 'inherit',
})
.on('close', (code: number) => process.exit(code!))
.on('error', (spawnError) => console.error(spawnError));
// console.log('Starting Main Process...');
// let args = ['run', 'start:main'];
// if (process.env.MAIN_ARGS) {
// args = args.concat(
// ['--', ...process.env.MAIN_ARGS.matchAll(/"[^"]+"|[^\s"]+/g)].flat(),
// );
// }
// spawn('npm', args, {
// shell: true,
// stdio: 'inherit',
// })
// .on('close', (code: number) => {
// preloadProcess.kill();
// process.exit(code!);
// })
// .on('error', (spawnError) => console.error(spawnError));
return middlewares;
},
},
};
export default merge(baseConfig, configuration);

View File

@@ -0,0 +1,71 @@
import path from 'path';
import webpack from 'webpack';
import { merge } from 'webpack-merge';
import { BundleAnalyzerPlugin } from 'webpack-bundle-analyzer';
import baseConfig from './webpack.config.base';
import webpackPaths from './webpack.paths';
import checkNodeEnv from '../scripts/check-node-env';
// When an ESLint server is running, we can't set the NODE_ENV so we'll check if it's
// at the dev webpack config is not accidentally run in a production environment
if (process.env.NODE_ENV === 'production') {
checkNodeEnv('development');
}
const configuration: webpack.Configuration = {
devtool: 'inline-source-map',
mode: 'development',
target: 'electron-preload',
entry: path.join(webpackPaths.srcMainPath, 'preload-cloud.ts'),
output: {
path: webpackPaths.dllPath,
filename: 'preload.js',
library: {
type: 'umd',
},
},
plugins: [
new BundleAnalyzerPlugin({
analyzerMode: process.env.ANALYZE === 'true' ? 'server' : 'disabled',
}),
/**
* Create global constants which can be configured at compile time.
*
* Useful for allowing different behaviour between development builds and
* release builds
*
* NODE_ENV should be production so that modules do not perform certain
* development checks
*
* By default, use 'development' as NODE_ENV. This can be overriden with
* 'staging', for example, by changing the ENV variables in the npm scripts
*/
new webpack.EnvironmentPlugin({
NODE_ENV: 'development',
}),
new webpack.LoaderOptionsPlugin({
debug: true,
}),
],
/**
* Disables webpack processing of __dirname and __filename.
* If you run the bundle in node.js it falls back to these values of node.js.
* https://github.com/webpack/webpack/issues/2010
*/
node: {
__dirname: false,
__filename: false,
},
watch: true,
};
export default merge(baseConfig, configuration);

View File

@@ -4,11 +4,20 @@ import detectPort from 'detect-port';
const port = process.env.PORT || '1212';
detectPort(port, (err, availablePort) => {
// This is a hacked in place to check the version of Node but it works to prevent users from using Node 23 and above
// because electron build breaks on Node 23. Remove this once electron is fixed.
if (process.versions.node.split('.')[0] >= 23) {
console.error(
`Node.js version 23 and above are not supported. Current version: ${process.version}`,
);
process.exit(1);
}
if (port !== String(availablePort)) {
throw new Error(
chalk.whiteBright.bgRed.bold(
`Port "${port}" on "localhost" is already in use. Please use another port. ex: PORT=4343 npm start`
)
`Port "${port}" on "localhost" is already in use. Please use another port. ex: PORT=4343 npm start`,
),
);
} else {
process.exit(0);

View File

@@ -38,7 +38,9 @@
"start": "ts-node ./.erb/scripts/check-port-in-use.js && npm run start:renderer",
"start:main": "cross-env NODE_ENV=development electronmon -r ts-node/register/transpile-only .",
"start:preload": "cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true webpack --config ./.erb/configs/webpack.config.preload.dev.ts",
"start:preload-cloud": "cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true webpack --config ./.erb/configs/webpack.config.preload-cloud.dev.ts",
"start:renderer": "cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true webpack serve --config ./.erb/configs/webpack.config.renderer.dev.ts",
"start:cloud": "cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true webpack serve --config ./.erb/configs/webpack.config.cloud.dev.ts",
"test": "jest"
},
"browserslist": [],

146
src/main/preload-cloud.ts Normal file
View File

@@ -0,0 +1,146 @@
// Disable no-unused-vars, broken for spread args
/* eslint no-unused-vars: off */
// webFrame.setZoomFactor(1);
console.log('CLOUD PRELOAD');
// This function contextBridge.exposeInMainWorld is a custom function that takes the provided argument
// adds it to the window object, and makes it available to the renderer process:
function exposeInMainWorld(key: string, value: unknown) {
window[key] = value;
}
const contextBridge = {} as any;
contextBridge.exposeInMainWorld = exposeInMainWorld;
// Now make a stub ipcRenderer object that will fake the real ipcRenderer object:
const ipcRenderer = {
send: async (_channel: string, ..._args: unknown[]) => {
return new Promise((resolve) => {
setTimeout(() => {
console.log(`Message sent to ${_channel} with args:`, _args);
resolve();
}, 1); // Simulate async operation with 1 ms delay
});
},
on: (_channel: string, _func: (...args: unknown[]) => void) => { },
once: (_channel: string, _func: (...args: unknown[]) => void) => { },
invoke: async (_channel: string, ..._args: unknown[]) => {
console.log(`Invoking ${_channel} with args:`, _args);
return new Promise((resolve) => {
setTimeout(() => {
resolve(`Response from ${_channel}`);
}, 1); // Simulate async operation with 1 ms delay
});
},
removeAllListeners: (_channel: string) => { },
};
// write to the browser window to break the HTML:
// export type Channels =
// | 'getStoreValue'
// | 'setStoreValue'
// | 'deleteStoreValue'
// | 'openURL'
// | 'server:checkSystemRequirements'
// | 'server:checkIfInstalledLocally'
// | 'server:checkLocalVersion'
// | 'server:startLocalServer'
// | 'server:InstallLocally'
// | 'server:install_conda'
// | 'server:install_create-conda-environment'
// | 'server:install_install-dependencies'
// | 'server:checkIfCondaExists'
// | 'server:checkIfCondaEnvironmentExists'
// | 'server:checkIfUvicornExists'
// | 'server:checkDependencies'
// | 'serverLog:startListening'
// | 'serverLog:stopListening'
// | 'serverLog:update';
// actually make the Channels type empty:
export type Channels = '';
const electronHandler = {
ipcRenderer: {
sendMessage(channel: Channels, ...args: unknown[]) {
ipcRenderer.send(channel, ...args);
},
on(channel: Channels, func: (...args: unknown[]) => void) {
const subscription = (_event: any, ...args: unknown[]) => func(...args);
ipcRenderer.on(channel, subscription);
return () => {
ipcRenderer.removeListener(channel, subscription);
};
},
once(channel: Channels, func: (...args: unknown[]) => void) {
ipcRenderer.once(channel, (_event, ...args) => func(...args));
},
invoke(channel: Channels, ...args: unknown[]) {
return ipcRenderer.invoke(channel, ...args);
},
removeAllListeners: (channel: string) =>
ipcRenderer.removeAllListeners(channel),
},
};
contextBridge.exposeInMainWorld('electron', electronHandler);
contextBridge.exposeInMainWorld('platform', {
appmode: "cloud",
node: () => process.versions.node,
chrome: () => process.versions.chrome,
electron: () => process.versions.electron,
isMac: () => process.platform === 'darwin',
isWindows: () => process.platform === 'win32',
isLinux: () => process.platform === 'linux',
platform: () => process.platform,
arch: () => process.arch,
});
contextBridge.exposeInMainWorld('storage', {
get: (key: string) => {
const keyValue = localStorage.getItem(key);
try {
return Promise.resolve(JSON.parse(keyValue));
} catch (err) {
// In case soemthing made it into storage wihout getting stringify-ed
return Promise.resolve(keyValue);
}
},
set: (key: string, value: any) => {
localStorage.setItem(key, JSON.stringify(value));
return Promise.resolve();
},
delete: (key: string) => {
localStorage.removeItem(key);
console.log('Deleted key from localStorage:', key);
return Promise.resolve();
},
});
// contextBridge.exposeInMainWorld('sshClient', {
// connect: (data) => ipcRenderer.invoke('ssh:connect', data),
// data: (data) => ipcRenderer.send('ssh:data', data),
// onData: (data) => ipcRenderer.on('ssh:data', data),
// onSSHConnected: (callback) => ipcRenderer.on('ssh:connected', callback),
// removeAllListeners: () => {
// ipcRenderer.removeAllListeners('ssh:data');
// ipcRenderer.removeAllListeners('ssh:connected');
// },
// });
contextBridge.exposeInMainWorld('autoUpdater', {
onMessage: (f) => {
f(null, 'Update not available.');
},
removeAllListeners: () => ipcRenderer.removeAllListeners('autoUpdater'),
requestUpdate: () => ipcRenderer.invoke('autoUpdater:requestUpdate'),
});

View File

@@ -60,6 +60,7 @@ export type ElectronHandler = typeof electronHandler;
contextBridge.exposeInMainWorld('electron', electronHandler);
contextBridge.exposeInMainWorld('platform', {
appmode: "electron",
node: () => process.versions.node,
chrome: () => process.versions.chrome,
electron: () => process.versions.electron,

View File

@@ -47,9 +47,10 @@ export default function App() {
useEffect(() => {
async function getSavedExperimentId() {
const connectionWithoutDots = connection.replace(/\./g, '-');
const experimentId = await window.storage.get(
`experimentId.${connectionWithoutDots}`
);
// window.storage should be defined by cloud or electron preload script
const experimentId = window.storage
? await window.storage.get(`experimentId.${connectionWithoutDots}`)
: 1;
if (experimentId) {
setExperimentId(experimentId);
} else if (connection !== '') {
@@ -74,7 +75,8 @@ export default function App() {
}, [connection]);
useEffect(() => {
if (experimentId == '') return;
// if there is no experiment or window.storage isn't setup then skip
if (experimentId == '' || !window.storage) return;
const connectionWithoutDots = connection.replace(/\./g, '-');
window.storage.set(`experimentId.${connectionWithoutDots}`, experimentId);
}, [experimentId]);

View File

@@ -45,6 +45,8 @@ export default function LoginModal({
const [host, setHost] = useState('');
const WEB_APP = window.platform.appmode == "cloud";
React.useEffect(() => {
window.storage
.get('recentConnections')
@@ -130,12 +132,14 @@ export default function LoginModal({
</OneTimePopup>
<Tabs
aria-label="Basic tabs"
defaultValue={0}
defaultValue={WEB_APP ? 1 : 0}
sx={{ overflow: 'hidden', height: '100%' }}
onChange={(_event, newValue) => { }}
>
<TabList tabFlex={1}>
{!WEB_APP && (
<Tab>Local Engine</Tab>
)}
<Tab>Connect to Remote Engine</Tab>
{/* <Tab value="SSH">Connect via SSH</Tab> */}
</TabList>