@lobehub/chat
Version:
Lobe Chat - an open-source, high-performance chatbot framework that supports speech synthesis, multimodal, and extensible Function Call plugin system. Supports one-click free deployment of your private ChatGPT/LLM web application.
422 lines (354 loc) • 13.2 kB
text/typescript
// copy from https://github.com/kirill-konshin/next-electron-rsc
import { serialize as serializeCookie } from 'cookie';
import { type Protocol, type Session, protocol } from 'electron';
import type { NextConfig } from 'next';
import type NextNodeServer from 'next/dist/server/next-server';
import assert from 'node:assert';
import { IncomingMessage, ServerResponse } from 'node:http';
import { Socket } from 'node:net';
import path from 'node:path';
import { parse } from 'node:url';
import resolve from 'resolve';
import { parse as parseCookie, splitCookiesString } from 'set-cookie-parser';
import { LOCAL_STORAGE_URL_PREFIX } from '@/const/dir';
import { isDev } from '@/const/env';
import { createLogger } from '@/utils/logger';
// 创建日志记录器
const logger = createLogger('utils:next-electron-rsc');
// 定义自定义处理器类型
export type CustomRequestHandler = (request: Request) => Promise<Response | null | undefined>;
export const createRequest = async ({
socket,
request,
session,
}: {
request: Request;
session: Session;
socket: Socket;
}): Promise<IncomingMessage> => {
const req = new IncomingMessage(socket);
const url = new URL(request.url);
// Normal Next.js URL does not contain schema and host/port, otherwise endless loops due to butchering of schema by normalizeRepeatedSlashes in resolve-routes
req.url = url.pathname + (url.search || '');
req.method = request.method;
request.headers.forEach((value, key) => {
req.headers[key] = value;
});
try {
// @see https://github.com/electron/electron/issues/39525#issue-1852825052
const cookies = await session.cookies.get({
url: request.url,
// domain: url.hostname,
// path: url.pathname,
// `secure: true` Cookies should not be sent via http
// secure: url.protocol === 'http:' ? false : undefined,
// theoretically not possible to implement sameSite because we don't know the url
// of the website that is requesting the resource
});
if (cookies.length) {
const cookiesHeader = [];
for (const cookie of cookies) {
const { name, value } = cookie;
cookiesHeader.push(serializeCookie(name, value));
}
req.headers.cookie = cookiesHeader.join('; ');
}
} catch (e) {
throw new Error('Failed to parse cookies', { cause: e });
}
if (request.body) {
req.push(Buffer.from(await request.arrayBuffer()));
}
req.push(null);
req.complete = true;
return req;
};
export class ReadableServerResponse extends ServerResponse {
private responsePromise: Promise<Response>;
constructor(req: IncomingMessage) {
super(req);
this.responsePromise = new Promise<Response>((resolve) => {
const readableStream = new ReadableStream({
cancel: () => {},
pull: () => {
this.emit('drain');
},
start: (controller) => {
let onData;
this.on(
'data',
(onData = (chunk) => {
controller.enqueue(chunk);
}),
);
this.once('end', (chunk) => {
controller.enqueue(chunk);
controller.close();
this.off('data', onData);
});
},
});
this.once('writeHead', (statusCode) => {
resolve(
new Response(readableStream, {
headers: this.getHeaders() as any,
status: statusCode,
statusText: this.statusMessage,
}),
);
});
});
}
write(chunk: any, ...args): boolean {
this.emit('data', chunk);
return super.write(chunk, ...args);
}
end(chunk: any, ...args): this {
this.emit('end', chunk);
return super.end(chunk, ...args);
}
writeHead(statusCode: number, ...args: any): this {
this.emit('writeHead', statusCode);
return super.writeHead(statusCode, ...args);
}
getResponse() {
return this.responsePromise;
}
}
/**
* https://nextjs.org/docs/pages/building-your-application/configuring/custom-server
* https://github.com/vercel/next.js/pull/68167/files#diff-d0d8b7158bcb066cdbbeb548a29909fe8dc4e98f682a6d88654b1684e523edac
* https://github.com/vercel/next.js/blob/canary/examples/custom-server/server.ts
*
* @param {string} standaloneDir
* @param {string} localhostUrl
* @param {import('electron').Protocol} protocol
* @param {boolean} debug
*/
export function createHandler({
standaloneDir,
localhostUrl,
protocol,
debug = false,
}: {
debug?: boolean;
localhostUrl: string;
protocol: Protocol;
standaloneDir: string;
}) {
assert(standaloneDir, 'standaloneDir is required');
assert(protocol, 'protocol is required');
// 存储自定义请求处理器的数组
const customHandlers: CustomRequestHandler[] = [];
// 注册自定义请求处理器的方法 - 在开发和生产环境中都提供此功能
function registerCustomHandler(handler: CustomRequestHandler) {
logger.debug('Registering custom request handler');
customHandlers.push(handler);
return () => {
const index = customHandlers.indexOf(handler);
if (index !== -1) {
logger.debug('Unregistering custom request handler');
customHandlers.splice(index, 1);
}
};
}
let registerProtocolHandle = false;
let interceptorCount = 0; // 追踪活跃的拦截器数量
protocol.registerSchemesAsPrivileged([
{
privileges: {
secure: true,
standard: true,
supportFetchAPI: true,
},
scheme: 'http',
},
]);
logger.debug('Registered HTTP scheme as privileged');
// 初始化 Next.js 应用(仅在生产环境中使用)
let app: NextNodeServer | null = null;
let handler: any = null;
let preparePromise: Promise<void> | null = null;
if (!isDev) {
logger.info('Initializing Next.js app for production');
const next = require(resolve.sync('next', { basedir: standaloneDir }));
// @see https://github.com/vercel/next.js/issues/64031#issuecomment-2078708340
const config = require(path.join(standaloneDir, '.next', 'required-server-files.json'))
.config as NextConfig;
process.env.__NEXT_PRIVATE_STANDALONE_CONFIG = JSON.stringify(config);
app = next({
dev: false,
dir: standaloneDir,
}) as NextNodeServer;
handler = app.getRequestHandler();
preparePromise = app.prepare();
} else {
logger.debug('Starting in development mode');
}
// 通用的请求处理函数 - 开发和生产环境共用
const handleRequest = async (
request: Request,
session: Session,
socket: Socket,
): Promise<Response> => {
try {
// 检查是否是本地文件服务请求,如果是则跳过处理
const url = new URL(request.url);
if (url.pathname.startsWith(LOCAL_STORAGE_URL_PREFIX + '/')) {
if (debug) logger.debug(`Skipping local file service request: ${request.url}`);
// 直接使用 fetch 转发请求到本地文件服务
return fetch(request);
}
// 先尝试使用自定义处理器处理请求
for (const customHandler of customHandlers) {
try {
const response = await customHandler(request);
if (response) {
if (debug) logger.debug(`Custom handler processed: ${request.url}`);
return response;
}
} catch (error) {
if (debug) logger.error(`Custom handler error: ${error}`);
// 继续尝试下一个处理器
}
}
// 创建 Node.js 请求对象
const req = await createRequest({ request, session, socket });
// 创建可读取响应的 Response 对象
const res = new ReadableServerResponse(req);
if (isDev) {
// 开发环境:转发请求到开发服务器
if (debug) logger.debug(`Forwarding request to dev server: ${request.url}`);
// 修改 URL 以指向开发服务器
const devUrl = new URL(req.url, localhostUrl);
// 使用 node:http 模块发送请求到开发服务器
const http = require('node:http');
const devReq = http.request(
{
headers: req.headers,
hostname: devUrl.hostname,
method: req.method,
path: devUrl.pathname + (devUrl.search || ''),
port: devUrl.port,
},
(devRes) => {
// 设置响应状态码和头部
res.statusCode = devRes.statusCode;
res.statusMessage = devRes.statusMessage;
// 复制响应头
Object.keys(devRes.headers).forEach((key) => {
res.setHeader(key, devRes.headers[key]);
});
// 流式传输响应内容
devRes.pipe(res);
},
);
// 处理错误
devReq.on('error', (err) => {
if (debug) logger.error(`Error forwarding request: ${err}`);
});
// 传输请求体
req.pipe(devReq);
} else {
// 生产环境:使用 Next.js 处理请求
if (debug) logger.debug(`Processing with Next.js handler: ${request.url}`);
// 确保 Next.js 已准备就绪
if (preparePromise) await preparePromise;
const url = parse(req.url, true);
handler(req, res, url);
}
// 获取 Response 对象
const response = await res.getResponse();
// 处理 cookies(两种环境通用处理)
try {
const cookies = parseCookie(
response.headers.getSetCookie().reduce((r, c) => {
return [...r, ...splitCookiesString(c)];
}, []),
);
for (const cookie of cookies) {
let expirationDate: number | undefined;
if (cookie.expires) {
// expires 是 Date 对象,转换为秒级时间戳
expirationDate = Math.floor(cookie.expires.getTime() / 1000);
} else if (cookie.maxAge) {
// maxAge 是秒数,计算过期时间戳
expirationDate = Math.floor(Date.now() / 1000) + cookie.maxAge;
}
// 如果都没有,则为 session cookie,不设置 expirationDate
// 检查是否已过期
if (expirationDate && expirationDate < Math.floor(Date.now() / 1000)) {
await session.cookies.remove(request.url, cookie.name);
continue;
}
await session.cookies.set({
domain: cookie.domain,
expirationDate,
httpOnly: cookie.httpOnly,
name: cookie.name,
path: cookie.path,
secure: cookie.secure,
url: request.url,
value: cookie.value,
} as any);
}
} catch (e) {
logger.error('Failed to set cookies', e);
}
if (debug) logger.debug(`Request processed: ${request.url}, status: ${response.status}`);
return response;
} catch (e) {
if (debug) logger.error(`Error handling request: ${e}`);
return new Response(e.message, { status: 500 });
}
};
// 创建拦截器函数
const createInterceptor = ({ session }: { session: Session }) => {
assert(session, 'Session is required');
logger.debug(
`Creating interceptor with session in ${isDev ? 'development' : 'production'} mode`,
);
const socket = new Socket();
interceptorCount++; // 增加拦截器计数
const closeSocket = () => socket.end();
process.on('SIGTERM', () => closeSocket);
process.on('SIGINT', () => closeSocket);
if (!registerProtocolHandle) {
logger.debug(
`Registering HTTP protocol handler in ${isDev ? 'development' : 'production'} mode`,
);
protocol.handle('http', async (request) => {
if (!isDev) {
// 检查是否是本地文件服务请求,如果是则允许通过
const isLocalhost = request.url.startsWith(localhostUrl);
const url = new URL(request.url);
const isLocalIP =
request.url.startsWith('http://127.0.0.1:') ||
request.url.startsWith('http://localhost:');
const isLocalFileService = url.pathname.startsWith(LOCAL_STORAGE_URL_PREFIX + '/');
const valid = isLocalhost || (isLocalIP && isLocalFileService);
if (!valid) {
throw new Error('External HTTP not supported, use HTTPS');
}
}
return handleRequest(request, session, socket);
});
registerProtocolHandle = true;
}
logger.debug(`Active interceptors count: ${interceptorCount}`);
return function stopIntercept() {
interceptorCount--; // 减少拦截器计数
logger.debug(`Stopping interceptor, remaining count: ${interceptorCount}`);
// 只有当没有活跃的拦截器时才取消注册协议处理器
if (registerProtocolHandle && interceptorCount === 0) {
logger.debug('Unregistering HTTP protocol handler (no active interceptors)');
protocol.unhandle('http');
registerProtocolHandle = false;
}
process.off('SIGTERM', () => closeSocket);
process.off('SIGINT', () => closeSocket);
closeSocket();
};
};
return { createInterceptor, registerCustomHandler };
}