From c06e429e6d263415b6b2a016e80d06e0b030e77c Mon Sep 17 00:00:00 2001 From: Shigma Date: Tue, 5 Dec 2023 02:57:33 +0800 Subject: [PATCH] feat(core): implement core --- package.json | 15 ++-- packages/core/.npmignore | 2 + packages/core/package.json | 48 ++++++++++ packages/core/src/index.ts | 175 ++++++++++++++++++++++++++++++++++++ packages/core/src/listen.ts | 40 +++++++++ packages/core/tsconfig.json | 12 +++ 6 files changed, 287 insertions(+), 5 deletions(-) create mode 100644 packages/core/.npmignore create mode 100644 packages/core/package.json create mode 100644 packages/core/src/index.ts create mode 100644 packages/core/src/listen.ts create mode 100644 packages/core/tsconfig.json diff --git a/package.json b/package.json index 39f93e9..e1422ff 100644 --- a/package.json +++ b/package.json @@ -1,17 +1,14 @@ { - "name": "@root/yakumo", + "name": "@root/server", "private": true, "version": "1.0.0", "workspaces": [ "external/*", - "fixtures/*", - "fixtures/default/packages/*", "packages/*" ], "license": "MIT", "scripts": { "build": "tsc -b", - "yakumo": "node -r esbuild-register packages/core/src/bin", "bump": "yarn yakumo version", "dep": "yarn yakumo upgrade", "pub": "yarn yakumo publish", @@ -30,6 +27,14 @@ "esbuild-register": "^3.5.0", "mocha": "^9.2.2", "shx": "^0.3.4", - "typescript": "^5.3.2" + "typescript": "^5.3.2", + "yakumo": "^0.3.13", + "yakumo-esbuild": "^0.3.26", + "yakumo-mocha": "^0.3.1", + "yakumo-publish": "^0.3.10", + "yakumo-publish-sync": "^0.3.3", + "yakumo-tsc": "^0.3.12", + "yakumo-upgrade": "^0.3.6", + "yakumo-version": "^0.3.4" } } diff --git a/packages/core/.npmignore b/packages/core/.npmignore new file mode 100644 index 0000000..7e5fcbc --- /dev/null +++ b/packages/core/.npmignore @@ -0,0 +1,2 @@ +.DS_Store +tsconfig.tsbuildinfo diff --git a/packages/core/package.json b/packages/core/package.json new file mode 100644 index 0000000..0308583 --- /dev/null +++ b/packages/core/package.json @@ -0,0 +1,48 @@ +{ + "name": "@cordisjs/server", + "description": "Server plugin for cordis", + "version": "0.1.2", + "main": "lib/index.js", + "types": "lib/index.d.ts", + "files": [ + "lib", + "src" + ], + "author": "Shigma ", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/cordisjs/server.git", + "directory": "packages/core" + }, + "bugs": { + "url": "https://github.com/cordisjs/server/issues" + }, + "homepage": "https://github.com/cordisjs/server", + "keywords": [ + "cordis", + "router", + "http", + "ws", + "websocket", + "server", + "service", + "plugin" + ], + "devDependencies": { + "@types/parseurl": "^1.3.3" + }, + "dependencies": { + "@koa/router": "^10.1.1", + "@types/koa": "*", + "@types/koa__router": "*", + "@types/ws": "^8.5.10", + "koa": "^2.14.2", + "koa-bodyparser": "^4.4.1", + "parseurl": "^1.3.3", + "path-to-regexp": "^6.2.1", + "reggol": "^1.6.3", + "schemastery": "^3.14.1", + "ws": "^8.14.2" + } +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts new file mode 100644 index 0000000..a28e4d5 --- /dev/null +++ b/packages/core/src/index.ts @@ -0,0 +1,175 @@ +import { Context } from 'cordis' +import { MaybeArray, remove, trimSlash } from 'cosmokit' +import { createServer, IncomingMessage, Server } from 'http' +import { pathToRegexp } from 'path-to-regexp' +import parseUrl from 'parseurl' +import WebSocket from 'ws' +import Logger from 'reggol' +import Schema from 'schemastery' +import KoaRouter from '@koa/router' +import Koa from 'koa' +import { listen } from './listen' + +declare module 'koa' { + // koa-bodyparser + interface Request { + body?: any + rawBody?: string + } +} + +declare module 'cordis' { + interface Context { + router: Router + } + + interface Events { + 'router/ready'(this: Router): void + } +} + +type WebSocketCallback = (socket: WebSocket, request: IncomingMessage) => void + +export class WebSocketLayer { + clients = new Set() + regexp: RegExp + + constructor(private router: Router, path: MaybeArray, public callback?: WebSocketCallback) { + this.regexp = pathToRegexp(path) + } + + accept(socket: WebSocket, request: IncomingMessage) { + if (!this.regexp.test(parseUrl(request)!.pathname!)) return + this.clients.add(socket) + socket.addEventListener('close', () => { + this.clients.delete(socket) + }) + this.callback?.(socket, request) + return true + } + + close() { + remove(this.router.wsStack, this) + for (const socket of this.clients) { + socket.close() + } + } +} + +export class Router extends KoaRouter { + public _http: Server + public _ws: WebSocket.Server + public wsStack: WebSocketLayer[] = [] + + public host!: string + public port!: number + + private logger: Logger + + constructor(protected ctx: Context, public config: Router.Config) { + super() + this.logger = new Logger('router', { [Context.current]: this }) + + // create server + const koa = new Koa() + koa.use(require('koa-bodyparser')({ + enableTypes: ['json', 'form', 'xml'], + jsonLimit: '10mb', + formLimit: '10mb', + textLimit: '10mb', + xmlLimit: '10mb', + })) + koa.use(this.routes()) + koa.use(this.allowedMethods()) + + this._http = createServer(koa.callback()) + this._ws = new WebSocket.Server({ + server: this._http, + }) + + this._ws.on('connection', (socket, request) => { + for (const manager of this.wsStack) { + if (manager.accept(socket, request)) return + } + socket.close() + }) + + ctx.decline(['selfUrl', 'host', 'port', 'maxPort']) + + if (config.selfUrl) { + config.selfUrl = trimSlash(config.selfUrl) + } + + ctx.on('ready', async () => { + const { host = '127.0.0.1', port } = config + if (!port) return + this.host = host + this.port = await listen(config) + this._http.listen(this.port, host) + this.logger.info('server listening at %c', this.selfUrl) + ctx.emit(this, 'router/ready') + }, true) + + ctx.on('dispose', () => { + if (config.port) { + this.logger.info('http server closing') + } + this._ws?.close() + this._http?.close() + }) + + ctx.on('event/router/ready', (ctx: Context, listener: Function) => { + if (!this[Context.filter](ctx) || !this.port) return + ctx.scope.ensure(async () => listener()) + return () => false + }) + } + + [Context.filter](ctx: Context) { + return ctx[Context.shadow].router === this.ctx[Context.shadow].router + } + + get selfUrl() { + const wildcard = ['0.0.0.0', '::'] + const host = wildcard.includes(this.host) ? '127.0.0.1' : this.host + return `http://${host}:${this.port}` + } + + /** + * hack into router methods to make sure that koa middlewares are disposable + */ + register(...args: Parameters) { + const layer = super.register(...args) + const context = this[Context.current] + context?.state.disposables.push(() => { + remove(this.stack, layer) + }) + return layer + } + + ws(path: MaybeArray, callback?: WebSocketCallback) { + const layer = new WebSocketLayer(this, path, callback) + this.wsStack.push(layer) + const context = this[Context.current] + context?.state.disposables.push(() => layer.close()) + return layer + } +} + +export namespace Router { + export interface Config { + host: string + port: number + maxPort?: number + selfUrl?: string + } + + export const Config: Schema = Schema.object({ + host: Schema.string().default('127.0.0.1').description('要监听的 IP 地址。如果将此设置为 `0.0.0.0` 将监听所有地址,包括局域网和公网地址。'), + port: Schema.natural().max(65535).description('要监听的初始端口号。'), + maxPort: Schema.natural().max(65535).description('允许监听的最大端口号。'), + selfUrl: Schema.string().role('link').description('应用暴露在公网的地址。'), + }) +} + +export default Router diff --git a/packages/core/src/listen.ts b/packages/core/src/listen.ts new file mode 100644 index 0000000..8026f8f --- /dev/null +++ b/packages/core/src/listen.ts @@ -0,0 +1,40 @@ +import net from 'net' + +export interface ListenOptions { + host: string + port: number + maxPort?: number +} + +export function listen({ host, port, maxPort = port }: ListenOptions) { + const server = net.createServer() + + return new Promise((resolve, reject) => { + function onListen() { + server.off('error', onError) + server.close((err) => { + err ? reject(err) : resolve(port) + }) + } + + function onError(err: NodeJS.ErrnoException) { + server.off('listening', onListen) + if (!(err.code === 'EADDRINUSE' || err.code === 'EACCES')) { + return reject(err) + } + port++ + if (port > maxPort) { + return reject(new Error('No open ports available')) + } + testPort() + } + + function testPort() { + server.once('error', onError) + server.once('listening', onListen) + server.listen(port, host) + } + + testPort() + }) +} diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json new file mode 100644 index 0000000..6f11f32 --- /dev/null +++ b/packages/core/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.base", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib", + "strict": true, + "noImplicitAny": false, + }, + "include": [ + "src", + ], +}