Ctrl K
ComunidadGuías…

server.js

Rama master · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
// 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.`);
});