init router

This commit is contained in:
xion 2024-10-16 00:47:30 +08:00
commit 733677f3f3
32 changed files with 2185 additions and 0 deletions

38
.github/workflows/publish.yml vendored Normal file
View File

@ -0,0 +1,38 @@
name: Publish to npm
on:
push:
tags:
- 'v*.*.*' # 当推送带有版本号的 tag 时触发,例如 v1.0.0
workflow_dispatch: # 添加手动触发器
jobs:
publish:
runs-on: ubuntu-latest
steps:
# Step 1: Clone current Git repository
- name: Checkout this repository
uses: actions/checkout@v3
# Step 3: Setup Node.js and install dependencies
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: '20.6'
registry-url: 'https://registry.npmjs.org/'
cache: 'npm' # 启用 npm 缓存,提高安装速度
- name: Configure npm authentication
run: npm config set //registry.npmjs.org/:_authToken=${{ secrets.NPM_TOKEN }}
- name: Install dependencies
run: npm install
- name: Build project
run: npm run build
# Step 6: 发布到 npm
- name: Publish package
run: npm publish
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
# Step 7: 发布成功后,更新版本标签
# - name: Create Git tag
# run: |
# TAG="v$(node -p -e "require('./package.json').version")"
# git tag $TAG
# git push origin $TAG

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
node_modules
src/app.config.json5
dist

268
demo/simple/package-lock.json generated Normal file
View File

@ -0,0 +1,268 @@
{
"name": "simple",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "simple",
"version": "1.0.0",
"license": "ISC",
"dependencies": {
"@abearxiong/router": "../.."
},
"devDependencies": {
"ts-node": "^10.9.2",
"typescript": "^5.5.4"
}
},
"../..": {
"name": "@kevisual/router",
"version": "0.0.2",
"license": "ISC",
"dependencies": {
"ws": "^8.18.0"
},
"devDependencies": {
"@rollup/plugin-commonjs": "^26.0.1",
"@rollup/plugin-node-resolve": "^15.2.4",
"@rollup/plugin-typescript": "^12.1.0",
"@types/lodash-es": "^4.17.12",
"@types/node": "^22.5.5",
"@types/ws": "^8.5.12",
"lodash-es": "^4.17.21",
"nanoid": "^5.0.7",
"rollup": "^4.22.4",
"ts-loader": "^9.5.1",
"ts-node": "^10.9.2",
"tslib": "^2.7.0",
"typescript": "^5.6.2",
"zod": "^3.23.8"
}
},
"node_modules/@abearxiong/router": {
"resolved": "../..",
"link": true
},
"node_modules/@cspotcode/source-map-support": {
"version": "0.8.1",
"resolved": "https://registry.npmmirror.com/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz",
"integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/trace-mapping": "0.3.9"
},
"engines": {
"node": ">=12"
}
},
"node_modules/@jridgewell/resolve-uri": {
"version": "3.1.2",
"resolved": "https://registry.npmmirror.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@jridgewell/sourcemap-codec": {
"version": "1.5.0",
"resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz",
"integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@jridgewell/trace-mapping": {
"version": "0.3.9",
"resolved": "https://registry.npmmirror.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz",
"integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/resolve-uri": "^3.0.3",
"@jridgewell/sourcemap-codec": "^1.4.10"
}
},
"node_modules/@tsconfig/node10": {
"version": "1.0.11",
"resolved": "https://registry.npmmirror.com/@tsconfig/node10/-/node10-1.0.11.tgz",
"integrity": "sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw==",
"dev": true,
"license": "MIT"
},
"node_modules/@tsconfig/node12": {
"version": "1.0.11",
"resolved": "https://registry.npmmirror.com/@tsconfig/node12/-/node12-1.0.11.tgz",
"integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==",
"dev": true,
"license": "MIT"
},
"node_modules/@tsconfig/node14": {
"version": "1.0.3",
"resolved": "https://registry.npmmirror.com/@tsconfig/node14/-/node14-1.0.3.tgz",
"integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==",
"dev": true,
"license": "MIT"
},
"node_modules/@tsconfig/node16": {
"version": "1.0.4",
"resolved": "https://registry.npmmirror.com/@tsconfig/node16/-/node16-1.0.4.tgz",
"integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/node": {
"version": "22.5.1",
"resolved": "https://registry.npmmirror.com/@types/node/-/node-22.5.1.tgz",
"integrity": "sha512-KkHsxej0j9IW1KKOOAA/XBA0z08UFSrRQHErzEfA3Vgq57eXIMYboIlHJuYIfd+lwCQjtKqUu3UnmKbtUc9yRw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~6.19.2"
}
},
"node_modules/acorn": {
"version": "8.12.1",
"resolved": "https://registry.npmmirror.com/acorn/-/acorn-8.12.1.tgz",
"integrity": "sha512-tcpGyI9zbizT9JbV6oYE477V6mTlXvvi0T0G3SNIYE2apm/G5huBa1+K89VGeovbg+jycCrfhl3ADxErOuO6Jg==",
"dev": true,
"license": "MIT",
"bin": {
"acorn": "bin/acorn"
},
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/acorn-walk": {
"version": "8.3.3",
"resolved": "https://registry.npmmirror.com/acorn-walk/-/acorn-walk-8.3.3.tgz",
"integrity": "sha512-MxXdReSRhGO7VlFe1bRG/oI7/mdLV9B9JJT0N8vZOhF7gFRR5l3M8W9G8JxmKV+JC5mGqJ0QvqfSOLsCPa4nUw==",
"dev": true,
"license": "MIT",
"dependencies": {
"acorn": "^8.11.0"
},
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/arg": {
"version": "4.1.3",
"resolved": "https://registry.npmmirror.com/arg/-/arg-4.1.3.tgz",
"integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==",
"dev": true,
"license": "MIT"
},
"node_modules/create-require": {
"version": "1.1.1",
"resolved": "https://registry.npmmirror.com/create-require/-/create-require-1.1.1.tgz",
"integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==",
"dev": true,
"license": "MIT"
},
"node_modules/diff": {
"version": "4.0.2",
"resolved": "https://registry.npmmirror.com/diff/-/diff-4.0.2.tgz",
"integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.3.1"
}
},
"node_modules/make-error": {
"version": "1.3.6",
"resolved": "https://registry.npmmirror.com/make-error/-/make-error-1.3.6.tgz",
"integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==",
"dev": true,
"license": "ISC"
},
"node_modules/ts-node": {
"version": "10.9.2",
"resolved": "https://registry.npmmirror.com/ts-node/-/ts-node-10.9.2.tgz",
"integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@cspotcode/source-map-support": "^0.8.0",
"@tsconfig/node10": "^1.0.7",
"@tsconfig/node12": "^1.0.7",
"@tsconfig/node14": "^1.0.0",
"@tsconfig/node16": "^1.0.2",
"acorn": "^8.4.1",
"acorn-walk": "^8.1.1",
"arg": "^4.1.0",
"create-require": "^1.1.0",
"diff": "^4.0.1",
"make-error": "^1.1.1",
"v8-compile-cache-lib": "^3.0.1",
"yn": "3.1.1"
},
"bin": {
"ts-node": "dist/bin.js",
"ts-node-cwd": "dist/bin-cwd.js",
"ts-node-esm": "dist/bin-esm.js",
"ts-node-script": "dist/bin-script.js",
"ts-node-transpile-only": "dist/bin-transpile.js",
"ts-script": "dist/bin-script-deprecated.js"
},
"peerDependencies": {
"@swc/core": ">=1.2.50",
"@swc/wasm": ">=1.2.50",
"@types/node": "*",
"typescript": ">=2.7"
},
"peerDependenciesMeta": {
"@swc/core": {
"optional": true
},
"@swc/wasm": {
"optional": true
}
}
},
"node_modules/typescript": {
"version": "5.5.4",
"resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.5.4.tgz",
"integrity": "sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "6.19.8",
"resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-6.19.8.tgz",
"integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==",
"dev": true,
"license": "MIT",
"peer": true
},
"node_modules/v8-compile-cache-lib": {
"version": "3.0.1",
"resolved": "https://registry.npmmirror.com/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz",
"integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==",
"dev": true,
"license": "MIT"
},
"node_modules/yn": {
"version": "3.1.1",
"resolved": "https://registry.npmmirror.com/yn/-/yn-3.1.1.tgz",
"integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
}
}
}

20
demo/simple/package.json Normal file
View File

@ -0,0 +1,20 @@
{
"name": "simple",
"version": "1.0.0",
"main": "index.js",
"type": "module",
"scripts": {
"app": "node --no-warnings=ExperimentalWarning --loader ts-node/esm src/app.ts"
},
"keywords": [],
"author": "",
"license": "ISC",
"description": "",
"dependencies": {
"@abearxiong/router": "../.."
},
"devDependencies": {
"ts-node": "^10.9.2",
"typescript": "^5.5.4"
}
}

View File

@ -0,0 +1,74 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>WebSocket with Redis</title>
</head>
<body>
<h1>Real-time Data Updates</h1>
<p id="output">Waiting for updates...</p>
<script>
// const ws = new WebSocket('ws://localhost:4002/api/router');
const ws = new WebSocket('ws://192.168.31.220:4002/api/router');
// 当连接成功时
ws.onopen = () => {
console.log('Connected to WebSocket server');
// 订阅数据 ID 为 1 的更新
// const message = JSON.stringify({ type: 'subscribe', dataId: '1' });
// ws.send(message);
const message = JSON.stringify({
type: 'router',
data: {
path: 'demo',
key: '01',
}
});
ws.send(message);
};
// 接收服务器的消息
ws.onmessage = (event) => {
const parseIfJson = (data) => {
try {
return JSON.parse(data);
} catch (error) {
return data;
}
};
const appendChild = (text) => {
const t = document.createElement('div');
t.innerText = text;
document.body.appendChild(t);
};
console.log('Received:', event.data);
const message = parseIfJson(event.data);
if (typeof message === 'string') {
appendChild(message);
return;
}
if (message.type === 'router') {
const res = message.data;
const text = `Data Updated: ${JSON.stringify(res)}`;
appendChild(text);
console.log('Data updated:', res);
} else {
document.querySelector('#output').innerText = event.data;
console.log('Unknown message type:', message.type);
}
};
// 处理 WebSocket 关闭
ws.onclose = () => {
console.log('Disconnected from WebSocket server');
};
</script>
</body>
</html>

41
demo/simple/src/app-02.ts Normal file
View File

@ -0,0 +1,41 @@
import { App } from '@abearxiong/router';
const app = new App();
app.listen(4002, () => {
console.log('Server is running at http://localhost:4002');
});
const callback = (req, res) => {
if (req.url.startsWith('/api/v')) {
// 在这里处理 /api/v 的请求
// res.writeHead(200, { 'Content-Type': 'text/plain' });
setTimeout(() => {
res.end('Intercepted /api/v request');
}, 2000);
}
};
app.server.on(callback);
new app.Route('demo', '01')
.define(async (ctx) => {
ctx.body = '01';
return ctx;
})
.addTo(app);
app
.route('demo')
.define(async (ctx) => {
ctx.body = '02';
return ctx;
})
.addTo(app);
app
.route('demo', '03')
.define(async (ctx) => {
ctx.body = '03';
return ctx;
})
.addTo(app);

27
demo/simple/src/app-ws.ts Normal file
View File

@ -0,0 +1,27 @@
import { Route, App } from '@abearxiong/router';
const app = new App({ io: true });
app.listen(4002);
const route01 = new Route('demo', '01');
route01.run = async (ctx) => {
ctx.body = '01';
return ctx;
};
app.use(
'demo',
async (ctx) => {
ctx.body = '01';
return ctx;
},
{ key: '01' },
);
const route02 = new Route('demo', '02');
route02.run = async (ctx) => {
ctx.body = '02';
return ctx;
};
app.addRoute(route02);
console.log(`http://localhost:4002/api/router?path=demo&key=02`);
console.log(`http://localhost:4002/api/router?path=demo&key=01`);

27
demo/simple/src/app.ts Normal file
View File

@ -0,0 +1,27 @@
import { Route, App } from '@abearxiong/router';
const app = new App();
app.listen(4003);
const route01 = new Route('demo', '01');
route01.run = async (ctx) => {
ctx.body = '01';
return ctx;
};
app.use(
'demo',
async (ctx) => {
ctx.body = '01';
return ctx;
},
{ key: '01' },
);
const route02 = new Route('demo', '02');
route02.run = async (ctx) => {
ctx.body = '02';
return ctx;
};
app.addRoute(route02);
console.log(`http://localhost:4003/api/router?path=demo&key=02`);
console.log(`http://localhost:4003/api/router?path=demo&key=01`);

36
demo/simple/src/index.ts Normal file
View File

@ -0,0 +1,36 @@
import { QueryRouter, Route, Server } from '@abearxiong/router';
const router = new QueryRouter();
const route01 = new Route('demo', '01');
route01.run = async (ctx) => {
ctx.body = '01';
return ctx;
};
router.add(route01);
const server = new Server({
handle: async (msg) => {
const res = await router.parse(msg);
const { code, body, message } = res;
// console.log('response', res);
return { code, data: body, message };
}
});
// server.setHandle(async (msg) => {
// const res = await router.parse(msg);
// const { code, body, message } = res;
// // console.log('response', res);
// return { code, data: body, message };
// });
server.listen(3000);
const route02 = new Route('demo', '02');
route02.run = async (ctx) => {
ctx.body = '02';
return ctx;
};
router.add(route02);

View File

@ -0,0 +1,85 @@
import { Route, QueryRouter, RouteContext } from '@abearxiong/router';
const qr = new QueryRouter();
qr.add(
new Route('project', 'getList', {
description: 'get project list',
run: async (ctx) => {
ctx!.body = 'project list';
return ctx;
},
}),
);
qr.add(
new Route('project', 'getDetail', {
description: 'get project detail',
run: async (ctx) => {
ctx!.body = 'project detail';
return ctx;
},
}),
);
qr.add(
new Route('project', 'getDetail2', {
description: 'get project detail2',
run: async (ctx: RouteContext) => {
ctx!.body = 'project detail2';
return ctx;
},
validator: {
id: {
type: 'number',
required: true,
message: 'id is required',
},
data: {
// @ts-ignore
type: 'object',
message: 'data query is error',
properties: {
name: {
type: 'string',
required: true,
message: 'name is required',
},
age: {
type: 'number',
required: true,
message: 'age is error',
},
friends: {
type: 'object',
properties: {
hair: {
type: 'string',
required: true,
message: 'hair is required',
},
},
},
},
},
},
}),
);
const main = async () => {
// 调用要测试的函数
const res = await qr.parse({
path: 'project',
key: 'getDetail2',
id: 4,
data: {
name: 'john',
age: 's'+13,
friends: {
hair: 'black',
messages: 'hello',
},
},
} as any);
console.log('test===', res);
};
main();

