This commit is contained in:
2026-01-15 18:59:06 +08:00
parent a5ce44ba70
commit 6a375db5a1
7 changed files with 1343 additions and 36 deletions

View File

@@ -76,7 +76,9 @@
"access": "public" "access": "public"
}, },
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "^3.969.0",
"@kevisual/ha-api": "^0.0.6", "@kevisual/ha-api": "^0.0.6",
"@kevisual/oss": "^0.0.16",
"@kevisual/video-tools": "^0.0.13", "@kevisual/video-tools": "^0.0.13",
"eventemitter3": "^5.0.1", "eventemitter3": "^5.0.1",
"lowdb": "^7.0.1", "lowdb": "^7.0.1",

View File

@@ -116,4 +116,6 @@ export const httpProxy = (req: http.IncomingMessage, res: http.ServerResponse, p
// 处理 POST 请求的请求体(传递数据到目标服务器),end:true 表示当请求体结束时,关闭请求 // 处理 POST 请求的请求体(传递数据到目标服务器),end:true 表示当请求体结束时,关闭请求
// req.pipe(proxyReq, { end: true }); // req.pipe(proxyReq, { end: true });
pipeProxyReq(req, proxyReq, res); pipeProxyReq(req, proxyReq, res);
} }

View File

@@ -1,6 +1,7 @@
import * as http from 'http'; import * as http from 'http';
import * as fs from 'fs'; import * as fs from 'fs';
import { isBun } from './utils.ts'; import { isBun } from './utils.ts';
import { Readable } from 'stream';
/** /**
* 文件流管道传输函数 * 文件流管道传输函数
@@ -25,7 +26,7 @@ export const pipeFileStream = (filePath: string, res: http.ServerResponse) => {
* @param readStream 可读流对象 * @param readStream 可读流对象
* @param res HTTP服务器响应对象 * @param res HTTP服务器响应对象
*/ */
export const pipeStream = (readStream: fs.ReadStream, res: http.ServerResponse) => { export const pipeStream = (readStream: fs.ReadStream | Readable, res: http.ServerResponse) => {
if (isBun) { if (isBun) {
// Bun环境下的流处理方式 // Bun环境下的流处理方式
res.pipe(readStream as any); res.pipe(readStream as any);

View File

@@ -1,3 +1,6 @@
import http from 'node:http';
import { httpProxy } from './http-proxy.ts';
import { s3Proxy } from './s3.ts';
export type ProxyInfo = { export type ProxyInfo = {
/** /**
* 代理路径, 比如/root/home, 匹配的路径 * 代理路径, 比如/root/home, 匹配的路径
@@ -10,7 +13,7 @@ export type ProxyInfo = {
/** /**
* 类型 * 类型
*/ */
type?: 'file' | 'dynamic' | 'minio' | 'http'; type?: 'file' | 'dynamic' | 'minio' | 'http' | 's3';
/** /**
* 目标的 pathname 默认为请求的url.pathname, 设置了pathname则会使用pathname作为请求的url.pathname * 目标的 pathname 默认为请求的url.pathname, 设置了pathname则会使用pathname作为请求的url.pathname
* @default undefined * @default undefined
@@ -23,40 +26,30 @@ export type ProxyInfo = {
*/ */
ws?: boolean; ws?: boolean;
/** /**
* type为file时有效 * type为file时有效
* 索引文件比如index.html type为fileProxy代理有用 设置了索引文件,如果文件不存在,则访问索引文件 * 索引文件比如index.html type为fileProxy代理有用 设置了索引文件,如果文件不存在,则访问索引文件
*/ */
indexPath?: string; indexPath?: string;
/** /**
* type为file时有效 * type为file时有效
* 根路径, 默认是process.cwd(), type为fileProxy代理有用必须为绝对路径 * 根路径, 默认是process.cwd(), type为fileProxy代理有用必须为绝对路径
*/ */
rootPath?: string; rootPath?: string;
s3?: {
bucket: string;
region: string;
accessKeyId: string;
secretAccessKey: string;
endpoint?: string;
}
}; };
export type ApiList = { export const proxy = (req: http.IncomingMessage, res: http.ServerResponse, proxyApi: ProxyInfo) => {
path: string; if (proxyApi.type === 'http' || !proxyApi.type) {
/** return httpProxy(req, res, proxyApi);
* url或者相对路径 }
*/ console.log('proxyApi', proxyApi);
target: string; if (proxyApi.type === 's3') {
/** return s3Proxy(req, res, proxyApi);
* 目标地址 }
*/ }
ws?: boolean;
/**
* 类型
*/
type?: 'static' | 'dynamic' | 'minio';
}[];
/**
[
{
path: '/api/v1/user',
target: 'http://localhost:3000/api/v1/user',
type: 'dynamic',
},
]
*/

View File

@@ -0,0 +1,71 @@
import { S3Client } from '@aws-sdk/client-s3';
import { ProxyInfo } from './proxy.ts';
import http from 'http';
import { OssBase } from '@kevisual/oss/s3.ts';
import { pipeStream } from './pipe.ts';
import { Readable } from 'stream';
const mapS3 = new Map<string, S3Client>();
export const s3Proxy = async (req: http.IncomingMessage, res: http.ServerResponse, proxyApi: ProxyInfo) => {
const s3 = proxyApi.s3;
if (!s3) {
res.statusCode = 500;
res.end('S3 config not found');
return;
}
let findClient = mapS3.get(s3.accessKeyId);
let s3Client: S3Client;
if (findClient) {
s3Client = findClient;
} else {
s3Client = new S3Client({
credentials: {
accessKeyId: s3.accessKeyId,
secretAccessKey: s3.secretAccessKey,
},
region: s3.region,
endpoint: s3.endpoint,
});
mapS3.set(s3.accessKeyId, s3Client);
}
const oss = new OssBase({
client: s3Client!,
bucketName: s3.bucket,
});
const requestUrl = new URL(req.url, 'http://localhost');
const proxyPath = proxyApi.path || '';
let objectPath = requestUrl.pathname.replace(proxyPath + '/', '');
if (objectPath.startsWith(s3.bucket + '/')) {
objectPath = objectPath.replace(s3.bucket + '/', '');
}
oss.getObject(objectPath).then((response) => {
if (!response.Body) {
res.statusCode = 404;
res.end('Object not found');
return;
}
// 设置响应头
if (response.ContentType) {
res.setHeader('Content-Type', response.ContentType);
}
if (response.ContentLength) {
res.setHeader('Content-Length', response.ContentLength);
}
if (response.LastModified) {
res.setHeader('Last-Modified', response.LastModified.toUTCString());
}
if (response.ETag) {
res.setHeader('ETag', response.ETag);
}
// response.Body 已经是 Readable 流,直接 pipe 到 res
// (response.Body as Readable).pipe(res);
pipeStream(response.Body as Readable, res);
}).catch((err) => {
console.error('S3 getObject error:', err);
res.statusCode = 500;
res.end(`S3 error: ${err.message}`);
});
return;
}

View File

@@ -1,4 +1,4 @@
import { fileProxy, httpProxy, createApiProxy, ProxyInfo } from '@/module/assistant/index.ts'; import { fileProxy, httpProxy, createApiProxy, ProxyInfo, proxy } from '@/module/assistant/index.ts';
import http from 'node:http'; import http from 'node:http';
import { LocalProxy } from './local-proxy.ts'; import { LocalProxy } from './local-proxy.ts';
import { assistantConfig, app } from '@/app.ts'; import { assistantConfig, app } from '@/app.ts';
@@ -110,12 +110,19 @@ export const proxyRoute = async (req: http.IncomingMessage, res: http.ServerResp
// client, api, v1, serve 开头的拦截 // client, api, v1, serve 开头的拦截
const apiProxy = _assistantConfig?.api?.proxy || []; const apiProxy = _assistantConfig?.api?.proxy || [];
const defaultApiProxy = createApiProxy(_assistantConfig?.app?.url || 'https://kevisual.cn'); const defaultApiProxy = createApiProxy(_assistantConfig?.app?.url || 'https://kevisual.cn');
const apiBackendProxy = [...apiProxy, ...defaultApiProxy].find((item) => pathname.startsWith(item.path)); const allProxy = [...apiProxy, ...defaultApiProxy];
const apiBackendProxy = allProxy.find((item) => pathname.startsWith(item.path));
// console.log('apiBackendProxy', allProxy, apiBackendProxy, pathname, apiProxy[0].path);
if (apiBackendProxy) { if (apiBackendProxy) {
log.debug('apiBackendProxy', { apiBackendProxy, url: req.url }); log.debug('apiBackendProxy', { apiBackendProxy, url: req.url });
return httpProxy(req, res, { // 设置 CORS 头
// res.setHeader('Access-Control-Allow-Origin', '*');
// res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
// res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Requested-With');
return proxy(req, res, {
path: apiBackendProxy.path, path: apiBackendProxy.path,
target: apiBackendProxy.target, target: apiBackendProxy.target,
...apiBackendProxy
}); });
} }
logger.debug('proxyRoute handle by router', { url: req.url }, noAdmin); logger.debug('proxyRoute handle by router', { url: req.url }, noAdmin);

1233
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff