From ac0aabfa6118d59f6ec759eeb4664cd338a26655 Mon Sep 17 00:00:00 2001 From: Nariman Jelveh Date: Thu, 30 Jul 2026 16:13:22 -0700 Subject: [PATCH] GUI: add npm start --server= 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.; 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=[;...] 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). --- package.json | 3 +- src/gui/dev-server.js | 99 +++++++++++++++++++++++++++++----- src/gui/utils.js | 69 ++++++++++++++++++------ src/gui/webpack/BaseConfig.cjs | 36 ++++++++++++- tools/start.mjs | 80 +++++++++++++++++++++++++++ 5 files changed, 253 insertions(+), 34 deletions(-) create mode 100644 tools/start.mjs diff --git a/package.json b/package.json index 2cdaf7cae..986ae4d35 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/gui/dev-server.js b/src/gui/dev-server.js index 747e69ebb..82166d031 100644 --- a/src/gui/dev-server.js +++ b/src/gui/dev-server.js @@ -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=` points the GUI at a remote Puter backend, +// e.g. `--server=puter.com` or `--server=http://puter.localhost:4100`. +// `--extensions=[;...]` 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.`; 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 }; diff --git a/src/gui/utils.js b/src/gui/utils.js index 1cc6fe506..463db70ff 100644 --- a/src/gui/utils.js +++ b/src/gui/utils.js @@ -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 += ''; // canonical url - h += ``; + h += ``; + // PROD: bundled stylesheet + if ( options.env === 'prod' ) { + h += ''; + } // 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 += ``; } @@ -293,6 +294,26 @@ function generateDevHtml (options) { // preload images when applicable h += ''; + + // service-script hook: initgui resolves this promise during boot, so the + // shim must exist before the bundle runs (same shim the backend injects). + h += ` + `; + h += ''; h += ''; @@ -336,9 +357,12 @@ function generateDevHtml (options) { h += ''; } - // 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 += ''; + h += ''; + h += ''; } // 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(/ window.addEventListener('load', function() {`; - h += 'gui()'; + h += `gui(${guiParamsJson})`; h += `}); `; diff --git a/src/gui/webpack/BaseConfig.cjs b/src/gui/webpack/BaseConfig.cjs index 624b15429..b04fa54e8 100644 --- a/src/gui/webpack/BaseConfig.cjs +++ b/src/gui/webpack/BaseConfig.cjs @@ -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'), diff --git a/tools/start.mjs b/tools/start.mjs new file mode 100644 index 000000000..e4e8f22aa --- /dev/null +++ b/tools/start.mjs @@ -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.`; +// a full origin (or an `api.`-prefixed host) is used verbatim. +// +// `--extensions=[;...]` 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_ 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); +}