43
demo/simple/tsconfig.json Normal file
View File

@ -0,0 +1,43 @@
{
"compilerOptions": {
"module": "NodeNext",
"target": "esnext",
"noImplicitAny": false,
"outDir": "./dist",
"sourceMap": false,
"allowJs": true,
"newLine": "LF",
"baseUrl": "./",
"typeRoots": [
"node_modules/@types",
],
"declaration": true,
"noEmit": true,
"allowImportingTsExtensions": true,
"moduleResolution": "NodeNext",
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"esModuleInterop": true,
"paths": {
"@/*": [
"src/*"
],
"*": [
"types/*"
]
}
},
"include": [
"typings.d.ts",
"src/**/*.ts"
],
"exclude": [
"node_modules",
"dist",
"src/**/*.test.ts",
"rollup.config.js",
],
"ts-node": {
"esm": true
}
}

44
package.json Normal file
View File

@ -0,0 +1,44 @@
{
"$schema": "https://json.schemastore.org/package",
"name": "@kevisual/router",
"version": "0.0.2",
"description": "",
"main": "dist/index.js",
"module": "dist/index.js",
"types": "dist/index.d.ts",
"type": "module",
"scripts": {
"build": "npm run clean && rollup -c",
"watch": "rollup -c -w",
"clean": "rm -rf dist"
},
"files": [
"dist"
],
"keywords": [],
"author": "",
"license": "ISC",
"devDependencies": {
"@rollup/plugin-commonjs": "^26.0.1",
"@rollup/plugin-node-resolve": "^15.2.4",
"@rollup/plugin-typescript": "^12.1.0",
"@types/lodash-es": "^4.17.12",
"@types/node": "^22.5.5",
"@types/ws": "^8.5.12",
"lodash-es": "^4.17.21",
"nanoid": "^5.0.7",
"rollup": "^4.22.4",
"ts-loader": "^9.5.1",
"ts-node": "^10.9.2",
"tslib": "^2.7.0",
"typescript": "^5.6.2",
"zod": "^3.23.8"
},
"repository": {
"type": "git",
"url": "https://github.com/abearxiong/kevisual-router.git"
},
"dependencies": {
"ws": "^8.18.0"
}
}

31
readme.md Normal file
View File

@ -0,0 +1,31 @@
# router
```
import { App } from '@kevisual/router';
const app = new App();
app.listen(4002);
new app.Route('demo', '01')
.define(async (ctx) => {
ctx.body = '01';
return ctx;
})
.addTo(app);
app
.route({path:'demo', key: '02})
.define(async (ctx) => {
ctx.body = '02';
return ctx;
})
.addTo(app);
app
.route('demo', '03')
.define(async (ctx) => {
ctx.body = '03';
return ctx;
})
.addTo(app);
```

24
rollup.config.js Normal file
View File

@ -0,0 +1,24 @@
// rollup.config.js
import typescript from '@rollup/plugin-typescript';
import resolve from '@rollup/plugin-node-resolve';
import commonjs from '@rollup/plugin-commonjs';
/**
* @type {import('rollup').RollupOptions}
*/
export default {
input: 'src/index.ts', // TypeScript 入口文件
output: {
file: 'dist/index.js', // 输出文件
format: 'es', // 输出格式设置为 ES 模块
},
plugins: [
resolve(), // 使用 @rollup/plugin-node-resolve 解析 node_modules 中的模块
// commonjs(),
typescript({
allowImportingTsExtensions: true,
noEmit: true,
}), // 使用 @rollup/plugin-typescript 处理 TypeScript 文件
],
external: ['ws']
};

84
src/app.ts Normal file
View File

@ -0,0 +1,84 @@
import { QueryRouter, Route, RouteContext, RouteOpts } from './route.ts';
import { Server, Cors } from './server/server.ts';
import { WsServer } from './server/ws-server.ts';
type RouterHandle = (msg: { path: string; [key: string]: any }) => { code: string; data?: any; message?: string; [key: string]: any };
type AppOptions<T = {}> = {
router?: QueryRouter;
server?: Server;
/** handle msg 关联 */
routerHandle?: RouterHandle;
routerContext?: RouteContext<T>;
serverOptions?: {
path?: string;
cors?: Cors;
handle?: any;
};
io?: boolean;
ioOpts?: { routerHandle?: RouterHandle; routerContext?: RouteContext<T>; path?: string };
};
export class App<T = {}> {
router: QueryRouter;
server: Server;
io: WsServer;
constructor(opts?: AppOptions<T>) {
const router = opts?.router || new QueryRouter();
const server = opts?.server || new Server(opts?.serverOptions || {});
server.setHandle(router.getHandle(router, opts?.routerHandle, opts?.routerContext));
this.router = router;
this.server = server;
if (opts?.io) {
this.io = new WsServer(server, opts?.ioOpts);
}
}
listen(port: number, hostname?: string, backlog?: number, listeningListener?: () => void): void;
listen(port: number, hostname?: string, listeningListener?: () => void): void;
listen(port: number, backlog?: number, listeningListener?: () => void): void;
listen(port: number, listeningListener?: () => void): void;
listen(path: string, backlog?: number, listeningListener?: () => void): void;
listen(path: string, listeningListener?: () => void): void;
listen(handle: any, backlog?: number, listeningListener?: () => void): void;
listen(handle: any, listeningListener?: () => void): void;
listen(...args: any[]) {
// @ts-ignore
this.server.listen(...args);
if (this.io) {
this.io.listen();
}
}
use(path: string, fn: (ctx: any) => any, opts?: RouteOpts) {
const route = new Route(path, '', opts);
route.run = fn;
this.router.add(route);
}
addRoute(route: Route) {
this.router.add(route);
}
add = this.addRoute;
Route = Route;
route(opts: RouteOpts): Route;
route(path: string, key?: string): Route;
route(path: string, opts?: RouteOpts): Route;
route(path: string, key?: string, opts?: RouteOpts): Route;
route(...args: any[]) {
const [path, key, opts] = args;
if (typeof path === 'object') {
return new Route(path.path, path.key, path);
}
if (typeof path === 'string') {
if (opts) {
return new Route(path, key, opts);
}
if (key && typeof key === 'object') {
return new Route(path, key?.key || '', key);
}
return new Route(path, key);
}
return new Route(path, key, opts);
}
async call(message: { path: string; key: string; payload?: any }, ctx?: RouteContext & { [key: string]: any }) {
const router = this.router;
return await router.parse(message, ctx);
}
}

67
src/connect.ts Normal file
View File

