feat: 初始化proxy代理请求

This commit is contained in:
xion 2024-10-06 03:23:49 +08:00
parent 9725145a43
commit 12e5184126
13 changed files with 1603 additions and 1 deletions

1
.gitignore vendored
View File

@ -3,3 +3,4 @@ dist
coverage coverage
.DS_Store .DS_Store
upload

1
.npmrc Normal file
View File

@ -0,0 +1 @@
@abearxiong:registry=https://npm.pkg.github.com

7
app.config.json5 Normal file
View File

@ -0,0 +1,7 @@
{
api: {
host: 'codeflow.xiongxiao.me',
},
domain: 'kevisual.xiongxiao.me',
resources: 'minio.xiongxiao.me/resources',
}

View File

@ -5,14 +5,21 @@
"main": "index.js", "main": "index.js",
"type": "module", "type": "module",
"scripts": { "scripts": {
"test": "echo \"Error: no test specified\" && exit 1" "dev": "nodemon --exec tsx src/index.ts"
}, },
"keywords": [], "keywords": [],
"author": "", "author": "",
"license": "ISC", "license": "ISC",
"devDependencies": { "devDependencies": {
"@types/node": "^22.7.4", "@types/node": "^22.7.4",
"nodemon": "^3.1.7",
"rollup": "^4.24.0", "rollup": "^4.24.0",
"typescript": "^5.6.2" "typescript": "^5.6.2"
},
"dependencies": {
"@abearxiong/use-config": "^0.0.2",
"@abearxiong/use-file-store": "^0.0.1",
"ioredis": "^5.4.1",
"nanoid": "^5.0.7"
} }
} }

975
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,15 @@
import http from 'http';
import { handleRequest } from './module/index.ts';
import { useConfig } from '@abearxiong/use-config';
useConfig();
const server = http.createServer((req, res) => {
// res.writeHead(200, { 'Content-Type': 'text/plain' });
// const pathname = new URL(req.url, `http://${dns.hostName}`).pathname;
handleRequest(req, res);
// res.write(`Request from ${dns.hostName} with IP: ${dns.ip}\n`);
// res.end('Hello World\n');
});
server.listen(3005, () => {
console.log('Server running at http://localhost:3005/');
});

View File

@ -0,0 +1,18 @@
import path from 'path';
// 获取文件的 content-type
export const getContentType = (filePath: string) => {
const extname = path.extname(filePath);
const contentType = {
'.html': 'text/html',
'.js': 'text/javascript',
'.css': 'text/css',
'.json': 'application/json',
'.png': 'image/png',
'.jpg': 'image/jpg',
'.gif': 'image/gif',
'.svg': 'image/svg+xml',
'.wav': 'audio/wav',
'.mp4': 'video/mp4',
};
return contentType[extname] || 'application/octet-stream';
};

283
src/module/get-user-app.ts Normal file
View File

