Skip to content

Commit

Permalink
feat(core): implement core
Browse files Browse the repository at this point in the history
  • Loading branch information
shigma committed Dec 4, 2023
1 parent 28511e6 commit c06e429
Show file tree
Hide file tree
Showing 6 changed files with 287 additions and 5 deletions.
15 changes: 10 additions & 5 deletions package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -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"
}
}
2 changes: 2 additions & 0 deletions packages/core/.npmignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
.DS_Store
tsconfig.tsbuildinfo
48 changes: 48 additions & 0 deletions packages/core/package.json
Original file line number Diff line number Diff line change
@@ -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 <[email protected]>",
"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"
}
}
175 changes: 175 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
@@ -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<WebSocket>()
regexp: RegExp

constructor(private router: Router, path: MaybeArray<string | RegExp>, 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<any>('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<KoaRouter['register']>) {
const layer = super.register(...args)
const context = this[Context.current]
context?.state.disposables.push(() => {
remove(this.stack, layer)
})
return layer
}

ws(path: MaybeArray<string | RegExp>, 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<Config> = 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
40 changes: 40 additions & 0 deletions packages/core/src/listen.ts
Original file line number Diff line number Diff line change
@@ -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<number>((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()
})
}
12 changes: 12 additions & 0 deletions packages/core/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"extends": "../../tsconfig.base",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib",
"strict": true,
"noImplicitAny": false,
},
"include": [
"src",
],
}

0 comments on commit c06e429

Please sign in to comment.