@ -0,0 +1,67 @@
import { nanoid } from 'nanoid';
import { RouteContext } from './route.ts';
export class Connect {
path: string;
key?: string;
_fn?: (ctx?: RouteContext) => Promise<RouteContext>;
description?: string;
connects: { path: string; key?: string }[];
share = false;
constructor(path: string) {
this.path = path;
this.key = nanoid();
}
use(path: string) {
this.connects.push({ path });
}
useList(paths: string[]) {
paths.forEach((path) => {
this.connects.push({ path });
});
}
useConnect(connect: Connect) {
this.connects.push({ path: connect.path, key: connect.key });
}
useConnectList(connects: Connect[]) {
connects.forEach((connect) => {
this.connects.push({ path: connect.path, key: connect.key });
});
}
getPathList() {
return this.connects.map((c) => c.path).filter(Boolean);
}
set fn(fn: (ctx?: RouteContext) => Promise<RouteContext>) {
this._fn = fn;
}
get fn() {
return this._fn;
}
}
export class QueryConnect {
connects: Connect[];
constructor() {
this.connects = [];
}
add(connect: Connect) {
const has = this.connects.find((c) => c.path === connect.path && c.key === connect.key);
if (has) {
// remove the old connect
console.log('[replace connect]:', connect.path, connect.key);
this.connects = this.connects.filter((c) => c.path !== connect.path && c.key !== connect.key);
}
this.connects.push(connect);
}
remove(connect: Connect) {
this.connects = this.connects.filter((c) => c.path !== connect.path && c.key !== connect.key);
}
getList() {
return this.connects.map((c) => {
return {
path: c.path,
key: c.key,
};
});
}
}

21
src/index.ts Normal file
View File

@ -0,0 +1,21 @@
export { Route, QueryRouter, QueryRouterServer } from './route.ts';
export { Connect, QueryConnect } from './connect.ts';
export type { RouteContext, RouteOpts } from './route.ts';
export type { Run } from './route.ts';
export { Server, handleServer } from './server/index.ts';
/**
*
*/
export { CustomError } from './result/error.ts';
/**
*
*/
export { Result } from './result/index.ts';
export { Rule, Schema, createSchema } from './validator/index.ts';
export { App } from './app.ts';

6
src/io.ts Normal file
View File

@ -0,0 +1,6 @@
// TODO: Implement IOApp
export class IOApp {
constructor() {
console.log('IoApp');
}
}

67
src/result/error.ts Normal file
View File

@ -0,0 +1,67 @@
/** 自定义错误 */
export class CustomError extends Error {
code?: number;
data?: any;
message: string;
tips?: string;
constructor(code?: number | string, message?: string, tips?: string) {
super(message || String(code));
this.name = 'CustomError';
if (typeof code === 'number') {
this.code = code;
this.message = message;
} else {
this.code = 500;
this.message = code;
}
this.tips = tips;
// 这一步可不写,默认会保存堆栈追踪信息到自定义错误构造函数之前,
// 而如果写成 `Error.captureStackTrace(this)` 则自定义错误的构造函数也会被保存到堆栈追踪信息
Error.captureStackTrace(this, this.constructor);
}
static fromCode(code?: number) {
return new this(code);
}
static fromErrorData(code?: number, data?: any) {
const error = new this(code);
error.data = data;
return error;
}
static parseError(e: CustomError) {
return {
code: e?.code,
data: e?.data,
message: e?.message,
tips: e?.tips,
};
}
parse(e?: CustomError) {
if (e) {
return CustomError.parseError(e);
} else {
return {
code: e?.code,
data: e?.data,
message: e?.message,
tips: e?.tips,
};
}
}
}
/*
try {
//
} catch(e) {
if (e instanceof CustomError) {
const errorInfo = e.parse();
if (dev) {
return {
error: errorInfo,
};
} else {
return errorInfo;
}
}
}
*/

45
src/result/index.ts Normal file
View File

@ -0,0 +1,45 @@
export const Code400 = [
{
code: 400,
msg: 'Bad Request',
zn: '表示其他错误就是4xx都无法描述的前端发生的错误',
},
{ code: 401, msg: 'Authentication', zn: '表示认证类型的错误' }, // token 无效 无token token无效 token 过期)
{
code: 403,
msg: 'Authorization',
zn: '表示授权的错误(认证和授权的区别在于:认证表示“识别前来访问的是谁”,而授权则是“赋予特定用户执行特定操作的权限”)',
},
{ code: 404, msg: 'Not Found', zn: '表示访问的数据不存在' },
{
code: 405,
msg: 'Method Not Allowd',
zn: '表示可以访问接口但是使用的HTTP方法不允许',
},
];
export const ResultCode = [{ code: 200, msg: 'OK', zn: '请求成功。' }].concat(Code400);
type ResultProps = {
code?: number;
msg?: string;
userTip?: string;
};
export const Result = ({ code, msg, userTip, ...other }: ResultProps) => {
const Code = ResultCode.find((item) => item.code === code);
let _result = {
code: code || Code?.code,
msg: msg || Code?.msg,
userTip: undefined,
...other,
};
if (userTip) {
_result.userTip = userTip;
}
return _result;
};
Result.success = (data?: any) => {
return {
code: 200,
data,
};
};

518
src/route.ts Normal file
View File

