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:
Nariman Jelveh
2026-07-30 16:13:22 -07:00
parent 2c9ae3489e
commit ac0aabfa61
5 changed files with 253 additions and 34 deletions
+1 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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>`;
+35 -1
View File
@@ -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'),
+80
View File
@@ -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);
}