@ -0,0 +1,283 @@
import path from 'path';
import { redis, subscriber } from './redis/redis.ts';
import { useFileStore } from '@abearxiong/use-file-store';
import { useConfig } from '@abearxiong/use-config';
import fs from 'fs';
import crypto from 'crypto';
import { nanoid } from 'nanoid';
import { pipeline } from 'stream';
import { promisify } from 'util';
const pipelineAsync = promisify(pipeline);
const { resources } = useConfig<{ resources: string }>();
const fileStore = useFileStore('upload');
const demoData = {
user: 'root',
key: 'codeflow',
appType: 'web-single', //
version: '1.0.0',
domain: null,
type: 'local',
data: {
files: [
{
name: 'index.html',
path: 'codeflow/index.html',
},
{
name: 'assets/index-14y4J8dP.js',
path: 'codeflow/assets/index-14y4J8dP.js',
},
{
name: 'assets/index-C-libw4a.css',
path: 'codeflow/assets/index-C-libw4a.css',
},
],
},
};
const demoData2 = {
user: 'root',
key: 'codeflow',
appType: 'web-single', //
version: '0.0.1',
domain: null,
type: 'oss', // 是否使用oss
data: {
files: [
{
name: 'index.html',
path: 'root/codeflow/0.0.1/index.html',
},
{
name: 'assets/index-14y4J8dP.js',
path: 'root/codeflow/0.0.1/assets/index-14y4J8dP.js',
},
{
name: 'assets/index-C-libw4a.css',
path: 'root/codeflow/0.0.1/assets/index-C-libw4a.css',
},
],
},
};
type UserAppOptions = {
user: string;
app: string;
};
export class UserApp {
user: string;
app: string;
constructor(options: UserAppOptions) {
this.user = options.user;
this.app = options.app;
}
async getExist() {
const app = this.app;
const user = this.user;
const key = 'user:app:exist:' + app + ':' + user;
const value = await redis.get(key);
return value;
}
async getCache() {
const app = this.app;
const user = this.user;
const key = 'user:app:' + app + ':' + user;
const value = await redis.get(key);
if (!value) {
return null;
}
}
async getFile(appFileUrl: string) {
const app = this.app;
const user = this.user;
const key = 'user:app:set:' + app + ':' + user;
const value = await redis.hget(key, appFileUrl);
return value;
}
async setCacheData() {
const app = this.app;
const user = this.user;
const key = 'user:app:' + app + ':' + user;
// 如果demoData 不存在则返回
if (!demoData2) {
return false;
}
const value = await downloadUserAppFiles(user, app, demoData2);
const valueIndexHtml = value.data.files.find((file) => file.name === 'index.html');
await redis.set(key, JSON.stringify(value));
await redis.set('user:app:exist:' + app + ':' + user, valueIndexHtml.path, 'EX', 60 * 60 * 24 * 7); // 24小时
const files = value.data.files;
// await redis.hset(key, 'files', JSON.stringify(files));
const data = {};
// 将文件名和路径添加到 `data` 对象中
files.forEach((file) => {
data[file.name] = file.path;
});
await redis.hset('user:app:set:' + app + ':' + user, data);
return true;
}
async getAllCacheData() {
const app = this.app;
const user = this.user;
const key = 'user:app:' + app + ':' + user;
const value = await redis.get(key);
console.log('getAllCacheData', JSON.parse(value));
const exist = await redis.get('user:app:exist:' + app + ':' + user);
console.log('getAllCacheData:exist', exist);
const files = await redis.hgetall('user:app:set:' + app + ':' + user);
console.log('getAllCacheData:files', files);
}
async clearCacheData() {
const app = this.app;
const user = this.user;
const key = 'user:app:' + app + ':' + user;
await redis.del(key);
await redis.del('user:app:exist:' + app + ':' + user);
await redis.del('user:app:set:' + app + ':' + user);
console.log('clear user data', key);
// 删除所有文件
deleteUserAppFiles(user, app);
}
async getData() {
return demoData;
}
async close() {
// 关闭连接
await redis.quit();
}
}
export const downloadUserAppFiles = async (user: string, app: string, data: typeof demoData) => {
const {
data: { files, ...dataRest },
...rest
} = data;
const uploadFiles = path.join(fileStore, user, app);
if (!checkFileExistsSync(uploadFiles)) {
fs.mkdirSync(uploadFiles, { recursive: true });
}
const newFiles = [];
if (data.type === 'local') {
// local copy file
for (let i = 0; i < files.length; i++) {
const file = files[i];
const copyFile = path.join(fileStore, file.path);
const destFile = path.join(uploadFiles, file.name);
const destDir = path.dirname(destFile); // 获取目标文件所在的目录路径
// 检查目录是否存在,如果不存在则创建
if (!checkFileExistsSync(destDir)) {
fs.mkdirSync(destDir, { recursive: true }); // 递归创建目录
}
fs.copyFileSync(copyFile, destFile);
// const etag = await setEtag(fs.readFileSync(destFile, 'utf-8'));
const etag = nanoid();
newFiles.push({
name: file.name,
path: destFile.replace(fileStore, '') + '||' + etag,
});
}
}
if (data.type === 'oss') {
const serverPath = 'https://' + resources + '/';
// server download file
for (let i = 0; i < files.length; i++) {
const file = files[i];
const destFile = path.join(uploadFiles, file.name);
const destDir = path.dirname(destFile); // 获取目标文件所在的目录路径
// 检查目录是否存在,如果不存在则创建
if (!checkFileExistsSync(destDir)) {
fs.mkdirSync(destDir, { recursive: true }); // 递归创建目录
}
// 下载文件到 destFile
await downloadFile(serverPath + file.path, destFile);
const etag = nanoid();
newFiles.push({
name: file.name,
path: destFile.replace(fileStore, '') + '||' + etag,
});
}
}
return {
...rest,
data: {
...dataRest,
files: newFiles,
},
};
};
export const checkFileExistsSync = (filePath: string) => {
try {
// 使用 F_OK 检查文件或目录是否存在
fs.accessSync(filePath, fs.constants.F_OK);
return true;
} catch (err) {
return false;
}
};
export const deleteUserAppFiles = async (user: string, app: string) => {
const uploadFiles = path.join(fileStore, user, app);
try {
fs.rmSync(uploadFiles, { recursive: true });
} catch (err) {
console.error('deleteUserAppFiles', err);
}
// console.log('deleteUserAppFiles', res);
};
async function downloadFile(fileUrl: string, destFile: string) {
const res = await fetch(fileUrl);
if (!res.ok) {
throw new Error(`Failed to fetch ${fileUrl}: ${res.statusText}`);
}
console.log('destFile', destFile);
const destStream = fs.createWriteStream(destFile);
// 使用 `pipeline` 将 `res.body` 中的数据传递给 `destStream`
await pipelineAsync(res.body, destStream);
console.log(`File downloaded to ${destFile}`);
}
export const clearAllUserApp = async () => {
// redis 删除 所有的 user:app:*
const keys = await redis.keys('user:app:*');
console.log('clearAllUserApp', keys);
if (keys.length > 0) {
const pipeline = redis.pipeline();
keys.forEach((key) => pipeline.del(key)); // 将每个键的删除操作添加到 pipeline 中
await pipeline.exec(); // 执行 pipeline 中的所有命令
console.log('All keys deleted successfully using pipeline');
}
};
export const setEtag = async (fileContent: string) => {
const eTag = crypto.createHash('md5').update(fileContent).digest('hex');
return eTag;
};
// redis 监听 user:app:exist:*的过期
subscriber.on('ready', () => {
console.log('Subscriber is ready and connected.');
});
// 订阅 Redis 频道
subscriber.subscribe('__keyevent@0__:expired', (err, count) => {
if (err) {
console.error('Failed to subscribe: ', err);
} else {
console.log(`Subscribed to ${count} channel(s). Waiting for expired events...`);
}
});
// 监听消息事件
subscriber.on('message', (channel, message) => {
// 检查是否匹配 user:app:exist:* 模式
if (message.startsWith('user:app:exist:')) {
const [_user, _app, _exist, app, user] = message.split(':');
// 在这里执行你的逻辑,例如清理缓存或通知用户
console.log('User app exist key expired:', app, user);
const userApp = new UserApp({ user, app });
userApp.clearCacheData();
}
});