@ -0,0 +1,518 @@
import { nanoid } from 'nanoid';
import { CustomError } from './result/error.ts';
import { Schema, Rule, createSchema } from './validator/index.ts';
import { pick } from './utils/pick.ts';
import { get } from 'lodash-es';
export type RouterContextT = { code?: number; [key: string]: any };
export type RouteContext<T = { code?: number }, S = any> = {
// run first
query?: { [key: string]: any };
// response body
/** return body */
body?: number | string | Object;
/** return code */
code?: number;
/** return msg */
message?: string;
// 传递状态
state?: S;
// transfer data
currentPath?: string;
currentKey?: string;
currentRoute?: Route;
progress?: [[string, string]][];
// onlyForNextRoute will be clear after next route
nextQuery?: { [key: string]: any };
// end
end?: boolean;
// 处理router manager
// TODO:
queryRouter?: QueryRouter;
error?: any;
call?: (message: { path: string; key: string; payload?: any }, ctx?: RouteContext & { [key: string]: any }) => Promise<any>;
index?: number;
} & T;
export type Run<T = any> = (ctx?: RouteContext<T>) => Promise<typeof ctx | null | void>;
export type NextRoute = Pick<Route, 'id' | 'path' | 'key'>;
export type RouteOpts = {
path?: string;
key?: string;
id?: string;
run?: Run;
nextRoute?: NextRoute; // route to run after this route
description?: string;
middleware?: Route[] | string[]; // middleware
type?: 'route' | 'middleware';
/**
* validator: {
* packageName: {
* type: 'string',
* required: true,
* },
* }
*/
validator?: { [key: string]: Rule };
schema?: { [key: string]: Schema<any> };
isVerify?: boolean;
verify?: (ctx?: RouteContext, dev?: boolean) => boolean;
verifyKey?: (key: string, ctx?: RouteContext, dev?: boolean) => boolean;
idUsePath?: boolean;
isDebug?: boolean;
};
export type DefineRouteOpts = Omit<RouteOpts, 'idUsePath' | 'verify' | 'verifyKey' | 'nextRoute'>;
const pickValue = ['path', 'key', 'id', 'description', 'type', 'validator', 'middleware'] as const;
export type RouteInfo = Pick<Route, (typeof pickValue)[number]>;
export class Route {
path?: string;
key?: string;
id?: string;
share? = false;
run?: Run;
nextRoute?: NextRoute; // route to run after this route
description?: string;
middleware?: (Route | string)[]; // middleware
type? = 'route';
private _validator?: { [key: string]: Rule };
schema?: { [key: string]: Schema<any> };
data?: any;
isVerify?: boolean;
isDebug?: boolean;
constructor(path: string, key: string = '', opts?: RouteOpts) {
path = path.trim();
key = key.trim();
this.path = path;
this.key = key;
if (opts) {
this.id = opts.id || nanoid();
if (!opts.id && opts.idUsePath) {
this.id = path + '$#$' + key;
}
this.run = opts.run;
this.nextRoute = opts.nextRoute;
this.description = opts.description;
this.type = opts.type || 'route';
this.validator = opts.validator;
this.middleware = opts.middleware || [];
this.key = opts.key || key;
this.path = opts.path || path;
this.isVerify = opts.isVerify ?? true;
this.createSchema();
} else {
this.isVerify = true;
this.middleware = [];
this.id = nanoid();
}
this.isDebug = opts?.isDebug ?? false;
}
private createSchema() {
const validator = this.validator;
const keys = Object.keys(validator || {});
const schemaList = keys.map((key) => {
return { [key]: createSchema(validator[key]) };
});
const schema = schemaList.reduce((prev, current) => {
return { ...prev, ...current };
}, {});
this.schema = schema;
}
/**
* set validator and create schema
* @param validator
*/
set validator(validator: { [key: string]: Rule }) {
this._validator = validator;
this.createSchema();
}
get validator() {
return this._validator || {};
}
/**
* has code, body, message in ctx, return ctx if has error
* @param ctx
* @param dev
* @returns
*/
verify(ctx: RouteContext, dev = false) {
const query = ctx.query || {};
const schema = this.schema || {};
const validator = this.validator;
const check = () => {
const queryKeys = Object.keys(validator);
for (let i = 0; i < queryKeys.length; i++) {
const key = queryKeys[i];
const value = query[key];
if (schema[key]) {
const result = schema[key].safeParse(value);
if (!result.success) {
const path = result.error.errors[0]?.path?.join?.('.properties.');
let message = 'Invalid params';
if (path) {
const keyS = `${key}.properties.${path}.message`;
message = get(validator, keyS, 'Invalid params') as any;
}
throw new CustomError(500, message);
}
}
}
};
check();
}
/**
* Need to manully call return ctx fn and configure body, code, message
* @param key
* @param ctx
* @param dev
* @returns
*/
verifyKey(key: string, ctx: RouteContext, dev = false) {
const query = ctx.query || {};
const schema = this.schema || {};
const validator = this.validator;
const check = () => {
const value = query[key];
if (schema[key]) {
try {
schema[key].parse(value);
} catch (e) {
if (dev) {
return {
message: validator[key].message || 'Invalid params',
path: this.path,
key: this.key,
error: e.message.toString(),
};
}
return {
message: validator[key].message || 'Invalid params',
path: this.path,
key: this.key,
};
}
}
};
const checkRes = check();
return checkRes;
}
setValidator(validator: { [key: string]: Rule }) {
this.validator = validator;
return this;
}
define<T extends { [key: string]: any } = RouterContextT>(opts: DefineRouteOpts): this;
define<T extends { [key: string]: any } = RouterContextT>(fn: Run<T>): this;
define<T extends { [key: string]: any } = RouterContextT>(key: string, fn: Run<T>): this;
define<T extends { [key: string]: any } = RouterContextT>(path: string, key: string, fn: Run<T>): this;
define(...args: any[]) {
const [path, key, opts] = args;
// 全覆盖所以opts需要准确不能由idUsePath 需要check的变量
const setOpts = (opts: DefineRouteOpts) => {
const keys = Object.keys(opts);
const checkList = ['path', 'key', 'run', 'nextRoute', 'description', 'middleware', 'type', 'validator', 'isVerify', 'isDebug'];
for (let item of keys) {
if (!checkList.includes(item)) {
continue;
}
if (item === 'validator') {
this.validator = opts[item];
continue;
}
if (item === 'middleware') {
this.middleware = this.middleware.concat(opts[item]);
continue;
}
this[item] = opts[item];
}
};
if (typeof path === 'object') {
setOpts(path);
return this;
}
if (typeof path === 'function') {
this.run = path;
return this;
}
if (typeof path === 'string' && typeof key === 'function') {
setOpts({ path, run: key });
return this;
}
if (typeof path === 'string' && typeof key === 'string' && typeof opts === 'function') {
setOpts({ path, key, run: opts });
return this;
}
return this;
}
addTo(router: QueryRouter | { add: (route: Route) => void; [key: string]: any }) {
router.add(this);
}
setData(data: any) {
this.data = data;
return this;
}
}
export class QueryRouter {
routes: Route[];
maxNextRoute = 40;
constructor() {
this.routes = [];
}
add(route: Route) {
const has = this.routes.find((r) => r.path === route.path && r.key === route.key);
if (has) {
// remove the old route
this.routes = this.routes.filter((r) => r.path === route.path && r.key === route.key);
}
this.routes.push(route);
}
/**
* remove route by path and key
* @param route
*/
remove(route: Route | { path: string; key: string }) {
this.routes = this.routes.filter((r) => r.path === route.path && r.key === route.key);
}
/**
* remove route by id
* @param uniqueId
*/
removeById(unique: string) {
this.routes = this.routes.filter((r) => r.id !== unique);
}
/**
* route
* @param path
* @param key
* @param ctx
* @returns
*/
async runRoute(path: string, key: string, ctx?: RouteContext) {
const route = this.routes.find((r) => r.path === path && r.key === key);
const maxNextRoute = this.maxNextRoute;
ctx = (ctx || {}) as RouteContext;
ctx.currentPath = path;
ctx.currentKey = key;
ctx.currentRoute = route;
ctx.index = (ctx.index || 0) + 1;
if (ctx.index > maxNextRoute) {
ctx.code = 500;
ctx.message = 'Too many nextRoute';
ctx.body = null;
return;
}
// run middleware
if (route && route.middleware && route.middleware.length > 0) {
const errorMiddleware: { path?: string; key?: string; id?: string }[] = [];
// TODO: 向上递归执行动作, 暂时不考虑
const routeMiddleware = route.middleware.map((m) => {
let route: Route | undefined;
const isString = typeof m === 'string';
if (typeof m === 'string') {
route = this.routes.find((r) => r.id === m);
} else {
route = this.routes.find((r) => r.path === m.path && r.key === m.key);
}
if (!route) {
if (isString) {
errorMiddleware.push({
id: m as string,
});
} else
errorMiddleware.push({
path: m?.path,
key: m?.key,
});
}
return route;
});
if (errorMiddleware.length > 0) {
console.error('middleware not found');
ctx.body = errorMiddleware;
ctx.message = 'middleware not found';
ctx.code = 404;
return ctx;
}
for (let i = 0; i < routeMiddleware.length; i++) {
const middleware = routeMiddleware[i];
if (middleware) {
if (middleware?.isVerify) {
try {
middleware.verify(ctx);
} catch (e) {
if (middleware?.isDebug) {
console.error('=====debug====:', 'middleware verify error:', e.message);
}
ctx.message = e.message;
ctx.code = 500;
ctx.body = null;
return ctx;
}
}
try {
await middleware.run(ctx);
} catch (e) {
if (route?.isDebug) {
console.error('=====debug====:middlerware error');
console.error('=====debug====:[path:key]:', `${route.path}-${route.key}`);
console.error('=====debug====:', e.message);
}
if (e instanceof CustomError) {
ctx.code = e.code;
ctx.message = e.message;
ctx.body = null;
} else {
console.error(`fn:${route.path}-${route.key}:${route.id}`);
console.error(`middleware:${middleware.path}-${middleware.key}:${middleware.id}`);
ctx.code = 500;
ctx.message = 'Internal Server Error';
ctx.body = null;
}
return ctx;
}
if (ctx.end) {
}
}
}
}
// run route
if (route) {
if (route.run) {
if (route?.isVerify) {
try {
route.verify(ctx);
} catch (e) {
if (route?.isDebug) {
console.error('=====debug====:', 'verify error:', e.message);
}
ctx.message = e.message;
ctx.code = 500;
ctx.body = null;
return ctx;
}
}
try {
await route.run(ctx);
} catch (e) {
if (route?.isDebug) {
console.error('=====debug====:', 'router run error:', e.message);
}
if (e instanceof CustomError) {
ctx.code = e.code;
ctx.message = e.message;
} else {
console.error(`[error]fn:${route.path}-${route.key}:${route.id}`);
console.error('error', e.message);
ctx.code = 500;
ctx.message = 'Internal Server Error';
}
ctx.body = null;
return ctx;
}
if (ctx.end) {
// TODO: 提前结束, 以及错误情况
return;
}
if (route.nextRoute) {
let path: string, key: string;
if (route.nextRoute.path || route.nextRoute.key) {
path = route.nextRoute.path;
key = route.nextRoute.key;
} else if (route.nextRoute.id) {
const nextRoute = this.routes.find((r) => r.id === route.nextRoute.id);
if (nextRoute) {
path = nextRoute.path;
key = nextRoute.key;
}
}
if (!path || !key) {
ctx.message = 'nextRoute not found';
ctx.code = 404;
ctx.body = null;
return ctx;
}
ctx.query = ctx.nextQuery;
ctx.nextQuery = {};
return await this.runRoute(path, key, ctx);
}
// clear body
ctx.body = JSON.parse(JSON.stringify(ctx.body||''));
if (!ctx.code) ctx.code = 200;
return ctx;
} else {
// return Promise.resolve({ code: 404, body: 'Not found runing' });
// 可以不需要run的route因为不一定是错误
return ctx;
}
}
// 如果没有找到route返回404这是因为出现了错误
return Promise.resolve({ code: 404, body: 'Not found' });
}
/**
*
* @param message
* @param ctx
* @returns
*/
async parse(message: { path: string; key?: string; payload?: any }, ctx?: RouteContext & { [key: string]: any }) {
if (!message?.path) {
return Promise.resolve({ code: 404, body: 'Not found path' });
}
const { path, key, payload = {}, ...query } = message;
ctx = ctx || {};
ctx.query = { ...ctx.query, ...query, ...payload };
ctx.state = {};
// put queryRouter to ctx
// TODO: 是否需要queryRouter函数内部处理router路由执行这应该是避免去内部去包含的功能过
ctx.queryRouter = this;
ctx.call = this.call.bind(this);
ctx.index = 0;
return await this.runRoute(path, key, ctx);
}
async call(message: { path: string; key: string; payload?: any }, ctx?: RouteContext & { [key: string]: any }) {
return await this.parse(message, ctx);
}
getList(): RouteInfo[] {
return this.routes.map((r) => {
return pick(r, pickValue as any);
});
}
getHandle<T = any>(router: QueryRouter, wrapperFn?: HandleFn<T>, ctx?: RouteContext) {
return async (msg: { path: string; key?: string; [key: string]: any }) => {
const context = { ...ctx };
const res = await router.parse(msg, context);
if (wrapperFn) {
res.data = res.body;
return wrapperFn(res, context);
}
const { code, body, message } = res;
return { code, data: body, message };
};
}
}
type QueryRouterServerOpts = {
handleFn?: HandleFn;
context?: RouteContext;
};
interface HandleFn<T = any> {
(msg: { path: string; [key: string]: any }, ctx?: any): { code: string; data?: any; message?: string; [key: string]: any };
(res: RouteContext<T>): any;
}
/**
* QueryRouterServer
* @description server相关的功能router相关的功能http.createServer不相关
*/
export class QueryRouterServer extends QueryRouter {
handle: any;
constructor(opts?: QueryRouterServerOpts) {
super();
this.handle = this.getHandle(this, opts?.handleFn, opts?.context);
}
setHandle(wrapperFn?: HandleFn, ctx?: RouteContext) {
this.handle = this.getHandle(this, wrapperFn, ctx);
}
}

