From 733677f3f34570f92ff69593914d78e889a922cf Mon Sep 17 00:00:00 2001 From: xion Date: Wed, 16 Oct 2024 00:47:30 +0800 Subject: [PATCH] init router --- .github/workflows/publish.yml | 38 +++ .gitignore | 3 + demo/simple/package-lock.json | 268 ++++++++++++++++ demo/simple/package.json | 20 ++ demo/simple/simple-ws/index.html | 74 +++++ demo/simple/src/app-02.ts | 41 +++ demo/simple/src/app-ws.ts | 27 ++ demo/simple/src/app.ts | 27 ++ demo/simple/src/index.ts | 36 +++ demo/simple/src/validator/app.ts | 85 +++++ demo/simple/tsconfig.json | 43 +++ package.json | 44 +++ readme.md | 31 ++ rollup.config.js | 24 ++ src/app.ts | 84 +++++ src/connect.ts | 67 ++++ src/index.ts | 21 ++ src/io.ts | 6 + src/result/error.ts | 67 ++++ src/result/index.ts | 45 +++ src/route.ts | 518 +++++++++++++++++++++++++++++++ src/server/handle-server.ts | 46 +++ src/server/index.ts | 2 + src/server/parse-body.ts | 18 ++ src/server/server.ts | 148 +++++++++ src/server/ws-server.ts | 156 ++++++++++ src/static.ts | 97 ++++++ src/utils/parse.ts | 13 + src/utils/pick.ts | 9 + src/validator/index.ts | 1 + src/validator/rule.ts | 92 ++++++ tsconfig.json | 34 ++ 32 files changed, 2185 insertions(+) create mode 100644 .github/workflows/publish.yml create mode 100644 .gitignore create mode 100644 demo/simple/package-lock.json create mode 100644 demo/simple/package.json create mode 100644 demo/simple/simple-ws/index.html create mode 100644 demo/simple/src/app-02.ts create mode 100644 demo/simple/src/app-ws.ts create mode 100644 demo/simple/src/app.ts create mode 100644 demo/simple/src/index.ts create mode 100644 demo/simple/src/validator/app.ts create mode 100644 demo/simple/tsconfig.json create mode 100644 package.json create mode 100644 readme.md create mode 100644 rollup.config.js create mode 100644 src/app.ts create mode 100644 src/connect.ts create mode 100644 src/index.ts create mode 100644 src/io.ts create mode 100644 src/result/error.ts create mode 100644 src/result/index.ts create mode 100644 src/route.ts create mode 100644 src/server/handle-server.ts create mode 100644 src/server/index.ts create mode 100644 src/server/parse-body.ts create mode 100644 src/server/server.ts create mode 100644 src/server/ws-server.ts create mode 100644 src/static.ts create mode 100644 src/utils/parse.ts create mode 100644 src/utils/pick.ts create mode 100644 src/validator/index.ts create mode 100644 src/validator/rule.ts create mode 100644 tsconfig.json diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..575edef --- /dev/null +++ b/.github/workflows/publish.yml @@ -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 \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..32c4a0c --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules +src/app.config.json5 +dist \ No newline at end of file diff --git a/demo/simple/package-lock.json b/demo/simple/package-lock.json new file mode 100644 index 0000000..0e4853b --- /dev/null +++ b/demo/simple/package-lock.json @@ -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" + } + } + } +} diff --git a/demo/simple/package.json b/demo/simple/package.json new file mode 100644 index 0000000..2774163 --- /dev/null +++ b/demo/simple/package.json @@ -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" + } +} diff --git a/demo/simple/simple-ws/index.html b/demo/simple/simple-ws/index.html new file mode 100644 index 0000000..e8e8d70 --- /dev/null +++ b/demo/simple/simple-ws/index.html @@ -0,0 +1,74 @@ + + + + + + + WebSocket with Redis + + + +

Real-time Data Updates

+

Waiting for updates...

