Files
puter/src/gui/dev-server.js
Daniel Salazar 1be5ce45f7
Maintain Release Merge PR / update-release-pr (push) Canceled after 0s
Notify HeyPuter / notify (push) Canceled after 0s
release-please / release-please (push) Canceled after 0s
feat: support dekstop app linking again (#3511)
2026-08-05 14:16:02 -07:00

180 lines
7.9 KiB
JavaScript

/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import express from 'express';
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';
import open from 'open';
import { authmeRequestUrl } from './src/util/authmeGrant.js';
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 — whoarewe, anti-csrf,
// socket.io, and builtin apps are all served there and are CORS-open, so
// the local GUI must point `gui_origin` at it.
//
// Signing in is the exception. `/login` and `/signup` answer with a full
// session token, so they only accept their own origin (`guiOriginGate` on
// the backend) — otherwise any page anywhere could trade a password for a
// session and read it straight out of the response. Instead the GUI bounces
// to `<guiOrigin>/?action=authme&token_type=session&redirectURL=<here>` and
// comes back with a token in the URL: the password is only ever typed on the
// remote's own origin, and the grant needs a type-to-confirm.
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 startServer = (attempt, useAnyFreePort = false) => {
if ( attempt > maxAttempts ) {
useAnyFreePort = true; // Use any port that is free
}
// Express 5 invokes the listen callback on 'error' as well, with the
// error as its first argument — in that case server.address() is null,
// so bail and let the 'error' listener below own the retry.
const server = app.listen(useAnyFreePort ? 0 : port, (err) => {
if ( err ) return;
const guiUrl = `http://localhost:${server.address().port}`;
// Same builder the GUI navigates to when it finds no session, so the
// printed link and the automatic redirect can never disagree.
const signInUrl = authmeRequestUrl(guiOrigin, `${guiUrl}/`, {
fullToken: true,
}).href;
const label = (s) => chalk.dim(s.padEnd(14));
console.log(`\n${chalk.bold(' Puter')} ${chalk.dim('(GUI only — backend is remote)')}\n`);
console.log(` ${label('GUI')}${chalk.underline.blue(guiUrl)}`);
console.log(` ${label('API')}${chalk.underline.blue(apiOrigin)}`);
console.log(` ${label('Accounts')}${chalk.underline.blue(guiOrigin)}`);
console.log(`\n ${label('Sign in')}${chalk.underline.cyan(signInUrl)}`);
console.log(chalk.dim(`\n Opening ${guiUrl} sends you there automatically when you have no\n session. Signing in grants this local GUI a full account session, so\n ${guiOrigin} asks you to confirm before handing it over.\n`));
if ( process.env.PUTER_NO_BROWSER ) return;
// Open the GUI rather than the sign-in link directly: it's idempotent.
// An already-signed-in GUI loads straight to the desktop instead of
// re-prompting for consent on every restart.
open(guiUrl).catch((e) => {
console.log(chalk.dim(` (could not open a browser: ${e.message})`));
});
}).on('error', (err) => {
if ( err.code === 'EADDRINUSE' ) { // Check if the error is because the port is already in use
console.error(chalk.red(`ERROR: Port ${port} is already in use. Trying next port...`));
port++; // Increment the port number
startServer(attempt + 1); // Try the next port
}
});
};
// 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);
app.get(['/', '/app/*splat', '/action/*splat', '/desktop', '/desktop/app/*splat', '/dashboard'], (req, res) => {
res.send(generateDevHtml({
env: env,
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.',
}));
});
// 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(path.join(__dirname, 'dist')));
} else {
app.use(express.static(path.join(__dirname, 'src')));
}
export { app };