View File

@ -0,0 +1,46 @@
import http, { IncomingMessage, Server, ServerResponse } from 'http';
import { parseBody } from './parse-body.ts';
import url from 'url';
/**
* get params and body
* @param req
* @param res
* @returns
*/
export const handleServer = async (req: IncomingMessage, res: ServerResponse) => {
if (req.url === '/favicon.ico') {
return;
}
const can = ['get', 'post'];
const method = req.method.toLocaleLowerCase();
if (!can.includes(method)) {
return;
}
const parsedUrl = url.parse(req.url, true);
// 获取token
let token = req.headers['authorization'] || '';
if (token) {
token = token.replace('Bearer ', '');
}
// 获取查询参数
const param = parsedUrl.query;
let body: Record<any, any>;
if (method === 'post') {
body = await parseBody(req);
}
if (param?.payload && typeof param.payload === 'string') {
try {
const payload = JSON.parse(param.payload as string);
param.payload = payload;
} catch (e) {
console.error(e);
}
}
const data = {
token,
...param,
...body,
};
return data;
};

2
src/server/index.ts Normal file
View File

@ -0,0 +1,2 @@
export { Server } from './server.ts';
export { handleServer } from './handle-server.ts';

18
src/server/parse-body.ts Normal file
View File

@ -0,0 +1,18 @@
import * as http from 'http';
export const parseBody = async (req: http.IncomingMessage) => {
return new Promise((resolve, reject) => {
const arr: any[] = [];
req.on('data', (chunk) => {
arr.push(chunk);
});
req.on('end', () => {
try {
const body = Buffer.concat(arr).toString();
resolve(JSON.parse(body));
} catch (e) {
resolve({});
}
});
});
};

148
src/server/server.ts Normal file
View File