167
src/module/index.ts Normal file
View File

@ -0,0 +1,167 @@
import { getDNS, isLocalhost } from '@/utils/dns.ts';
import http from 'http';
import { UserApp } from './get-user-app.ts';
import { useFileStore } from '@abearxiong/use-file-store';
import path from 'path';
import fs from 'fs';
import { useConfig } from '@abearxiong/use-config';
import { redis } from './redis/redis.ts';
import { getContentType } from './get-content-type.ts';
const { api, domain } = useConfig<{
api: {
host: string;
};
domain: string;
}>();
const fileStore = useFileStore('upload');
console.log('filePath', fileStore);
const noProxyUrl = ['/', '/favicon.ico'];
export const handleRequest = async (req: http.IncomingMessage, res: http.ServerResponse) => {
const dns = getDNS(req);
let user, app;
let domainApp = false;
if (isLocalhost(dns.hostName)) {
// 本地开发环境 测试
// user = 'root';
// app = 'codeflow';
// domainApp = true;
} else {
// 生产环境
// 验证域名
if (dns.hostName !== domain) {
// redis获取域名对应的用户和应用
domainApp = true;
const key = 'domain:' + dns.hostName;
const value = await redis.get(key);
if (!value) {
res.writeHead(404, { 'Content-Type': 'text/plain' });
res.write('Invalid domain\n');
return res.end();
}
const [_user, _app] = value.split(':');
if (!_user || !_app) {
res.writeHead(404, { 'Content-Type': 'text/plain' });
res.write('Invalid domain, Config error\n');
return res.end();
}
user = _user;
app = _app;
}
}
const url = req.url;
if (!domainApp && noProxyUrl.includes(req.url)) {
res.write('No proxy for this URL\n');
return res.end();
}
if (!domainApp) {
// 原始url地址
const urls = url.split('/');
if (urls.length < 3) {
console.log('urls errpr', urls);
res.writeHead(404, { 'Content-Type': 'text/html' });
res.write('Invalid Proxy URL\n');
return res.end();
}
const [_, _user, _app] = urls;
if (!_user || !_app) {
res.write('Invalid URL\n');
return res.end();
}
user = _user;
app = _app;
}
const [_, _api] = req.url.split('/');
if (_api === 'api') {
// 代理到 http://codeflow.xiongxiao.me/api
// 设置代理请求的目标 URL 和请求头
const options = {
host: api.host,
path: req.url,
method: 'POST',
headers: {
'Content-Type': req.headers['content-type'],
Authroization: req.headers?.['authorization'] || '',
},
};
// 创建代理请求
const proxyReq = http.request(options, (proxyRes) => {
// 将代理服务器的响应头和状态码返回给客户端
res.writeHead(proxyRes.statusCode, proxyRes.headers);
// 将代理响应流写入客户端响应
proxyRes.pipe(res, { end: true });
});
// 处理代理请求的错误事件
proxyReq.on('error', (err) => {
console.error(`Proxy request error: ${err.message}`);
res.writeHead(500, { 'Content-Type': 'text/plain' });
res.write(`Proxy request error: ${err.message}`);
});
// 处理 POST 请求的请求体(传递数据到目标服务器)
req.pipe(proxyReq, { end: true });
return;
}
const userApp = new UserApp({ user, app });
let isExist = await userApp.getExist();
if (!isExist) {
try {
const hasApp = await userApp.setCacheData();
if (!hasApp) {
res.writeHead(404, { 'Content-Type': 'text/html' });
res.write('Not Found App\n');
res.end();
return;
}
} catch (error) {
console.error('setCacheData error', error);
res.writeHead(500, { 'Content-Type': 'text/html' });
res.write('Server Error\n');
res.end();
return;
}
isExist = await userApp.getExist();
if (!isExist) {
res.writeHead(404, { 'Content-Type': 'text/html' });
res.write('Not Found App Index Page\n');
res.end();
return;
}
}
const indexFile = isExist;
let appFileUrl: string;
if (domainApp) {
appFileUrl = (url + '').replace(`/`, '');
} else {
appFileUrl = (url + '').replace(`/${user}/${app}/`, '');
}
const appFile = await userApp.getFile(appFileUrl);
if (!appFile) {
const [indexFilePath, etag] = indexFile.split('||');
// 不存在的文件,返回indexFile的文件
res.writeHead(200, { 'Content-Type': 'text/html', 'Cache-Control': 'no-cache' });
const filePath = path.join(fileStore, indexFilePath);
const readStream = fs.createReadStream(filePath);
readStream.pipe(res);
return;
} else {
const [appFilePath, eTag] = appFile.split('||');
// 检查 If-None-Match 头判断缓存是否有效
if (req.headers['if-none-match'] === eTag) {
res.statusCode = 304; // 内容未修改
res.end();
return;
}
const filePath = path.join(fileStore, appFilePath);
let contentType = getContentType(filePath);
res.writeHead(200, {
'Content-Type': contentType,
'Cache-Control': 'public, max-age=3600', // 设置缓存时间为 1 小时
ETag: eTag,
});
const readStream = fs.createReadStream(filePath);
readStream.pipe(res);
return;
}
};

