server.js
Rama master · réplica de lectura del repositorio original.
// Runs AppBlips in a browser instead of the desktop app: serves the built
// client (dist/) and answers POST /api/chat with the same handler the desktop
// app uses. Started by `npm start` (from source) and by the Docker image.
//
// Single-user like the desktop app: /api/chat has no sign-in and spends the
// configured key, so this listens on 127.0.0.1 unless HOST says otherwise
// (the Docker image listens on 0.0.0.0 inside the container, and
// docker-compose.yml publishes it on 127.0.0.1 only). Projects and the
// Settings → AI provider choice live in the browser's own storage, as in
// `npm run dev`; the provider's API key is kept here instead (keyFile.js), so
// the page never stores it.
//
// Only Node built-ins plus electron/server, so the Docker image ships it as
// one bundled file (scripts/build-server.js) with no node_modules.
import { createServer } from 'node:http';
import { createReadStream, existsSync } from 'node:fs';
import { rm, stat } from 'node:fs/promises';
import { extname, join, normalize, sep } from 'node:path';
import { fileURLToPath } from 'node:url';
import { handleChatProxy } from './electron/server/chatProxy.js';
import { describeConfig, formatConfigSummary } from './electron/server/configSummary.js';
import { toChatRequest, toFetchRequest, sendWebResponse } from './electron/server/nodeHttp.js';
import { createKeyFile, defaultKeyFile } from './electron/server/keyFile.js';
import { handleProviderApi } from './electron/server/providerApi.js';
import { handleSurgeApi } from './electron/server/surgeApi.js';
import { handlePollinationsApi } from './electron/server/pollinationsApi.js';
import { handlePixabayApi } from './electron/server/pixabayApi.js';
import { handleTavilyApi } from './electron/server/tavilyApi.js';
import { handleSeoScanApi } from './electron/server/seoScanApi.js';
import { withSavedProvider } from './electron/server/savedProvider.js';
import { hostAllowed } from './electron/server/hosts.js';
import { SECURITY_HEADERS, MIME_TYPES } from './electron/server/staticFiles.js';
import { resolveAddresses } from './electron/server/dnsLookup.js';
// A .env in the folder it starts from, when there is one. Docker passes its
// settings as real environment variables instead; those win over the file.
try {
process.loadEnvFile();
} catch (err) {
// No .env is normal: environment only.
if (err.code !== 'ENOENT') console.warn('[server] could not read .env:', err);
}
const DIST_DIR = fileURLToPath(new URL('./dist', import.meta.url));
const INDEX_FILE = join(DIST_DIR, 'index.html');
const PORT = Number(process.env.PORT) || 3000;
const HOST = process.env.HOST || '127.0.0.1';
const keys = createKeyFile({ file: defaultKeyFile() });
const surgeTokens = createKeyFile({ file: defaultKeyFile(process.env, 'surge-token.json') });
const pollinationsKeys = createKeyFile({ file: defaultKeyFile(process.env, 'pollinations-key.json') });
const pixabayKeys = createKeyFile({ file: defaultKeyFile(process.env, 'pixabay-key.json') });
const tavilyKeys = createKeyFile({ file: defaultKeyFile(process.env, 'tavily-key.json') });
// AppBlips AI Credits were removed; delete the sign-in token older versions
// saved, so no credential is left behind that nothing uses.
rm(defaultKeyFile(process.env, 'credits-key.json'), { force: true }).catch(() => {});
const sendText = (res, status, text, headers = {}) => {
res.writeHead(status, { ...SECURITY_HEADERS, 'Content-Type': 'text/plain; charset=utf-8', ...headers });
res.end(text);
};
const sendJson = (res, status, body) => {
res.writeHead(status, { ...SECURITY_HEADERS, 'Content-Type': 'application/json' });
res.end(JSON.stringify(body));
};
async function serveStatic(req, res) {
let requested;
try {
requested = decodeURIComponent(new URL(req.url, 'http://localhost').pathname);
} catch {
sendText(res, 400, 'Bad request');
return;
}
const safePath = normalize(join(DIST_DIR, requested));
if (!safePath.startsWith(DIST_DIR + sep) && safePath !== DIST_DIR) {
sendText(res, 400, 'Bad request');
return;
}
let filePath = requested === '/' ? INDEX_FILE : safePath;
let info;
try {
info = await stat(filePath);
if (info.isDirectory()) {
filePath = join(filePath, 'index.html');
info = await stat(filePath);
}
} catch {
filePath = INDEX_FILE; // SPA fallback
info = await stat(filePath);
}
// Vite's hashed bundles never change under the same name; everything else
// (index.html above all) is revalidated so an update shows up at once.
const cacheControl = filePath === INDEX_FILE
? 'no-cache'
: filePath.startsWith(join(DIST_DIR, 'assets') + sep)
? 'public, max-age=31536000, immutable'
: 'public, max-age=3600';
res.writeHead(200, {
...SECURITY_HEADERS,
'Content-Type': MIME_TYPES[extname(filePath)] || 'application/octet-stream',
'Content-Length': info.size,
'Cache-Control': cacheControl,
});
if (req.method === 'HEAD') {
res.end();
return;
}
createReadStream(filePath).pipe(res);
}
const server = createServer((req, res) => {
if (!hostAllowed(req.headers.host || '')) {
sendText(res, 403, `This address isn't allowed. To open AppBlips at ${req.headers.host || 'this address'}, add it to APPBLIPS_ALLOWED_HOSTS and restart.`);
return;
}
const fail = (err) => {
if (err?.status === 413 && !res.headersSent) {
sendJson(res, 413, { error: err.message });
return;
}
console.error('[server] request failed:', req.url, err);
if (!res.headersSent) sendText(res, 500, 'Internal error.');
else res.destroy();
};
if (req.url === '/api/chat') {
if (req.method !== 'POST') {
sendText(res, 405, 'Method not allowed');
return;
}
const handle = async () => {
const request = await withSavedProvider(await toChatRequest(req, res), keys);
const response = await handleChatProxy(request, process.env);
sendWebResponse(res, response, SECURITY_HEADERS, '[server]');
};
handle().catch(fail);
return;
}
if (req.url === '/api/provider' || req.url.startsWith('/api/provider/')) {
toFetchRequest(req, res)
.then((request) => handleProviderApi(request, process.env, keys))
.then((response) => sendWebResponse(res, response, SECURITY_HEADERS, '[server]'))
.catch(fail);
return;
}
if (req.url === '/api/surge' || req.url.startsWith('/api/surge/')) {
toFetchRequest(req, res)
.then((request) => handleSurgeApi(request, process.env, surgeTokens))
.then((response) => sendWebResponse(res, response, SECURITY_HEADERS, '[server]'))
.catch(fail);
return;
}
if (req.url === '/api/pollinations' || req.url.startsWith('/api/pollinations/')) {
toFetchRequest(req, res)
.then((request) => handlePollinationsApi(request, process.env, pollinationsKeys))
.then((response) => sendWebResponse(res, response, SECURITY_HEADERS, '[server]'))
.catch(fail);
return;
}
if (req.url === '/api/pixabay' || req.url.startsWith('/api/pixabay/')) {
toFetchRequest(req, res)
.then((request) => handlePixabayApi(request, process.env, pixabayKeys))
.then((response) => sendWebResponse(res, response, SECURITY_HEADERS, '[server]'))
.catch(fail);
return;
}
if (req.url === '/api/tavily' || req.url.startsWith('/api/tavily/')) {
toFetchRequest(req, res)
.then((request) => handleTavilyApi(request, process.env, tavilyKeys))
.then((response) => sendWebResponse(res, response, SECURITY_HEADERS, '[server]'))
.catch(fail);
return;
}
if (req.url === '/api/seo/scan') {
toFetchRequest(req, res)
.then((request) => handleSeoScanApi(request, process.env, { lookup: resolveAddresses }))
.then((response) => sendWebResponse(res, response, SECURITY_HEADERS, '[server]'))
.catch(fail);
return;
}
if (req.method !== 'GET' && req.method !== 'HEAD') {
sendText(res, 405, 'Method not allowed');
return;
}
serveStatic(req, res).catch(fail);
});
if (!existsSync(INDEX_FILE)) {
console.error('AppBlips isn\'t built yet: dist/index.html is missing. Run `npm start` (it builds first), or `npm run build` and then `node server.js`.');
process.exit(1);
}
server.on('error', (err) => {
if (err.code === 'EADDRINUSE') {
console.error(`Port ${PORT} is already in use. Stop whatever is using it, or start AppBlips on another port with PORT=3001.`);
} else {
console.error('[server]', err.message);
}
process.exit(1);
});
server.listen(PORT, HOST, () => {
// Inside Docker, HOST is 0.0.0.0 but the address to open is the published
// one; localhost is right for the default compose file.
const shown = HOST === '0.0.0.0' || HOST === '::' || HOST === '127.0.0.1' ? 'localhost' : HOST;
console.log(`\n${formatConfigSummary(describeConfig(process.env))}\n`);
console.log(`AppBlips is running. Open http://${shown}:${process.env.APPBLIPS_PUBLIC_PORT || PORT} in your browser.`);
});