@ -0,0 +1,148 @@
import http, { IncomingMessage, ServerResponse } from 'http';
import { handleServer } from './handle-server.ts';
export type Listener = (...args: any[]) => void;
export type Cors = {
/**
* @default '*''
*/
origin?: string | undefined;
};
type ServerOpts = {
/**path default `/api/router` */
path?: string;
/**handle Fn */
handle?: (msg?: { path: string; key?: string; [key: string]: any }) => any;
cors?: Cors;
};
export const resultError = (error: string, code = 500) => {
const r = {
code: code,
message: error,
};
return JSON.stringify(r);
};
export class Server {
path = '/api/router';
private _server: http.Server;
public handle: ServerOpts['handle'];
private _callback: any;
private cors: Cors;
private hasOn = false;
constructor(opts?: ServerOpts) {
this.path = opts?.path || '/api/router';
this.handle = opts?.handle;
this.cors = opts?.cors;
}
listen(port: number, hostname?: string, backlog?: number, listeningListener?: () => void): void;
listen(port: number, hostname?: string, listeningListener?: () => void): void;
listen(port: number, backlog?: number, listeningListener?: () => void): void;
listen(port: number, listeningListener?: () => void): void;
listen(path: string, backlog?: number, listeningListener?: () => void): void;
listen(path: string, listeningListener?: () => void): void;
listen(handle: any, backlog?: number, listeningListener?: () => void): void;
listen(handle: any, listeningListener?: () => void): void;
listen(...args: any[]) {
this._server = http.createServer();
const callback = this.createCallback();
this._server.on('request', callback);
this._server.listen(...args);
}
setHandle(handle?: any) {
this.handle = handle;
}
/**
* get callback
* @returns
*/
createCallback() {
const path = this.path;
const handle = this.handle;
const cors = this.cors;
const _callback = async (req: IncomingMessage, res: ServerResponse) => {
if (req.url === '/favicon.ico') {
return;
}
if (res.headersSent) {
// 程序已经在其他地方响应了
return;
}
if (this.hasOn && !req.url.startsWith(path)) {
// 其他监听存在,不判断不是当前路径的请求,
// 也就是不处理!url.startsWith(path)这个请求了
// 交给其他监听处理
return;
}
// res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.setHeader('Content-Type', 'application/json; charset=utf-8');
if (cors) {
res.setHeader('Access-Control-Allow-Origin', cors?.origin || '*'); // 允许所有域名的请求访问,可以根据需要设置具体的域名
res.setHeader('Access-Control-Allow-Methods', 'GET, POST');
if (req.method === 'OPTIONS') {
res.end();
return;
}
}
res.writeHead(200); // 设置响应头给予其他api知道headersSent它已经被响应了
const url = req.url;
if (!url.startsWith(path)) {
res.end(resultError(`not path:[${path}]`));
return;
}
const messages = await handleServer(req, res);
if (!handle) {
res.end(resultError('no handle'));
return;
}
try {
const end = await handle(messages as any);
if (typeof end === 'string') {
res.end(end);
} else {
res.end(JSON.stringify(end));
}
} catch (e) {
console.error(e);
if (e.code && typeof e.code === 'number') {
res.end(resultError(e.message || `Router Server error`, e.code));
} else {
res.end(resultError('Router Server error'));
}
}
};
this._callback = _callback;
return _callback;
}
get handleServer() {
return this._callback;
}
set handleServer(fn: any) {
this._callback = fn;
}
/**
* `/api/router` api
* @description
* @param listener
*/
on(listener: Listener | Listener[]) {
this._server = this._server || http.createServer();
this._server.removeAllListeners('request');
this.hasOn = true;
if (Array.isArray(listener)) {
listener.forEach((l) => this._server.on('request', l));
} else {
this._server.on('request', listener);
}
this._server.on('request', this._callback || this.createCallback());
}
get callback() {
return this._callback || this.createCallback();
}
get server() {
return this._server;
}
}

156
src/server/ws-server.ts Normal file
View File

@ -0,0 +1,156 @@
import { WebSocketServer, WebSocket } from 'ws';
import { Server } from './server.ts';
import { parseIfJson } from '../utils/parse.ts';
export const createWsServer = (server: Server) => {
// 将 WebSocket 服务器附加到 HTTP 服务器
const wss = new WebSocketServer({ server: server.server });
return wss;
};
type WsServerBaseOpts = {
wss?: WebSocketServer;
path?: string;
};
export type ListenerFn = (message: { data: Record<string, any>; ws: WebSocket; end: (data: any) => any }) => Promise<any>;
export type Listener<T = 'router' | 'chat' | 'ai'> = {
type: T;
listener: ListenerFn;
};
export class WsServerBase {
wss: WebSocketServer;
path: string;
listeners: { type: string; listener: ListenerFn }[] = [];
listening: boolean = false;
constructor(opts: WsServerBaseOpts) {
this.wss = opts.wss || new WebSocketServer();
this.path = opts.path || '';
}
setPath(path: string) {
this.path = path;
}
listen() {
if (this.listening) {
console.error('WsServer is listening');
return;
}
this.listening = true;
this.wss.on('connection', (ws) => {
ws.on('message', async (message: string) => {
const data = parseIfJson(message);
if (typeof data === 'string') {
ws.emit('string', data);
return;
}
const { type, data: typeData, ...rest } = data;
if (!type) {
ws.send(JSON.stringify({ code: 500, message: 'type is required' }));
}
const listeners = this.listeners.find((item) => item.type === type);
const res = {
type,
data: {} as any,
...rest,
};
const end = (data: any, all?: Record<string, any>) => {
const result = {
...res,
data,
...all,
};
ws.send(JSON.stringify(result));
};
if (!listeners) {
const data = { code: 500, message: `${type} server is error` };
end(data);
return;
}
listeners.listener({
data: typeData,
ws,
end: end,
});
});
ws.on('string', (message: string) => {
if (message === 'close') {
ws.close();
}
if (message === 'ping') {
ws.send('pong');
}
});
ws.send('connected');
});
}
addListener(type: string, listener: ListenerFn) {
if (!type || !listener) {
throw new Error('type and listener is required');
}
const find = this.listeners.find((item) => item.type === type);
if (find) {
this.listeners = this.listeners.filter((item) => item.type !== type);
}
this.listeners.push({ type, listener });
}
removeListener(type: string) {
this.listeners = this.listeners.filter((item) => item.type !== type);
}
}
// TODO: ws handle and path and routerContext
export class WsServer extends WsServerBase {
server: Server;
constructor(server: Server, opts?: any) {
const wss = new WebSocketServer({ noServer: true });
const path = server.path;
super({ wss });
this.server = server;
this.setPath(opts?.path || path);
this.initListener();
}
initListener() {
const server = this.server;
const listener: Listener = {
type: 'router',
listener: async ({ data, ws, end }) => {
if (!server) {
end({ code: 500, message: 'server handle is error' });
return;
}
const handle = this.server.handle;
try {
const result = await handle(data as any);
end(result);
} catch (e) {
if (e.code && typeof e.code === 'number') {
end({
code: e.code,
message: e.message,
});
} else {
end({ code: 500, message: 'Router Server error' });
}
}
},
};
this.addListener(listener.type, listener.listener);
}
listen() {
super.listen();
const server = this.server;
const wss = this.wss;
// HTTP 服务器的 upgrade 事件
server.server.on('upgrade', (req, socket, head) => {
if (req.url === this.path) {
wss.handleUpgrade(req, socket, head, (ws) => {
// 这里手动触发 connection 事件
// @ts-ignore
wss.emit('connection', ws, req);
});
} else {
socket.destroy();
}
});
}
}