View File

@ -0,0 +1,28 @@
import { redis } from './redis.ts';
/**
* 访
* @param key
*/
export const accessKeyWithTimestamp = async (key: string) => {
const value = await redis.get(key);
if (value !== null) {
// 记录上一次访问时间(使用当前 Unix 时间戳)
await redis.hset('key_last_access_time', key, Math.floor(Date.now() / 1000));
}
return value;
};
/**
* 访
* @param key
* @returns
*/
export const accessKeyAndCount = async (key: string) => {
const value = await redis.get(key);
if (value !== null) {
// 增加访问计数
await redis.incr(`access_count:${key}`);
}
return value;
};

48
src/module/redis/redis.ts Normal file
View File

@ -0,0 +1,48 @@
import { Redis } from 'ioredis';
import { useConfig } from '@abearxiong/use-config';
const config = useConfig<{
redis: ConstructorParameters<typeof Redis>;
}>();
// 配置 Redis 连接
export const redis = new Redis({
host: 'localhost', // Redis 服务器的主机名或 IP 地址
port: 6379, // Redis 服务器的端口号
// password: 'your_password', // Redis 的密码 (如果有)
db: 0, // 要使用的 Redis 数据库索引 (0-15)
keyPrefix: '', // key 前缀
retryStrategy(times) {
// 连接重试策略
return Math.min(times * 50, 2000); // 每次重试时延迟增加
},
maxRetriesPerRequest: null, // 允许请求重试的次数 (如果需要无限次重试)
...config.redis,
});
export const subscriber = redis.duplicate(); // 创建一个订阅者连接
async function ensureKeyspaceNotifications() {
try {
// 获取当前的 `notify-keyspace-events` 配置
const currentConfig = (await redis.config('GET', 'notify-keyspace-events')) as string[];
// 检查返回的数组长度是否大于1表示获取成功
if (currentConfig && currentConfig.length > 1) {
const currentSetting = currentConfig[1]; // 值在数组的第二个元素
// 检查当前配置是否包含 "Ex"
if (!currentSetting.includes('E') || !currentSetting.includes('x')) {
console.log('Keyspace notifications are not fully enabled. Setting correct value...');
await redis.config('SET', 'notify-keyspace-events', 'Ex');
console.log('Keyspace notifications enabled with setting "Ex".');
} else {
// console.log('Keyspace notifications are already correctly configured.');
}
} else {
console.error('Failed to get the current notify-keyspace-events setting.');
}
} catch (err) {
console.error('Error while configuring Redis keyspace notifications:', err);
}
}
// 确保键空间通知被正确设置
ensureKeyspaceNotifications().catch(console.error);

