Ctrl K
ComunidadGuías…

electron/main/brainStreamRelay.ts

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

Archivo original ↗Preguntar a KiBot
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//     http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========

import http from 'node:http';
import {
  BRAIN_STREAM_RELAY_CANCEL_CHANNEL,
  BRAIN_STREAM_RELAY_DISABLE_ENV,
  BRAIN_STREAM_RELAY_OPEN_CHANNEL,
  BRAIN_STREAM_RELAY_READ_CHANNEL,
  BRAIN_STREAM_RELAY_TARGET_CHANNEL,
  brainStreamRelayOrigins,
  validateBrainStreamRelayRequest,
  type BrainStreamRelayOpenResult,
  type BrainStreamRelayReadResult,
  type BrainStreamRelayRequest,
  type BrainStreamRelayTarget,
} from '../../src/shared/brainStreamRelay';

/**
 * Relays Brain event streams (see src/shared/brainStreamRelay.ts) from the
 * main process. Main reads ahead into a bounded buffer per stream, like the
 * browser's own network buffer, and the renderer pulls everything buffered
 * with each read. When the buffer is full, main stops reading the socket and
 * TCP pushes back on the Brain.
 */

const DEFAULT_UNREAD_TIMEOUT_MS = 60_000;
const DEFAULT_MAX_STREAMS_PER_SENDER = 64;
const DEFAULT_READ_AHEAD_BYTES = 1024 * 1024;
const MAX_REMEMBERED_CANCELLATIONS = 128;
// Browsers never expose these to page scripts.
const HIDDEN_RESPONSE_HEADERS = new Set(['set-cookie', 'set-cookie2']);
const SENDER_TEARDOWN_EVENTS = ['did-navigate', 'render-process-gone'] as const;

/** The parts of a WebContents the relay uses. */
export interface BrainStreamRelaySender {
  readonly id: number;
  isDestroyed(): boolean;
  on(event: string, listener: (...args: any[]) => void): unknown;
  removeListener(event: string, listener: (...args: any[]) => void): unknown;
}

export interface BrainStreamRelayEvent {
  sender: BrainStreamRelaySender;
  senderFrame?: unknown;
}

interface IpcMainHandlers {
  handle(
    channel: string,
    listener: (event: any, ...args: any[]) => unknown
  ): void;
  removeHandler(channel: string): void;
}

export interface BrainStreamRelayOptions {
  ipcMain: IpcMainHandlers;
  /** Port of the Brain process this app started and still runs, or null. */
  getManagedBackendPort: () => number | null;
  /** Whether the IPC event comes from the main renderer's top-level frame. */
  isTrustedSender: (event: BrainStreamRelayEvent) => boolean;
  enabled?: boolean;
  /** Close a stream when its renderer has not asked for data for this long. */
  unreadTimeoutMs?: number;
  maxStreamsPerSender?: number;
  /** Bytes buffered per stream before main stops reading its socket. */
  readAheadBytes?: number;
  /** Address the Brain listens on. uvicorn binds 127.0.0.1 by default. */
  connectHost?: string;
  /** Receives short diagnostics only: never URLs, headers or bodies. */
  log?: { warn: (message: string) => void };
}

export interface BrainStreamRelay {
  activeStreamCount(senderId?: number): number;
  closeSender(senderId: number): void;
  closeAll(): void;
  dispose(): void;
}

interface RelayStream {
  readonly key: string;
  readonly sender: BrainStreamRelaySender;
  readonly request: http.ClientRequest;
  response: http.IncomingMessage | null;
  /** Body bytes received but not yet read by the renderer. */
  buffered: Buffer[];
  bufferedBytes: number;
  /** The upstream body ended normally, or failed. */
  upstream: 'open' | 'ended' | 'failed';
  settleOpen: ((result: BrainStreamRelayOpenResult) => void) | null;
  settleRead: ((result: BrainStreamRelayReadResult) => void) | null;
  unreadTimer: ReturnType<typeof setTimeout> | null;
  closed: boolean;
}

const DECLINED: BrainStreamRelayOpenResult = { ok: false, reason: 'declined' };
const UNAVAILABLE: BrainStreamRelayOpenResult = {
  ok: false,
  reason: 'unavailable',
};

