mirror of
https://github.com/HeyPuter/puter.git
synced 2026-08-27 16:37:18 +00:00
GUI: add npm start --server=<domain> to run the GUI against a remote backend
- npm start --server=puter.com (or -- --server=...) skips the local backend and serves the bundled GUI locally, pointed at the remote server's API. Bare domains resolve to https://api.<domain>; full origins are used verbatim. gui_origin points at the remote origin so /whoarewe, login, anti-csrf, socket.io, and builtin apps hit the real backend (CORS-open). - --extensions=<dir>[;<dir>...] bundles out-of-tree GUI extension directories (sugar for PUTER_GUI_EXTENSION_PATHS). Their imports resolve as if the files lived in src/gui/src/extensions, with the extension's own files taking precedence, and bare imports fall back to the repo-root node_modules. - Fix the bit-rotted dev-server: Express 5 wildcard routes, pass gui() params (previously called with none), inject the service_script shim, load bundle.min.js + bundle.min.css in prod mode, serve /sdk, and properly await the webpack build (it previously resolved immediately).
This commit is contained in:
+1
-2
@@ -60,8 +60,7 @@
|
||||
"test:puterjs:coverage": "npm run build:workerLib:coverage && npm run setupExtensions && rm -rf src/puter-js/coverage && PUTER_COVERAGE=1 vitest run --config src/puter-js/tests/api/vitest.config.ts && node ./tools/puterjsCoverageReport.mjs",
|
||||
"build:workerLib:coverage": "cd src/puter-js && npm run build:coverage && cd ../worker && npm run build",
|
||||
"start:gui": "nodemon --exec \"node dev-server.js\" ",
|
||||
"start": "node --enable-source-maps -r ./dist/src/backend/telemetry.js ./dist/src/backend/index.js",
|
||||
"prestart": "npm run setupExtensions && npm run build:ts",
|
||||
"start": "node ./tools/start.mjs",
|
||||
"dev": "npm start",
|
||||
"build": "npm run setupExtensions && npm run build:ts && cd src/gui && node ./build.js && cd ../puter-js && npm run build",
|
||||
"build:workerLib": "cd src/puter-js && npm run build && cd ../worker && npm run build",
|
||||
|
||||
+85
-14
@@ -21,12 +21,65 @@ import { generateDevHtml, build } from './utils.js';
|
||||
import { argv } from 'node:process';
|
||||
import chalk from 'chalk';
|
||||
import dotenv from 'dotenv';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
dotenv.config();
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
// Arguments: the first bare argument selects the env ('dev' or 'prod');
|
||||
// `--server=<domain-or-origin>` points the GUI at a remote Puter backend,
|
||||
// e.g. `--server=puter.com` or `--server=http://puter.localhost:4100`.
|
||||
// `--extensions=<dir>[;<dir>...]` bundles extra (out-of-tree) GUI extension
|
||||
// directories into the build via PUTER_GUI_EXTENSION_PATHS.
|
||||
// `npm start --server=puter.com` (repo root) arrives via npm_config_server.
|
||||
let env = null;
|
||||
let server = process.env.npm_config_server || null;
|
||||
let extensions = process.env.npm_config_extensions || null;
|
||||
for ( const arg of argv.slice(2) ) {
|
||||
if ( arg.startsWith('--server=') ) server = arg.slice('--server='.length);
|
||||
else if ( arg.startsWith('--extensions=') ) extensions = arg.slice('--extensions='.length);
|
||||
else if ( ! arg.startsWith('--') && env === null ) env = arg;
|
||||
}
|
||||
|
||||
if ( extensions ) {
|
||||
const dirs = extensions.split(';').filter(Boolean)
|
||||
.map(p => path.resolve(process.cwd(), p));
|
||||
for ( const dir of dirs ) {
|
||||
if ( ! fs.existsSync(dir) ) {
|
||||
console.warn(chalk.yellow(`WARNING: GUI extensions directory not found: ${dir}`));
|
||||
}
|
||||
}
|
||||
process.env.PUTER_GUI_EXTENSION_PATHS =
|
||||
[process.env.PUTER_GUI_EXTENSION_PATHS, ...dirs].filter(Boolean).join(';');
|
||||
console.log('Extra GUI extensions:', dirs.join(', '));
|
||||
}
|
||||
// A remote server implies the bundled GUI: the unbundled html loads raw
|
||||
// source modules, whose bare npm imports don't resolve in the browser.
|
||||
env = env ?? (server ? 'prod' : 'dev');
|
||||
const bundled = env === 'prod';
|
||||
|
||||
// Bare domains follow the production convention of the API living at
|
||||
// `api.<domain>`; a full origin (or an `api.` host) is used verbatim.
|
||||
// guiOrigin is the remote server's own GUI origin — login, signup,
|
||||
// anti-csrf, socket.io, and builtin apps are all served there, so the
|
||||
// local GUI must point `gui_origin` at it (the backend is CORS-open).
|
||||
const { apiOrigin, guiOrigin, appDomain } = (() => {
|
||||
const value = server ?? 'puter.com';
|
||||
const url = new URL(value.includes('://') ? value : `https://${value}`);
|
||||
return {
|
||||
apiOrigin: value.includes('://') || url.hostname.startsWith('api.')
|
||||
? url.origin
|
||||
: `https://api.${url.hostname}`,
|
||||
guiOrigin: `${url.protocol}//${url.host.replace(/^api\./, '')}`,
|
||||
appDomain: url.hostname.replace(/^api\./, ''),
|
||||
};
|
||||
})();
|
||||
|
||||
const app = express();
|
||||
let port = process.env.PORT ?? 4000; // Starting port
|
||||
const maxAttempts = 10; // Maximum number of ports to try
|
||||
const env = argv[2] ?? 'dev';
|
||||
|
||||
const startServer = (attempt, useAnyFreePort = false) => {
|
||||
if ( attempt > maxAttempts ) {
|
||||
@@ -36,6 +89,7 @@ const startServer = (attempt, useAnyFreePort = false) => {
|
||||
const server = app.listen(useAnyFreePort ? 0 : port, () => {
|
||||
console.log('\n-----------------------------------------------------------\n');
|
||||
console.log('Puter is now live at: ', chalk.underline.blue(`http://localhost:${server.address().port}`));
|
||||
console.log('Backend (API) origin: ', chalk.underline.blue(apiOrigin));
|
||||
console.log('\n-----------------------------------------------------------\n');
|
||||
}).on('error', (err) => {
|
||||
if ( err.code === 'EADDRINUSE' ) { // Check if the error is because the port is already in use
|
||||
@@ -46,31 +100,48 @@ const startServer = (attempt, useAnyFreePort = false) => {
|
||||
});
|
||||
};
|
||||
|
||||
// Start the server with the first attempt
|
||||
// Build the GUI. The bundled html can't render without the webpack output,
|
||||
// so wait for the build before serving anything.
|
||||
try {
|
||||
await build();
|
||||
} catch (err) {
|
||||
// webpack already printed the compilation errors above
|
||||
console.error(chalk.red(`\nGUI build failed: ${err.message}`));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
startServer(1);
|
||||
|
||||
// build the GUI
|
||||
build();
|
||||
|
||||
app.get(['/', '/app/*', '/action/*', '/desktop', '/dashboard'], (req, res) => {
|
||||
app.get(['/', '/app/*splat', '/action/*splat', '/desktop', '/dashboard'], (req, res) => {
|
||||
res.send(generateDevHtml({
|
||||
env: env,
|
||||
api_origin: 'https://api.puter.com',
|
||||
api_origin: apiOrigin,
|
||||
gui_origin: guiOrigin,
|
||||
app_domain: appDomain,
|
||||
title: 'Puter',
|
||||
max_item_name_length: 150,
|
||||
require_email_verification_to_publish_website: false,
|
||||
short_description: 'Puter is a privacy-first personal cloud that houses all your files, apps, and games in one private and secure place, accessible from anywhere at any time.',
|
||||
}));
|
||||
});
|
||||
app.use(express.static('./'));
|
||||
|
||||
if ( env === 'prod' ) {
|
||||
// The unbundled html loads the local puter.js build at /sdk/puter.dev.js;
|
||||
// fall back to the production build when only that one exists.
|
||||
const sdkDir = path.join(__dirname, '../puter-js/dist');
|
||||
app.use('/sdk', express.static(sdkDir));
|
||||
app.get('/sdk/puter.dev.js', (req, res, next) => {
|
||||
const prodBuild = path.join(sdkDir, 'puter.js');
|
||||
if ( fs.existsSync(prodBuild) ) res.sendFile(prodBuild);
|
||||
else next();
|
||||
});
|
||||
|
||||
app.use(express.static(__dirname));
|
||||
|
||||
if ( bundled ) {
|
||||
// make sure to serve the ./dist/ folder maps to the root of the website
|
||||
app.use(express.static('./dist/'));
|
||||
}
|
||||
|
||||
if ( env === 'dev' ) {
|
||||
app.use(express.static('./src/'));
|
||||
app.use(express.static(path.join(__dirname, 'dist')));
|
||||
} else {
|
||||
app.use(express.static(path.join(__dirname, 'src')));
|
||||
}
|
||||
|
||||
export { app };
|
||||
|
||||
+52
-17
@@ -152,18 +152,13 @@ async function build (options) {
|
||||
},
|
||||
};
|
||||
console.log('webpack opts', webpack_opts);
|
||||
await webpack(webpack_opts, (err, stats) => {
|
||||
if ( err ) {
|
||||
throw err;
|
||||
// console.error(err);
|
||||
// return;
|
||||
}
|
||||
//if(options?.verbose)
|
||||
console.log(stats.toString());
|
||||
// write to ./dist/bundle.min.js
|
||||
// fs.writeFileSync(path.join(__dirname, 'dist', 'bundle.min.js'), fs.readFileSync(path.join(__dirname, 'dist', 'main.js')));
|
||||
// remove ./dist/main.js
|
||||
// fs.unlinkSync(path.join(__dirname, 'dist', 'main.js'));
|
||||
await new Promise((resolve, reject) => {
|
||||
webpack(webpack_opts, (err, stats) => {
|
||||
if ( err ) return reject(err);
|
||||
console.log(stats.toString());
|
||||
if ( stats.hasErrors() ) return reject(new Error('webpack build failed'));
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
// Copy index.js to dist/gui.js
|
||||
@@ -209,6 +204,8 @@ async function build (options) {
|
||||
* @param {Object} options - The configuration options for the GUI.
|
||||
* @param {string} options.env - The environment in which the GUI is running (e.g., "dev" or "prod").
|
||||
* @param {string} options.api_origin - The origin of the API server.
|
||||
* @param {string} options.gui_origin - The origin the GUI is served from.
|
||||
* @param {string} options.app_domain - The domain apps and hosted sites live under (e.g., "puter.com").
|
||||
* @param {string} options.title - The title of the GUI.
|
||||
* @param {string} options.company - The name of the company or organization.
|
||||
* @param {string} options.description - The description of the GUI.
|
||||
@@ -247,10 +244,14 @@ function generateDevHtml (options) {
|
||||
// facebook domain verification
|
||||
h += '<meta name="facebook-domain-verification" content="e29w3hjbnnnypf4kzk2cewcdaxym1y" />';
|
||||
// canonical url
|
||||
h += `<link rel="canonical" href="${options.origin}">`;
|
||||
h += `<link rel="canonical" href="${options.origin ?? options.gui_origin}">`;
|
||||
|
||||
// PROD: bundled stylesheet
|
||||
if ( options.env === 'prod' ) {
|
||||
h += '<link rel="stylesheet" href="/dist/bundle.min.css">';
|
||||
}
|
||||
// DEV: load every CSS file individually
|
||||
if ( options.env === 'dev' ) {
|
||||
else if ( options.env === 'dev' ) {
|
||||
for ( let i = 0; i < css_paths.length; i++ ) {
|
||||
h += `<link rel="stylesheet" href="${css_paths[i]}">`;
|
||||
}
|
||||
@@ -293,6 +294,26 @@ function generateDevHtml (options) {
|
||||
|
||||
// preload images when applicable
|
||||
h += '<link rel="preload" as="image" href="./images/wallpaper.webp">';
|
||||
|
||||
// service-script hook: initgui resolves this promise during boot, so the
|
||||
// shim must exist before the bundle runs (same shim the backend injects).
|
||||
h += `
|
||||
<script>
|
||||
if ( ! window.service_script ) {
|
||||
window.service_script_api_promise = (() => {
|
||||
let resolve, reject;
|
||||
const promise = new Promise((res, rej) => { resolve = res; reject = rej; });
|
||||
promise.resolve = resolve;
|
||||
promise.reject = reject;
|
||||
return promise;
|
||||
})();
|
||||
window.service_script = async fn => {
|
||||
try { await fn(await window.service_script_api_promise); }
|
||||
catch (e) { console.error('service_script(ERROR)', e); }
|
||||
};
|
||||
}
|
||||
</script>`;
|
||||
|
||||
h += '</head>';
|
||||
|
||||
h += '<body>';
|
||||
@@ -336,9 +357,12 @@ function generateDevHtml (options) {
|
||||
h += '</script>';
|
||||
}
|
||||
|
||||
// PROD: gui.js
|
||||
// PROD: the self-contained webpack bundle (includes the gui() bootstrap).
|
||||
// `window.gui_env` must be set before gui() runs — it selects which
|
||||
// puter.js build gets loaded.
|
||||
if ( options.env === 'prod' ) {
|
||||
h += '<script src="/dist/gui.js"></script>';
|
||||
h += '<script>window.gui_env = "prod";</script>';
|
||||
h += '<script src="/dist/bundle.min.js"></script>';
|
||||
}
|
||||
// DEV: load every JS file individually
|
||||
else {
|
||||
@@ -352,10 +376,21 @@ function generateDevHtml (options) {
|
||||
// ----------------------------------------
|
||||
// Initialize GUI with config options
|
||||
// ----------------------------------------
|
||||
const guiParams = {
|
||||
env: options.env,
|
||||
api_origin: options.api_origin,
|
||||
gui_origin: options.gui_origin,
|
||||
app_origin: options.gui_origin,
|
||||
app_domain: options.app_domain,
|
||||
title: options.title,
|
||||
max_item_name_length: options.max_item_name_length,
|
||||
require_email_verification_to_publish_website: options.require_email_verification_to_publish_website,
|
||||
};
|
||||
const guiParamsJson = JSON.stringify(guiParams).replace(/</g, '\\u003c');
|
||||
h += `
|
||||
<script type="text/javascript">
|
||||
window.addEventListener('load', function() {`;
|
||||
h += 'gui()';
|
||||
h += `gui(${guiParamsJson})`;
|
||||
h += `});
|
||||
</script>`;
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const webpack = require('webpack');
|
||||
const EmitPlugin = require('./EmitPlugin.cjs');
|
||||
|
||||
module.exports = async (options = {}) => {
|
||||
@@ -26,11 +27,32 @@ module.exports = async (options = {}) => {
|
||||
|
||||
extension_directories.push(path.join(__dirname, '../src/extensions'));
|
||||
|
||||
// Out-of-tree extension directories (e.g. proprietary extensions kept in
|
||||
// a separate repository).
|
||||
const externalDirs = [];
|
||||
if ( process.env.PUTER_GUI_EXTENSION_PATHS ) {
|
||||
const paths = process.env.PUTER_GUI_EXTENSION_PATHS.split(';');
|
||||
extension_directories.push(...paths);
|
||||
externalDirs.push(...paths.filter(Boolean).map(p => path.resolve(p)));
|
||||
extension_directories.push(...externalDirs);
|
||||
}
|
||||
|
||||
// Imports in out-of-tree extensions resolve as if the extension lived in
|
||||
// src/extensions — so `../UI/UIAlert.js` reaches GUI internals the same
|
||||
// way it does in-tree — while files that exist next to the extension
|
||||
// (its own libs) still win.
|
||||
const srcDir = path.join(__dirname, '../src');
|
||||
const resolvesTo = (p) => fs.existsSync(p) || fs.existsSync(`${p}.js`);
|
||||
const remapExternalImport = (resource) => {
|
||||
const issuerDir = resource.context;
|
||||
const root = externalDirs.find(dir =>
|
||||
issuerDir === dir || issuerDir.startsWith(dir + path.sep));
|
||||
if ( ! root ) return;
|
||||
if ( resolvesTo(path.resolve(issuerDir, resource.request)) ) return;
|
||||
const virtualDir = path.join(srcDir, 'extensions', path.relative(root, issuerDir));
|
||||
const target = path.resolve(virtualDir, resource.request);
|
||||
if ( resolvesTo(target) ) resource.request = target;
|
||||
};
|
||||
|
||||
const entries = [];
|
||||
|
||||
for ( const extensionsDir of extension_directories ) {
|
||||
@@ -73,7 +95,19 @@ module.exports = async (options = {}) => {
|
||||
path: path.resolve(__dirname, '../dist'),
|
||||
filename: 'bundle.min.js',
|
||||
};
|
||||
config.resolve = {
|
||||
modules: [
|
||||
'node_modules',
|
||||
// Hoisted workspace deps: bare imports in out-of-tree extensions
|
||||
// can't reach this repo's node_modules by walking up from their
|
||||
// own location.
|
||||
path.join(__dirname, '../../../node_modules'),
|
||||
],
|
||||
};
|
||||
config.plugins = [
|
||||
...(externalDirs.length
|
||||
? [new webpack.NormalModuleReplacementPlugin(/^\.\.?\//, remapExternalImport)]
|
||||
: []),
|
||||
await EmitPlugin({
|
||||
options,
|
||||
dir: path.join(__dirname, '../src/icons'),
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
#!/usr/bin/env node
|
||||
// Entry point for `npm start`.
|
||||
//
|
||||
// Default: build and run the self-hosted backend.
|
||||
//
|
||||
// With a server flag, the local backend is skipped entirely and the GUI is
|
||||
// served locally against that remote Puter backend:
|
||||
//
|
||||
// npm start --server=puter.com
|
||||
// npm start -- --server=puter.com
|
||||
// npm start -- --server=http://puter.localhost:4100
|
||||
//
|
||||
// A bare domain resolves to the production convention `https://api.<domain>`;
|
||||
// a full origin (or an `api.`-prefixed host) is used verbatim.
|
||||
//
|
||||
// `--extensions=<dir>[;<dir>...]` bundles out-of-tree GUI extension
|
||||
// directories into the served GUI (sugar for PUTER_GUI_EXTENSION_PATHS),
|
||||
// so proprietary extensions can live outside this repository:
|
||||
//
|
||||
// npm start --server=puter.com --extensions=../puter-private/gui-extensions
|
||||
|
||||
import { spawn } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const rootDir = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
|
||||
|
||||
// `npm start --flag=x` arrives as the npm_config_<flag> env var;
|
||||
// `npm start -- --flag=x` arrives as a regular argument.
|
||||
const getFlag = (name) => {
|
||||
for ( const arg of process.argv.slice(2) ) {
|
||||
if ( arg.startsWith(`--${name}=`) ) return arg.slice(name.length + 3);
|
||||
}
|
||||
return process.env[`npm_config_${name}`] || null;
|
||||
};
|
||||
|
||||
// Relative paths resolve against where `npm start` was invoked (INIT_CWD),
|
||||
// not the repo root npm runs scripts from.
|
||||
const resolveExtensionPaths = (value) => value
|
||||
.split(';')
|
||||
.filter(Boolean)
|
||||
.map(p => path.resolve(process.env.INIT_CWD ?? process.cwd(), p))
|
||||
.join(';');
|
||||
|
||||
const run = (cmd, args, opts = {}) => new Promise((resolve, reject) => {
|
||||
const child = spawn(cmd, args, { stdio: 'inherit', cwd: rootDir, ...opts });
|
||||
child.on('error', reject);
|
||||
child.on('exit', (code, signal) => {
|
||||
if ( code === 0 || signal ) resolve();
|
||||
else reject(new Error(`\`${[cmd, ...args].join(' ')}\` exited with code ${code}`));
|
||||
});
|
||||
});
|
||||
|
||||
const npm = process.platform === 'win32' ? 'npm.cmd' : 'npm';
|
||||
const server = getFlag('server');
|
||||
const extensions = getFlag('extensions');
|
||||
|
||||
try {
|
||||
if ( server ) {
|
||||
const args = ['dev-server.js', `--server=${server}`];
|
||||
if ( extensions ) args.push(`--extensions=${resolveExtensionPaths(extensions)}`);
|
||||
await run(process.execPath, args, {
|
||||
cwd: path.join(rootDir, 'src', 'gui'),
|
||||
});
|
||||
} else {
|
||||
if ( extensions ) {
|
||||
console.warn('--extensions only applies to --server (GUI-only) mode; ignoring.');
|
||||
}
|
||||
await run(npm, ['run', 'setupExtensions']);
|
||||
await run(npm, ['run', 'build:ts']);
|
||||
await run(process.execPath, [
|
||||
'--enable-source-maps',
|
||||
'-r', './dist/src/backend/telemetry.js',
|
||||
'./dist/src/backend/index.js',
|
||||
]);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err.message);
|
||||
process.exit(1);
|
||||
}
|
||||
Reference in New Issue
Block a user