41
src/scripts/copy.ts Normal file
View File

@ -0,0 +1,41 @@
import { UserApp, clearAllUserApp } from '../module/get-user-app.ts';
import { redis } from '../module/redis/redis.ts';
import path from 'path';
import { useFileStore } from '@abearxiong/use-file-store';
const filePath = useFileStore('upload');
const main = async () => {
const userApp = new UserApp({ user: 'root', app: 'codeflow' });
const res = await userApp.setCacheData();
console.log(res);
// userApp.close();
process.exit(0);
};
// main();
const getAll = async () => {
const userApp = new UserApp({ user: 'root', app: 'codeflow' });
const res = await userApp.getAllCacheData();
userApp.close();
};
// getAll();
// console.log('path', path.join(filePath, '/module/get-user-app.ts'));
const clearData = async () => {
const userApp = new UserApp({ user: 'root', app: 'codeflow' });
const res = await userApp.clearCacheData();
process.exit(0);
};
// clearData();
clearAllUserApp();
const expireData = async () => {
await redis.set('user:app:exist:' + 'codeflow:root', 'value', 'EX', 2);
process.exit(0);
};
// expireData();

11
src/utils/dns.ts Normal file
View File

@ -0,0 +1,11 @@
import http from 'http';
export const getDNS = (req: http.IncomingMessage) => {
const hostName = req.headers.host;
const ip = req.socket.remoteAddress;
return { hostName, ip };
};
export const isLocalhost = (hostName: string) => {
return hostName.includes('localhost');
};