export function isBrainStreamRelayDisabled(
  env: Record<string, string | undefined> = process.env
): boolean {
  const value = env[BRAIN_STREAM_RELAY_DISABLE_ENV]?.trim().toLowerCase();
  return value === '1' || value === 'true';
}

function responseHeaderPairs(rawHeaders: string[]): Array<[string, string]> {
  const pairs: Array<[string, string]> = [];
  for (let index = 0; index + 1 < rawHeaders.length; index += 2) {
    const name = rawHeaders[index];
    if (HIDDEN_RESPONSE_HEADERS.has(name.toLowerCase())) continue;
    pairs.push([name, rawHeaders[index + 1]]);
  }
  return pairs;
}

/**
 * Socket chunks can be views into a larger pooled buffer, and IPC serializes
 * a view's whole backing store. Copy into an exact-size array so only this
 * stream's bytes leave main.
 */
function takeBuffered(stream: RelayStream): Uint8Array {
  const bytes = new Uint8Array(stream.bufferedBytes);
  let offset = 0;
  for (const chunk of stream.buffered) {
    bytes.set(chunk, offset);
    offset += chunk.byteLength;
  }
  stream.buffered = [];
  stream.bufferedBytes = 0;
  return bytes;
}

export function registerBrainStreamRelay(
  options: BrainStreamRelayOptions
): BrainStreamRelay {
  const { ipcMain, getManagedBackendPort, isTrustedSender, log } = options;
  const enabled = options.enabled ?? true;
  const unreadTimeoutMs = options.unreadTimeoutMs ?? DEFAULT_UNREAD_TIMEOUT_MS;
  const maxStreamsPerSender =
    options.maxStreamsPerSender ?? DEFAULT_MAX_STREAMS_PER_SENDER;
  const readAheadBytes = options.readAheadBytes ?? DEFAULT_READ_AHEAD_BYTES;
  const connectHost = options.connectHost ?? '127.0.0.1';
  // One socket per stream that closes with it, so closing a stream always
  // disconnects its Brain subscriber. This agent has no per-host limit.
  const agent = new http.Agent({ keepAlive: false });
  const streams = new Map<string, RelayStream>();
  const watchedSenders = new Map<number, () => void>();
  const cancelledBeforeOpen = new Set<string>();

  const streamKey = (senderId: number, streamId: string) =>
    `${senderId}:${streamId}`;

  function assertTrustedSender(event: BrainStreamRelayEvent): void {
    if (!isTrustedSender(event)) {
      throw new Error('Stream relay is restricted to the main renderer');
    }
  }

  function allowedOrigins(): string[] {
    if (!enabled) return [];
    const port = getManagedBackendPort();
    return port === null ? [] : brainStreamRelayOrigins(port);
  }

  function clearUnreadTimer(stream: RelayStream): void {
    if (stream.unreadTimer) clearTimeout(stream.unreadTimer);
    stream.unreadTimer = null;
  }

  function armUnreadTimer(stream: RelayStream): void {
    clearUnreadTimer(stream);
    stream.unreadTimer = setTimeout(() => {
      log?.warn('[StreamRelay] Closed a stream that was no longer read');
      closeStream(stream, 'closed');
    }, unreadTimeoutMs);
    stream.unreadTimer.unref?.();
  }

  /** Abandon the upstream request; the Brain sees the socket close. */
  function closeStream(stream: RelayStream, reason: 'network' | 'closed') {
    if (stream.closed) return;
    stream.closed = true;
    streams.delete(stream.key);
    clearUnreadTimer(stream);
    const settleOpen = stream.settleOpen;
    const settleRead = stream.settleRead;
    stream.settleOpen = null;
    stream.settleRead = null;
    stream.buffered = [];
    stream.bufferedBytes = 0;
    settleOpen?.({ ok: false, reason });
    settleRead?.({ type: 'error', reason });
    stream.request.destroy();
    stream.response?.destroy();
  }

  /** The upstream body ended normally; its socket is already closing. */
  function finishStream(stream: RelayStream): void {
    stream.closed = true;
    streams.delete(stream.key);
    clearUnreadTimer(stream);
  }

  /**
   * Answer a pending renderer read: buffered bytes first, in order, then the
   * end of the body or its failure.
   */
  function deliver(stream: RelayStream): void {
    const settleRead = stream.settleRead;
    if (stream.closed || !settleRead) return;
    if (stream.bufferedBytes > 0) {
      stream.settleRead = null;
      settleRead({ type: 'data', chunk: takeBuffered(stream) });
      if (stream.upstream === 'open') stream.response?.resume();
      armUnreadTimer(stream);
      return;
    }
    if (stream.upstream === 'ended') {
      stream.settleRead = null;
      finishStream(stream);
      settleRead({ type: 'end' });
      return;
    }
    if (stream.upstream === 'failed') closeStream(stream, 'network');
  }

  /** The body failed after its head; bytes already received still go first. */
  function failUpstream(stream: RelayStream): void {
    if (stream.upstream !== 'open') return;
    stream.upstream = 'failed';
    deliver(stream);
  }

  function closeSender(senderId: number): void {
    for (const stream of [...streams.values()]) {
      if (stream.sender.id === senderId) closeStream(stream, 'closed');
    }
  }

  function unwatchSender(senderId: number): void {
    watchedSenders.get(senderId)?.();
    watchedSenders.delete(senderId);
  }

  /** Close a renderer's streams when its document goes away. */
  function watchSender(sender: BrainStreamRelaySender): void {
    if (watchedSenders.has(sender.id)) return;
    const closeAllForSender = () => closeSender(sender.id);
    const onDestroyed = () => {
      closeAllForSender();
      unwatchSender(sender.id);
    };
    for (const event of SENDER_TEARDOWN_EVENTS) {
      sender.on(event, closeAllForSender);
    }
    sender.on('destroyed', onDestroyed);
    watchedSenders.set(sender.id, () => {
      for (const event of SENDER_TEARDOWN_EVENTS) {
        sender.removeListener(event, closeAllForSender);
      }
      sender.removeListener('destroyed', onDestroyed);
    });
  }

  function countFor(senderId: number): number {
    let count = 0;
    for (const stream of streams.values()) {
      if (stream.sender.id === senderId) count += 1;
    }
    return count;
  }

  function rememberCancellation(key: string): void {
    cancelledBeforeOpen.add(key);
    if (cancelledBeforeOpen.size > MAX_REMEMBERED_CANCELLATIONS) {
      const oldest = cancelledBeforeOpen.values().next().value;
      if (oldest !== undefined) cancelledBeforeOpen.delete(oldest);
    }
  }

  function startStream(
    sender: BrainStreamRelaySender,
    key: string,
    request: BrainStreamRelayRequest,
    url: URL
  ): Promise<BrainStreamRelayOpenResult> {
    const body =
      request.body === undefined
        ? undefined
        : Buffer.from(request.body, 'utf8');
    const headers: http.OutgoingHttpHeaders = {
      ...request.headers,
      host: url.host,
      // Node does not decode bodies; ask for the bytes as they are.
      'accept-encoding': 'identity',
    };
    if (request.method === 'POST') {
      headers['content-length'] = body?.byteLength ?? 0;
    }

    let upstream: http.ClientRequest;
    try {
      upstream = http.request({
        host: connectHost,
        port: Number(url.port),
        method: request.method,
        path: `${url.pathname}${url.search}`,
        headers,
        agent,
      });
    } catch {
      return Promise.resolve(DECLINED);
    }

    return new Promise((resolve) => {
      const stream: RelayStream = {
        key,
        sender,
        request: upstream,
        response: null,
        buffered: [],
        bufferedBytes: 0,
        upstream: 'open',
        settleOpen: resolve,
        settleRead: null,
        unreadTimer: null,
        closed: false,
      };
      streams.set(key, stream);

      upstream.on('response', (response) => {
        const fail = () => failUpstream(stream);
        response.on('error', fail);
        if (stream.closed) {
          response.destroy();
          return;
        }
        stream.response = response;
        response.on('data', (chunk: Buffer) => {
          if (stream.closed) return;
          stream.buffered.push(chunk);
          stream.bufferedBytes += chunk.byteLength;
          if (stream.bufferedBytes >= readAheadBytes) response.pause();
          deliver(stream);
        });
        response.on('end', () => {
          if (stream.upstream !== 'open') return;
          stream.upstream = 'ended';
          deliver(stream);
        });
        // A body cut short (socket closed before its end) is a failure.
        response.on('close', fail);
        const settleOpen = stream.settleOpen;
        stream.settleOpen = null;
        settleOpen?.({
          ok: true,
          status: response.statusCode ?? 0,
          statusText: response.statusMessage ?? '',
          headers: responseHeaderPairs(response.rawHeaders),
        });
        armUnreadTimer(stream);
      });
      upstream.on('error', () => {
        if (stream.response) failUpstream(stream);
        else closeStream(stream, 'network');
      });
      upstream.end(body);
    });
  }

  async function open(
    event: BrainStreamRelayEvent,
    value: unknown
  ): Promise<BrainStreamRelayOpenResult> {
    assertTrustedSender(event);
    if (!enabled) return DECLINED;
    const port = getManagedBackendPort();
    // No Brain is running (it may be restarting): like a refused connection.
    if (port === null) return UNAVAILABLE;
    const sender = event.sender;
    const validation = validateBrainStreamRelayRequest(
      value,
      brainStreamRelayOrigins(port)
    );
    if (!validation.ok) {
      log?.warn(`[StreamRelay] Declined a stream: ${validation.reason}`);
      return DECLINED;
    }
    const { request, url } = validation;
    const key = streamKey(sender.id, request.streamId);
    if (cancelledBeforeOpen.delete(key)) {
      return { ok: false, reason: 'closed' };
    }
    if (streams.has(key) || sender.isDestroyed()) return DECLINED;
    if (countFor(sender.id) >= maxStreamsPerSender) {
      log?.warn('[StreamRelay] Declined a stream: too many open streams');
      return DECLINED;
    }
    watchSender(sender);
    return startStream(sender, key, request, url);
  }

  async function read(
    event: BrainStreamRelayEvent,
    streamId: unknown
  ): Promise<BrainStreamRelayReadResult> {
    assertTrustedSender(event);
    const stream =
      typeof streamId === 'string'
        ? streams.get(streamKey(event.sender.id, streamId))
        : undefined;
    // Reads are strictly sequential: the renderer asks again only after the
    // previous chunk arrived.
    if (!stream?.response || stream.settleRead) {
      return { type: 'error', reason: 'closed' };
    }
    clearUnreadTimer(stream);
    return new Promise((resolve) => {
      stream.settleRead = resolve;
      deliver(stream);
    });
  }

  function cancel(event: BrainStreamRelayEvent, streamId: unknown): boolean {
    assertTrustedSender(event);
    if (typeof streamId !== 'string') return false;
    const key = streamKey(event.sender.id, streamId);
    const stream = streams.get(key);
    if (!stream) {
      // Cancellation may overtake an open that has not been handled yet.
      rememberCancellation(key);
      return false;
    }
    closeStream(stream, 'closed');
    return true;
  }

  function target(event: BrainStreamRelayEvent): BrainStreamRelayTarget | null {
    assertTrustedSender(event);
    const origins = allowedOrigins();
    return origins.length > 0 ? { origins } : null;
  }

  ipcMain.handle(BRAIN_STREAM_RELAY_TARGET_CHANNEL, target);
  ipcMain.handle(BRAIN_STREAM_RELAY_OPEN_CHANNEL, open);
  ipcMain.handle(BRAIN_STREAM_RELAY_READ_CHANNEL, read);
  ipcMain.handle(BRAIN_STREAM_RELAY_CANCEL_CHANNEL, cancel);

  function closeAll(): void {
    for (const stream of [...streams.values()]) closeStream(stream, 'closed');
  }

  return {
    activeStreamCount(senderId?: number): number {
      return senderId === undefined ? streams.size : countFor(senderId);
    },
    closeSender,
    closeAll,
    dispose(): void {
      for (const channel of [
        BRAIN_STREAM_RELAY_TARGET_CHANNEL,
        BRAIN_STREAM_RELAY_OPEN_CHANNEL,
        BRAIN_STREAM_RELAY_READ_CHANNEL,
        BRAIN_STREAM_RELAY_CANCEL_CHANNEL,
      ]) {
        ipcMain.removeHandler(channel);
      }
      closeAll();
      for (const senderId of [...watchedSenders.keys()]) {
        unwatchSender(senderId);
      }
      cancelledBeforeOpen.clear();
      agent.destroy();
    },
  };
}