97
src/static.ts Normal file
View File

@ -0,0 +1,97 @@
const http = require('http');
const fs = require('fs').promises;
const path = require('path');
const fetch = require('node-fetch'); // 如果使用 Node.js 18 以上版本,可以改用内置 fetch
const url = require('url');
// 配置远端静态文件服务器和本地缓存目录
const remoteServer = 'https://example.com/static'; // 远端服务器的 URL
const cacheDir = path.join(__dirname, 'cache'); // 本地缓存目录
const PORT = process.env.PORT || 3000;
// 确保本地缓存目录存在
fs.mkdir(cacheDir, { recursive: true }).catch(console.error);
// 获取文件的 content-type
function getContentType(filePath) {
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';
}
// 处理请求文件
async function serveFile(filePath, remoteUrl, res) {
try {
// 检查文件是否存在于本地缓存中
const fileContent = await fs.readFile(filePath);
res.writeHead(200, { 'Content-Type': getContentType(filePath) });
res.end(fileContent, 'utf-8');
} catch (err) {
if (err.code === 'ENOENT') {
// 本地缓存中不存在,向远端服务器请求文件
try {
const response = await fetch(remoteUrl);
if (response.ok) {
// 远端请求成功,获取文件内容
const data = await response.buffer();
// 将文件缓存到本地
await fs.writeFile(filePath, data);
// 返回文件内容
res.writeHead(200, { 'Content-Type': getContentType(filePath) });
res.end(data, 'utf-8');
} else {
// 远端文件未找到或错误,返回 404
res.writeHead(404, { 'Content-Type': 'text/plain' });
res.end(`Error 404: File not found at ${remoteUrl}`);
}
} catch (fetchErr) {
// 处理请求错误
res.writeHead(500, { 'Content-Type': 'text/plain' });
res.end(`Server Error: Unable to fetch ${remoteUrl}`);
}
} else {
// 其他文件系统错误
res.writeHead(500, { 'Content-Type': 'text/plain' });
res.end(`Server Error: ${err.message}`);
}
}
}
// 创建 HTTP 服务器
http.createServer(async (req, res) => {
let reqPath = req.url;
// 如果路径是根路径 `/`,将其设置为 `index.html`
if (reqPath === '/') reqPath = '/index.html';
// 构建本地缓存路径和远端 URL
const localFilePath = path.join(cacheDir, reqPath); // 本地文件路径
const remoteFileUrl = url.resolve(remoteServer, reqPath); // 远端文件 URL
// 根据请求路径处理文件或返回 index.html单页面应用处理
await serveFile(localFilePath, remoteFileUrl, res);
// 单页面应用的路由处理
if (res.headersSent) return; // 如果响应已发送,不再处理
// 如果未匹配到任何文件,返回 index.html
const indexFilePath = path.join(cacheDir, 'index.html');
const indexRemoteUrl = url.resolve(remoteServer, '/index.html');
await serveFile(indexFilePath, indexRemoteUrl, res);
}).listen(PORT, () => {
console.log(`Server running at http://localhost:${PORT}`);
});

13
src/utils/parse.ts Normal file
View File

@ -0,0 +1,13 @@
export const parseIfJson = (input: string): { [key: string]: any } | string => {
try {
// 尝试解析 JSON
const parsed = JSON.parse(input);
// 检查解析结果是否为对象(数组或普通对象)
if (typeof parsed === 'object' && parsed !== null) {
return parsed;
}
} catch (e) {
// 如果解析失败,直接返回原始字符串
}
return input;
};

9
src/utils/pick.ts Normal file
View File

@ -0,0 +1,9 @@
export function pick<T extends object, K extends keyof T>(obj: T, keys: K[]): Pick<T, K> {
const result = {} as Pick<T, K>;
keys.forEach((key) => {
if (key in obj) {
result[key] = obj[key];
}
});
return result;
}

1
src/validator/index.ts Normal file
View File

@ -0,0 +1 @@
export * from './rule.ts';

92
src/validator/rule.ts Normal file
View File

@ -0,0 +1,92 @@
import { z, ZodError, Schema } from 'zod';
export { Schema };
type BaseRule = {
value?: any;
required?: boolean;
message?: string;
};
type RuleString = {
type: 'string';
minLength?: number;
maxLength?: number;
regex?: string;
} & BaseRule;
type RuleNumber = {
type: 'number';
min?: number;
max?: number;
} & BaseRule;
type RuleBoolean = {
type: 'boolean';
} & BaseRule;
type RuleArray = {
type: 'array';
items: Rule;
minItems?: number;
maxItems?: number;
} & BaseRule;
type RuleObject = {
type: 'object';
properties: { [key: string]: Rule };
} & BaseRule;
type RuleAny = {
type: 'any';
} & BaseRule;
export type Rule = RuleString | RuleNumber | RuleBoolean | RuleArray | RuleObject | RuleAny;
export const schemaFormRule = (rule: Rule): z.ZodType<any, any, any> => {
switch (rule.type) {
case 'string':
let stringSchema = z.string();
if (rule.minLength) stringSchema = stringSchema.min(rule.minLength, `String must be at least ${rule.minLength} characters long.`);
if (rule.maxLength) stringSchema = stringSchema.max(rule.maxLength, `String must not exceed ${rule.maxLength} characters.`);
if (rule.regex) stringSchema = stringSchema.regex(new RegExp(rule.regex), 'Invalid format');
return stringSchema;
case 'number':
let numberSchema = z.number();
if (rule.min) numberSchema = numberSchema.min(rule.min, `Number must be at least ${rule.min}.`);
if (rule.max) numberSchema = numberSchema.max(rule.max, `Number must not exceed ${rule.max}.`);
return numberSchema;
case 'boolean':
return z.boolean();
case 'array':
return z.array(createSchema(rule.items));
case 'object':
return z.object(Object.fromEntries(Object.entries(rule.properties).map(([key, value]) => [key, createSchema(value)])));
case 'any':
return z.any();
default:
throw new Error(`Unknown rule type: ${(rule as any)?.type}`);
}
};
export const createSchema = (rule: Rule): Schema => {
try {
rule.required = rule.required || false;
if (!rule.required) {
return schemaFormRule(rule).nullable();
}
return schemaFormRule(rule);
} catch (e) {
if (e instanceof ZodError) {
console.error(e.format());
}
throw e;
}
};
export const createSchemaList = (rules: Rule[]) => {
try {
return rules.map((rule) => createSchema(rule));
} catch (e) {
if (e instanceof ZodError) {
console.error(e.format());
}
}
};

34
tsconfig.json Normal file
View File

@ -0,0 +1,34 @@
{
"compilerOptions": {
"module": "NodeNext",
"target": "esnext",
"noImplicitAny": false,
"outDir": "./dist",
"sourceMap": false,
"allowJs": true,
"newLine": "LF",
"baseUrl": "./",
"typeRoots": [
"node_modules/@types",
],
"declaration": true,
"noEmit": true,
"allowImportingTsExtensions": true,
"moduleResolution": "NodeNext",
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"esModuleInterop": true,
"paths": {
"@/*": [
"src/*"
],
}
},
"include": [
"src/**/*.ts"
],
"exclude": [
"node_modules",
"rollup.config.js",
]
}