+ + + + + \ No newline at end of file diff --git a/demo/simple/src/app-02.ts b/demo/simple/src/app-02.ts new file mode 100644 index 0000000..d132cf7 --- /dev/null +++ b/demo/simple/src/app-02.ts @@ -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); diff --git a/demo/simple/src/app-ws.ts b/demo/simple/src/app-ws.ts new file mode 100644 index 0000000..9a5b7c6 --- /dev/null +++ b/demo/simple/src/app-ws.ts @@ -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`); diff --git a/demo/simple/src/app.ts b/demo/simple/src/app.ts new file mode 100644 index 0000000..01dc604 --- /dev/null +++ b/demo/simple/src/app.ts @@ -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`); diff --git a/demo/simple/src/index.ts b/demo/simple/src/index.ts new file mode 100644 index 0000000..07bb98f --- /dev/null +++ b/demo/simple/src/index.ts @@ -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); + diff --git a/demo/simple/src/validator/app.ts b/demo/simple/src/validator/app.ts new file mode 100644 index 0000000..5b29e29 --- /dev/null +++ b/demo/simple/src/validator/app.ts @@ -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(); diff --git a/demo/simple/tsconfig.json b/demo/simple/tsconfig.json new file mode 100644 index 0000000..76f3427 --- /dev/null +++ b/demo/simple/tsconfig.json @@ -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 + } +} \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..be571ca --- /dev/null +++ b/package.json @@ -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" + } +} \ No newline at end of file diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..f802d49 --- /dev/null +++ b/readme.md @@ -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); +``` diff --git a/rollup.config.js b/rollup.config.js new file mode 100644 index 0000000..1cd10e4 --- /dev/null +++ b/rollup.config.js @@ -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'] +}; diff --git a/src/app.ts b/src/app.ts new file mode 100644 index 0000000..76471d0 --- /dev/null +++ b/src/app.ts @@ -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 = { + router?: QueryRouter; + server?: Server; + /** handle msg 关联 */ + routerHandle?: RouterHandle; + routerContext?: RouteContext; + serverOptions?: { + path?: string; + cors?: Cors; + handle?: any; + }; + io?: boolean; + ioOpts?: { routerHandle?: RouterHandle; routerContext?: RouteContext; path?: string }; +}; +export class App { + router: QueryRouter; + server: Server; + io: WsServer; + constructor(opts?: AppOptions) { + 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); + } +} diff --git a/src/connect.ts b/src/connect.ts new file mode 100644 index 0000000..56f4cff --- /dev/null +++ b/src/connect.ts @@ -0,0 +1,67 @@ +import { nanoid } from 'nanoid'; +import { RouteContext } from './route.ts'; + +export class Connect { + path: string; + key?: string; + _fn?: (ctx?: RouteContext) => Promise; + 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) { + 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, + }; + }); + } +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..296da8f --- /dev/null +++ b/src/index.ts @@ -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'; diff --git a/src/io.ts b/src/io.ts new file mode 100644 index 0000000..ed76dc2 --- /dev/null +++ b/src/io.ts @@ -0,0 +1,6 @@ +// TODO: Implement IOApp +export class IOApp { + constructor() { + console.log('IoApp'); + } +} diff --git a/src/result/error.ts b/src/result/error.ts new file mode 100644 index 0000000..0ac6e53 --- /dev/null +++ b/src/result/error.ts @@ -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; + } + } +} +*/ diff --git a/src/result/index.ts b/src/result/index.ts new file mode 100644 index 0000000..8f9077a --- /dev/null +++ b/src/result/index.ts @@ -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, + }; +}; diff --git a/src/route.ts b/src/route.ts new file mode 100644 index 0000000..35f6733 --- /dev/null +++ b/src/route.ts @@ -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 = { + // 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; + index?: number; +} & T; + +export type Run = (ctx?: RouteContext) => Promise; + +export type NextRoute = Pick; +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 }; + isVerify?: boolean; + verify?: (ctx?: RouteContext, dev?: boolean) => boolean; + verifyKey?: (key: string, ctx?: RouteContext, dev?: boolean) => boolean; + idUsePath?: boolean; + isDebug?: boolean; +}; +export type DefineRouteOpts = Omit; +const pickValue = ['path', 'key', 'id', 'description', 'type', 'validator', 'middleware'] as const; +export type RouteInfo = Pick; +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 }; + 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(opts: DefineRouteOpts): this; + define(fn: Run): this; + define(key: string, fn: Run): this; + define(path: string, key: string, fn: Run): 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(router: QueryRouter, wrapperFn?: HandleFn, 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 { + (msg: { path: string; [key: string]: any }, ctx?: any): { code: string; data?: any; message?: string; [key: string]: any }; + (res: RouteContext): 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); + } +} diff --git a/src/server/handle-server.ts b/src/server/handle-server.ts new file mode 100644 index 0000000..eb636f8 --- /dev/null +++ b/src/server/handle-server.ts @@ -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; + 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; +}; diff --git a/src/server/index.ts b/src/server/index.ts new file mode 100644 index 0000000..bfb2689 --- /dev/null +++ b/src/server/index.ts @@ -0,0 +1,2 @@ +export { Server } from './server.ts'; +export { handleServer } from './handle-server.ts'; diff --git a/src/server/parse-body.ts b/src/server/parse-body.ts new file mode 100644 index 0000000..b165849 --- /dev/null +++ b/src/server/parse-body.ts @@ -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({}); + } + }); + }); +}; diff --git a/src/server/server.ts b/src/server/server.ts new file mode 100644 index 0000000..7943510 --- /dev/null +++ b/src/server/server.ts @@ -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; + } +} diff --git a/src/server/ws-server.ts b/src/server/ws-server.ts new file mode 100644 index 0000000..52eb4bd --- /dev/null +++ b/src/server/ws-server.ts @@ -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; ws: WebSocket; end: (data: any) => any }) => Promise; +export type Listener = { + 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) => { + 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(); + } + }); + } +} diff --git a/src/static.ts b/src/static.ts new file mode 100644 index 0000000..440a7c2 --- /dev/null +++ b/src/static.ts @@ -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}`); +}); diff --git a/src/utils/parse.ts b/src/utils/parse.ts new file mode 100644 index 0000000..095b5b8 --- /dev/null +++ b/src/utils/parse.ts @@ -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; +}; diff --git a/src/utils/pick.ts b/src/utils/pick.ts new file mode 100644 index 0000000..60b8f46 --- /dev/null +++ b/src/utils/pick.ts @@ -0,0 +1,9 @@ +export function pick(obj: T, keys: K[]): Pick { + const result = {} as Pick; + keys.forEach((key) => { + if (key in obj) { + result[key] = obj[key]; + } + }); + return result; +} diff --git a/src/validator/index.ts b/src/validator/index.ts new file mode 100644 index 0000000..068a38e --- /dev/null +++ b/src/validator/index.ts @@ -0,0 +1 @@ +export * from './rule.ts'; diff --git a/src/validator/rule.ts b/src/validator/rule.ts new file mode 100644 index 0000000..4e1c6b2 --- /dev/null +++ b/src/validator/rule.ts @@ -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 => { + 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()); + } + } +}; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..8e111c6 --- /dev/null +++ b/tsconfig.json @@ -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", + ] +} \ No newline at end of file