starlight-server 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +1 -0
- package/dist/demo/index.d.ts +1 -0
- package/dist/demo/index.js +27 -0
- package/dist/http/body/form-data.d.ts +35 -0
- package/dist/http/body/form-data.js +141 -0
- package/dist/http/body/index.d.ts +23 -0
- package/dist/http/body/index.js +47 -0
- package/dist/http/body/receive.d.ts +7 -0
- package/dist/http/body/receive.js +39 -0
- package/dist/http/http-status.d.ts +9 -0
- package/dist/http/http-status.js +64 -0
- package/dist/http/index.d.ts +9 -0
- package/dist/http/index.js +9 -0
- package/dist/http/mime-types.d.ts +14 -0
- package/dist/http/mime-types.js +764 -0
- package/dist/http/request.d.ts +25 -0
- package/dist/http/request.js +40 -0
- package/dist/http/response.d.ts +32 -0
- package/dist/http/response.js +66 -0
- package/dist/http/server.d.ts +31 -0
- package/dist/http/server.js +52 -0
- package/dist/http/types.d.ts +26 -0
- package/dist/http/types.js +12 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +4 -0
- package/dist/logging.d.ts +24 -0
- package/dist/logging.js +30 -0
- package/dist/router/cors.d.ts +24 -0
- package/dist/router/cors.js +35 -0
- package/dist/router/index.d.ts +38 -0
- package/dist/router/index.js +36 -0
- package/dist/router/match.d.ts +23 -0
- package/dist/router/match.js +172 -0
- package/dist/router/parameters.d.ts +51 -0
- package/dist/router/parameters.js +118 -0
- package/dist/router/router.d.ts +127 -0
- package/dist/router/router.js +97 -0
- package/dist/swagger/index.d.ts +1 -0
- package/dist/swagger/index.js +168 -0
- package/dist/swagger/openapi-spec.d.ts +261 -0
- package/dist/swagger/openapi-spec.js +5 -0
- package/dist/validators/array.d.ts +9 -0
- package/dist/validators/array.js +28 -0
- package/dist/validators/boolean.d.ts +4 -0
- package/dist/validators/boolean.js +28 -0
- package/dist/validators/common.d.ts +23 -0
- package/dist/validators/common.js +25 -0
- package/dist/validators/index.d.ts +20 -0
- package/dist/validators/index.js +38 -0
- package/dist/validators/number.d.ts +10 -0
- package/dist/validators/number.js +30 -0
- package/dist/validators/object.d.ts +13 -0
- package/dist/validators/object.js +36 -0
- package/dist/validators/string.d.ts +11 -0
- package/dist/validators/string.js +29 -0
- package/package.json +54 -0
- package/src/demo/index.ts +33 -0
- package/src/http/body/form-data.ts +164 -0
- package/src/http/body/index.ts +59 -0
- package/src/http/body/receive.ts +49 -0
- package/src/http/http-status.ts +65 -0
- package/src/http/index.ts +9 -0
- package/src/http/mime-types.ts +765 -0
- package/src/http/request.ts +44 -0
- package/src/http/response.ts +73 -0
- package/src/http/server.ts +67 -0
- package/src/http/types.ts +31 -0
- package/src/index.ts +4 -0
- package/src/logging.ts +57 -0
- package/src/router/cors.ts +54 -0
- package/src/router/index.ts +38 -0
- package/src/router/match.ts +194 -0
- package/src/router/parameters.ts +172 -0
- package/src/router/router.ts +233 -0
- package/src/swagger/index.ts +184 -0
- package/src/swagger/openapi-spec.ts +312 -0
- package/src/validators/array.ts +33 -0
- package/src/validators/boolean.ts +23 -0
- package/src/validators/common.ts +46 -0
- package/src/validators/index.ts +50 -0
- package/src/validators/number.ts +36 -0
- package/src/validators/object.ts +41 -0
- package/src/validators/string.ts +38 -0
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type http from 'node:http'
|
|
2
|
+
import { type Logger } from '@anjianshi/utils'
|
|
3
|
+
import { RequestBody, type BodyOptions } from './body/index.js'
|
|
4
|
+
import { HTTPError, type NodeRequest } from './types.js'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* 对 Node.js 请求内容的二次封装
|
|
8
|
+
* - 提供经过整理的请求信息
|
|
9
|
+
* - 自带 body 解析(实现了 “body 大小限制” 和 “JSON、form-data 等格式的内容解析”)
|
|
10
|
+
*
|
|
11
|
+
* Secondary encapsulation of the Node.js Request object
|
|
12
|
+
* - Organized request information
|
|
13
|
+
* - Comes with body parsing (implements 'body size limit' and 'content parsing for formats such as JSON and form-data').
|
|
14
|
+
*/
|
|
15
|
+
export class Request {
|
|
16
|
+
readonly method: string
|
|
17
|
+
readonly host: string
|
|
18
|
+
readonly path: string
|
|
19
|
+
readonly query: URLSearchParams
|
|
20
|
+
readonly headers: http.IncomingHttpHeaders
|
|
21
|
+
readonly body: RequestBody
|
|
22
|
+
|
|
23
|
+
constructor(
|
|
24
|
+
readonly nodeRequest: NodeRequest,
|
|
25
|
+
protected readonly logger: Logger,
|
|
26
|
+
bodyOptions: BodyOptions,
|
|
27
|
+
) {
|
|
28
|
+
if (nodeRequest.method === undefined) throw new HTTPError(405)
|
|
29
|
+
this.method = nodeRequest.method
|
|
30
|
+
|
|
31
|
+
try {
|
|
32
|
+
const url = new URL(nodeRequest.url ?? '', `http://${nodeRequest.headers.host ?? ''}`)
|
|
33
|
+
this.host = url.host
|
|
34
|
+
this.path = url.pathname
|
|
35
|
+
this.query = url.searchParams
|
|
36
|
+
} catch (e) {
|
|
37
|
+
this.logger.warn('parse url failed', e)
|
|
38
|
+
throw new HTTPError(400, 'url invalid')
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
this.headers = nodeRequest.headers
|
|
42
|
+
this.body = new RequestBody(nodeRequest, bodyOptions)
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { type Logger } from '@anjianshi/utils'
|
|
2
|
+
import { path2MIMEType } from './mime-types.js'
|
|
3
|
+
import { HTTPError, type NodeResponse } from './types.js'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* 封装响应内容输出函数
|
|
7
|
+
* Encapsulate functions for outputting response content.
|
|
8
|
+
*/
|
|
9
|
+
export class ResponseUtils {
|
|
10
|
+
constructor(
|
|
11
|
+
readonly nodeResponse: NodeResponse,
|
|
12
|
+
readonly logger: Logger,
|
|
13
|
+
) {}
|
|
14
|
+
|
|
15
|
+
header(name: string, value: string) {
|
|
16
|
+
this.nodeResponse.setHeader(name, value)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
headers(...items: [string, string][]) {
|
|
20
|
+
items.forEach(([name, value]) => this.header(name, value))
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Output CORS Headers
|
|
25
|
+
*/
|
|
26
|
+
cors(allowOrigin = '*', allowHeaders = '*') {
|
|
27
|
+
this.headers(
|
|
28
|
+
['Access-Control-Allow-Origin', allowOrigin],
|
|
29
|
+
['Access-Control-Allow-Headers', allowHeaders],
|
|
30
|
+
)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
text(content: string | Buffer) {
|
|
34
|
+
this.nodeResponse.end(content)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Output JSON
|
|
39
|
+
*/
|
|
40
|
+
json(data: unknown) {
|
|
41
|
+
try {
|
|
42
|
+
const json = JSON.stringify(data)
|
|
43
|
+
this.header('Content-Type', 'application/json; charset=UTF-8')
|
|
44
|
+
this.nodeResponse.end(json)
|
|
45
|
+
} catch (e) {
|
|
46
|
+
throw new HTTPError(500, 'Invalid JSON Response')
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* 原样输出文件内容,并带上文件类型对应的 MIME Type
|
|
52
|
+
* Output the file content and include the MIME Type corresponding to the file type.
|
|
53
|
+
*/
|
|
54
|
+
file(content: Buffer | string, path: string) {
|
|
55
|
+
const mimeType = path2MIMEType(path)
|
|
56
|
+
if (mimeType !== null) this.header('Content-Type', mimeType)
|
|
57
|
+
this.nodeResponse.end(content)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Output HTTP Error
|
|
62
|
+
*/
|
|
63
|
+
error(error: unknown) {
|
|
64
|
+
if (error instanceof HTTPError) {
|
|
65
|
+
this.nodeResponse.statusCode = error.status // eslint-disable-line require-atomic-updates
|
|
66
|
+
this.nodeResponse.end(error.message)
|
|
67
|
+
} else {
|
|
68
|
+
this.logger.error(error)
|
|
69
|
+
this.nodeResponse.statusCode = 500 // eslint-disable-line require-atomic-updates
|
|
70
|
+
this.nodeResponse.end(new HTTPError(500).message)
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import http from 'node:http'
|
|
2
|
+
import { type Logger, LogLevel } from '@anjianshi/utils'
|
|
3
|
+
import { type BodyOptions } from './body/index.js'
|
|
4
|
+
import { Request } from './request.js'
|
|
5
|
+
import { ResponseUtils } from './response.js'
|
|
6
|
+
import { type NodeRequest, type NodeResponse } from './types.js'
|
|
7
|
+
|
|
8
|
+
export type RequestHandler = (request: Request, response: ResponseUtils) => unknown
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* 二次封装 Node.js HTTP Server,实现了更完善的 request 和 response 处理
|
|
12
|
+
* Secondary encapsulation of the Node.js HTTP Server, with more complete request and response handling.
|
|
13
|
+
*
|
|
14
|
+
* 功能点:
|
|
15
|
+
* - 经过整理的 request 信息,且自带 body 解析
|
|
16
|
+
* - 快速输出指定格式的响应内容
|
|
17
|
+
* - 自带异常处理
|
|
18
|
+
* - 任意处抛出 HTTPError,可以指定 HTTP Status 结束请求
|
|
19
|
+
* - 抛出其他 Error,则以 Status 500 结束请求
|
|
20
|
+
*
|
|
21
|
+
* Features:
|
|
22
|
+
* - Organized request information, and built-in body parsing
|
|
23
|
+
* - Quickly output response content in a specified format
|
|
24
|
+
* - Built-in exception handling
|
|
25
|
+
* - Throw HTTPError anywhere, and end the request with the specified HTTP Status
|
|
26
|
+
* - If another Error is thrown, end the request with Status 500
|
|
27
|
+
*
|
|
28
|
+
* Usage:
|
|
29
|
+
* startHTTPServer(handler, options)
|
|
30
|
+
*/
|
|
31
|
+
export function startHTTPServer(
|
|
32
|
+
options: BodyOptions & {
|
|
33
|
+
handler?: RequestHandler
|
|
34
|
+
logger: Logger
|
|
35
|
+
port: number
|
|
36
|
+
},
|
|
37
|
+
) {
|
|
38
|
+
const { handler = placeholderHandler, logger, port, ...bodyOptions } = options
|
|
39
|
+
|
|
40
|
+
async function handleRequest(nodeRequest: NodeRequest, nodeResponse: NodeResponse) {
|
|
41
|
+
const logStart = Date.now()
|
|
42
|
+
const logMethod = nodeRequest.method ?? 'UNKNOWN'
|
|
43
|
+
const logUrl = nodeRequest.url ?? ''
|
|
44
|
+
logger.info('<--', logMethod, logUrl)
|
|
45
|
+
|
|
46
|
+
const response = new ResponseUtils(nodeResponse, logger)
|
|
47
|
+
try {
|
|
48
|
+
const request = new Request(nodeRequest, logger, bodyOptions)
|
|
49
|
+
await handler(request, response)
|
|
50
|
+
} catch (error) {
|
|
51
|
+
response.error(error)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const logUsage = Date.now() - logStart
|
|
55
|
+
const logStatus = nodeResponse.statusCode
|
|
56
|
+
const logLevel = logStatus >= 400 ? LogLevel.Warning : LogLevel.Info
|
|
57
|
+
logger.log(logLevel, ['-->', logMethod, logUrl, logStatus, `${logUsage}ms`])
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const server = http.createServer(handleRequest)
|
|
61
|
+
logger.info('Listening', port)
|
|
62
|
+
server.listen(port)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function placeholderHandler(request: Request, response: ResponseUtils) {
|
|
66
|
+
response.json({ message: 'Hello World, from starlight.' })
|
|
67
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 重新定义 HTTP 请求相关类型
|
|
3
|
+
* Redefine HTTP request-related types.
|
|
4
|
+
*/
|
|
5
|
+
import type http from 'node:http'
|
|
6
|
+
import { HTTPStatusMap } from './http-status.js'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Node.js 原生的请求对象
|
|
10
|
+
* Node.js native request object
|
|
11
|
+
*/
|
|
12
|
+
export type NodeRequest = http.IncomingMessage
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Node.js 原生的响应对象
|
|
16
|
+
* Node.js native response object
|
|
17
|
+
*/
|
|
18
|
+
export type NodeResponse = http.ServerResponse & { req: http.IncomingMessage }
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* 带有 HTTP Status 的错误对象
|
|
22
|
+
* Error object with HTTP Status
|
|
23
|
+
*/
|
|
24
|
+
export class HTTPError extends Error {
|
|
25
|
+
readonly status: number
|
|
26
|
+
|
|
27
|
+
constructor(status: number, message?: string) {
|
|
28
|
+
super(message ?? HTTPStatusMap.get(status) ?? status.toString())
|
|
29
|
+
this.status = status
|
|
30
|
+
}
|
|
31
|
+
}
|
package/src/index.ts
ADDED
package/src/logging.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 实现日志记录
|
|
3
|
+
*/
|
|
4
|
+
import { truthy, Logger, LogLevel, adaptDebugLib } from '@anjianshi/utils'
|
|
5
|
+
import {
|
|
6
|
+
ConsoleHandler,
|
|
7
|
+
FileHandler,
|
|
8
|
+
type FileHandlerOptions,
|
|
9
|
+
} from '@anjianshi/utils/env-node/logging.js'
|
|
10
|
+
import debug from 'debug'
|
|
11
|
+
|
|
12
|
+
export type { FileHandlerOptions }
|
|
13
|
+
|
|
14
|
+
export interface LoggingOptions {
|
|
15
|
+
/**
|
|
16
|
+
* 指定日志等级(debug info warn err)
|
|
17
|
+
* Specify log level.
|
|
18
|
+
*/
|
|
19
|
+
level?: string
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* 文件日志参数
|
|
23
|
+
* File log options
|
|
24
|
+
*/
|
|
25
|
+
file?: Partial<FileHandlerOptions>
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* 是否把 debug 库的日志引入进来;可通过字符串指定匹配规则
|
|
29
|
+
* Whether to import logs from 'debug' library; can specify matching rules through strings
|
|
30
|
+
*/
|
|
31
|
+
debugLib?: boolean | string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const levelMap: Record<string, LogLevel> = {
|
|
35
|
+
debug: LogLevel.Debug,
|
|
36
|
+
info: LogLevel.Info,
|
|
37
|
+
warn: LogLevel.Warning,
|
|
38
|
+
warning: LogLevel.Warning,
|
|
39
|
+
err: LogLevel.Error,
|
|
40
|
+
error: LogLevel.Error,
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function getLogger(options: LoggingOptions = {}) {
|
|
44
|
+
const logger = new Logger()
|
|
45
|
+
logger.addHandler(new ConsoleHandler())
|
|
46
|
+
if (options.level !== undefined) {
|
|
47
|
+
const level = levelMap[options.level.toLowerCase()]
|
|
48
|
+
if (level !== undefined) logger.setLevel(level)
|
|
49
|
+
}
|
|
50
|
+
if (options.file) {
|
|
51
|
+
logger.addHandler(new FileHandler(options.file))
|
|
52
|
+
}
|
|
53
|
+
if (truthy(options.debugLib)) {
|
|
54
|
+
void adaptDebugLib(debug, options.debugLib === true ? '*' : options.debugLib, logger)
|
|
55
|
+
}
|
|
56
|
+
return logger
|
|
57
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { type Request, type ResponseUtils } from '@/http/index.js'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* CORS 配置
|
|
5
|
+
*
|
|
6
|
+
* - false: 不处理跨域
|
|
7
|
+
* - true: 支持且不设限(等同于 { allowOrigin: '*', allowHeaders: '*', exposeHeaders: '*' })
|
|
8
|
+
* - { allowOrigin, allowHeaders, exposeHeaders }: 手动配置
|
|
9
|
+
*
|
|
10
|
+
* allowHeaders 仅对 Preflight 请求有效
|
|
11
|
+
* exposeHeaders 仅对非 Preflight 请求有效
|
|
12
|
+
*/
|
|
13
|
+
export type CORSOptions =
|
|
14
|
+
| boolean
|
|
15
|
+
| { allowOrigin?: string; allowHeaders?: string; exposeHeaders?: string }
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* 若当前请求是 CORS Preflight 请求,返回实际请求的 method,否则返回 null
|
|
19
|
+
*/
|
|
20
|
+
export function getMethodFromCORSPreflight(request: Request) {
|
|
21
|
+
if (
|
|
22
|
+
request.method === 'OPTIONS' &&
|
|
23
|
+
request.headers['Access-Control-Request-Methods'] !== undefined
|
|
24
|
+
) {
|
|
25
|
+
const rawMethod = request.headers['Access-Control-Request-Methods']
|
|
26
|
+
return Array.isArray(rawMethod) ? rawMethod[0]! : rawMethod
|
|
27
|
+
}
|
|
28
|
+
return null
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* 输出 CORS 相关 headers
|
|
33
|
+
*/
|
|
34
|
+
export function outputCORSHeaders(
|
|
35
|
+
response: ResponseUtils,
|
|
36
|
+
cors: CORSOptions,
|
|
37
|
+
isPreflight: boolean,
|
|
38
|
+
) {
|
|
39
|
+
if (cors === true) {
|
|
40
|
+
response.headers(['Access-Control-Allow-Origin', '*'])
|
|
41
|
+
if (isPreflight) response.headers(['Access-Control-Allow-Headers', '*'])
|
|
42
|
+
else response.headers(['Access-Control-Expose-Headers', '*'])
|
|
43
|
+
} else if (cors !== false) {
|
|
44
|
+
if (cors.allowOrigin !== undefined)
|
|
45
|
+
response.header('Access-Control-Allow-Origin', cors.allowOrigin)
|
|
46
|
+
if (isPreflight && cors.allowHeaders !== undefined)
|
|
47
|
+
response.header('Access-Control-Allow-Headers', cors.allowHeaders)
|
|
48
|
+
if (!isPreflight && cors.exposeHeaders !== undefined)
|
|
49
|
+
response.header('Access-Control-Expose-Headers', cors.exposeHeaders)
|
|
50
|
+
} else {
|
|
51
|
+
// 若指定为“不处理跨域”,则不输出 headers,浏览器会默认服务端不允许跨域请求
|
|
52
|
+
// (后续 route handler 仍有机会自行设置 CORS headers)
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 实现路由注册
|
|
3
|
+
* Implement route registration
|
|
4
|
+
*
|
|
5
|
+
* Usage:
|
|
6
|
+
* const router = new DefaultRouter()
|
|
7
|
+
* router.register('GET', '/hello/:name', async ({ request, response }: { request: Request, response: ResponseUtils }, params: { name: string }) => {
|
|
8
|
+
* response.json({ hello: params.name })
|
|
9
|
+
* })
|
|
10
|
+
* startHTTPServer({
|
|
11
|
+
* handler: router.handle
|
|
12
|
+
* ...
|
|
13
|
+
* })
|
|
14
|
+
*
|
|
15
|
+
* Usage With Custom Context:
|
|
16
|
+
* interface MyContext extends BaseContext {
|
|
17
|
+
* foo: () => void
|
|
18
|
+
* }
|
|
19
|
+
* class MyRouter extends BaseRouter<MyContext> {
|
|
20
|
+
* executeWithContext(baseContext: BaseContext, route: Route<MyContext>, params: PathParamaters) {
|
|
21
|
+
* function foo() { console.log('bar') }
|
|
22
|
+
* const context = { ...baseContext, foo }
|
|
23
|
+
* route.handle(context)
|
|
24
|
+
* }
|
|
25
|
+
* }
|
|
26
|
+
* const router = new MyRouter()
|
|
27
|
+
* router.register('GET', '/foobar', async ({ request, response, foo }) => {
|
|
28
|
+
* foo()
|
|
29
|
+
* response.json({})
|
|
30
|
+
* })
|
|
31
|
+
* startHTTPServer({
|
|
32
|
+
* handler: router.handle
|
|
33
|
+
* ...
|
|
34
|
+
* })
|
|
35
|
+
*/
|
|
36
|
+
export * from './router.js'
|
|
37
|
+
export { type PathParameters } from './match.js'
|
|
38
|
+
export type { BasicDataType, Parameter, ParameterDataType } from './parameters.js'
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 匹配路由路径
|
|
3
|
+
*
|
|
4
|
+
* 支持的格式:
|
|
5
|
+
* 1. /abc/ abc/ /abc => 这几种形式等价
|
|
6
|
+
* 2. /abc/:foo/bar => 命名参数(foo)
|
|
7
|
+
* 3. /abc/:foo?/bar => 可选命名参数(foo)
|
|
8
|
+
* 4. /abc/* => 后续路径(任意长度,* 只能出现在路由最后面,匹配结果不含开头的“/”)
|
|
9
|
+
*
|
|
10
|
+
* 路由不区分大小写
|
|
11
|
+
*/
|
|
12
|
+
import escapeRegExp from 'lodash/escapeRegExp.js'
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* 标准化路径
|
|
16
|
+
* 标准化后,相同的两个路径一定也是相同的字符串(例如 /Abc/Def 和 abc/def/ 都会变成 abc/def)。
|
|
17
|
+
*
|
|
18
|
+
* - 移除首尾和重复的 '/',完成后有 path 有这几种可能的格式: ''、'abc'、'abc/def'
|
|
19
|
+
* - 统一改为小写
|
|
20
|
+
*/
|
|
21
|
+
export function normalizePath(path: string) {
|
|
22
|
+
if (path.startsWith('/')) path = path.slice(1)
|
|
23
|
+
if (path.endsWith('/')) path = path.slice(0, -1)
|
|
24
|
+
path = path.replace(/\/+/g, '/')
|
|
25
|
+
return path.toLowerCase()
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* 解析路由路径定义
|
|
30
|
+
* '/abc/:foo?/*' => [{ type: 'text', text: 'abc' }, { type: 'named', name: 'foo', optional: true }, { type: 'rest' }]
|
|
31
|
+
*/
|
|
32
|
+
function parseRoutePath(routePath: string): ParsedPath {
|
|
33
|
+
const normalizedPath = normalizePath(routePath)
|
|
34
|
+
|
|
35
|
+
const nodes: PathNode[] = []
|
|
36
|
+
const pathParts = normalizedPath.split('/')
|
|
37
|
+
for (const [i, part] of pathParts.entries()) {
|
|
38
|
+
if (part.startsWith(':') && part !== ':' && part !== ':?') {
|
|
39
|
+
const optional = part.endsWith('?')
|
|
40
|
+
const name = optional ? part.slice(1, -1) : part.slice(1)
|
|
41
|
+
if (name === '*') throw new Error('route path parameter name cannot be "*"')
|
|
42
|
+
nodes.push({ type: PathNodeType.Named, name, optional })
|
|
43
|
+
} else if (part === '*' && i === pathParts.length - 1) {
|
|
44
|
+
nodes.push({ type: PathNodeType.Rest })
|
|
45
|
+
} else {
|
|
46
|
+
nodes.push({ type: PathNodeType.Text, text: part })
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return nodes
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
enum PathNodeType {
|
|
54
|
+
/** 纯文本 */
|
|
55
|
+
Text = 'text',
|
|
56
|
+
/** 命名参数 */
|
|
57
|
+
Named = 'named',
|
|
58
|
+
/** 后续路径 */
|
|
59
|
+
Rest = 'rest',
|
|
60
|
+
}
|
|
61
|
+
type PathNode =
|
|
62
|
+
| { type: PathNodeType.Text; text: string }
|
|
63
|
+
| { type: PathNodeType.Named; name: string; optional: boolean }
|
|
64
|
+
| { type: PathNodeType.Rest }
|
|
65
|
+
type ParsedPath = PathNode[]
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* 把路由定义转换成正则表达式
|
|
69
|
+
*
|
|
70
|
+
* Nodes | Path | RegExp
|
|
71
|
+
* -------------------------- | ------------- | ----------------------------
|
|
72
|
+
* [] | | ^$
|
|
73
|
+
* ['abc'] | abc | ^abc$
|
|
74
|
+
* ['abc', named:foo, 'xyz'] | abc/:foo/xyz | ^abc/([^/]+?)/xyz$
|
|
75
|
+
* ['abc', named:foo?, 'xyz'] | abc/:foo?/xyz | ^abc(?:/([^/]+?))?/xyz$
|
|
76
|
+
* ['abc', named:foo?, rest] | abc/:foo?/* | ^abc(?:/([^/]+?))?(?:/(.+))?$
|
|
77
|
+
*
|
|
78
|
+
* ^(?:/(.+))?$
|
|
79
|
+
*/
|
|
80
|
+
function parsedPathToRegExp(parsedPath: ParsedPath) {
|
|
81
|
+
const regexpParts: string[] = []
|
|
82
|
+
for (const node of parsedPath) {
|
|
83
|
+
const prefix = node === parsedPath[0] ? '' : '/'
|
|
84
|
+
if (node.type === PathNodeType.Text) {
|
|
85
|
+
regexpParts.push(prefix + escapeRegExp(node.text))
|
|
86
|
+
} else if (node.type === PathNodeType.Named) {
|
|
87
|
+
regexpParts.push(node.optional ? `(?:${prefix}([^/]+?))?` : `${prefix}([^/]+?)`)
|
|
88
|
+
} else {
|
|
89
|
+
regexpParts.push(`(?:${prefix}(.+))?`)
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return new RegExp('^' + regexpParts.join('') + '$', 'i')
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* 比较两个路径的优先级(两个路由都能匹配路径时,优先级更高的生效)
|
|
97
|
+
*
|
|
98
|
+
* pathA 优先:-1
|
|
99
|
+
* pathB 优先:1
|
|
100
|
+
*
|
|
101
|
+
* 规则:
|
|
102
|
+
* 1. 挨个 node 比对
|
|
103
|
+
* 2. 先匹配各节点的类型,text > named > named optional > rest
|
|
104
|
+
* 3. 节点类型都一致,节点数量多的优先级更高
|
|
105
|
+
* 4. 节点类型、数量都一样,后出现的覆盖先出现的
|
|
106
|
+
*/
|
|
107
|
+
function pathSorter(pathA: ParsedPath, pathB: ParsedPath) {
|
|
108
|
+
const typePriorities = [PathNodeType.Text, PathNodeType.Named, PathNodeType.Rest]
|
|
109
|
+
|
|
110
|
+
const length = Math.max(pathA.length, pathB.length)
|
|
111
|
+
for (let i = 0; i < length; i++) {
|
|
112
|
+
const a = pathA[i]
|
|
113
|
+
const b = pathB[i]
|
|
114
|
+
|
|
115
|
+
// 若 nodes 长度不一样,长的优先
|
|
116
|
+
if (a === undefined) return -1
|
|
117
|
+
else if (b === undefined) return 1
|
|
118
|
+
|
|
119
|
+
// node 类型不一样,按类型排序
|
|
120
|
+
const aTypePriority = typePriorities.indexOf(a.type)
|
|
121
|
+
const bTypePriority = typePriorities.indexOf(b.type)
|
|
122
|
+
if (aTypePriority !== bTypePriority) return aTypePriority - bTypePriority
|
|
123
|
+
|
|
124
|
+
// 若都是 named 类型但 optional 不同
|
|
125
|
+
if (
|
|
126
|
+
a.type === PathNodeType.Named &&
|
|
127
|
+
b.type === PathNodeType.Named &&
|
|
128
|
+
a.optional !== b.optional
|
|
129
|
+
) {
|
|
130
|
+
return a.optional ? 1 : -1
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// 长度、类型都一样,后出现的优先
|
|
135
|
+
return 1
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* 返回匹配的路由及匹配上的路径参数
|
|
140
|
+
*/
|
|
141
|
+
export function matchRoutes<R extends BasicRoute>(requestPath: string, routes: R[]) {
|
|
142
|
+
if (!parsedCache.has(routes)) {
|
|
143
|
+
// 解析路由路径并按优先级排序
|
|
144
|
+
const parsedRoutes = routes
|
|
145
|
+
.map(route => {
|
|
146
|
+
const parsedPath = parseRoutePath(route.path)
|
|
147
|
+
const regexp = parsedPathToRegExp(parsedPath)
|
|
148
|
+
return { parsedPath, regexp, route }
|
|
149
|
+
})
|
|
150
|
+
.sort((a, b) => pathSorter(a.parsedPath, b.parsedPath))
|
|
151
|
+
parsedCache.set(routes, parsedRoutes)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
requestPath = normalizePath(requestPath)
|
|
155
|
+
const parsedRoutes = parsedCache.get(routes)!
|
|
156
|
+
return parsedRoutes
|
|
157
|
+
.map(({ parsedPath, regexp, route }) => {
|
|
158
|
+
const match = regexp.exec(requestPath)
|
|
159
|
+
if (!match) return null
|
|
160
|
+
|
|
161
|
+
const pathParameters = matchPathParameters(parsedPath, [...match].slice(1))
|
|
162
|
+
return { route, pathParameters } as RouteMatch<R>
|
|
163
|
+
})
|
|
164
|
+
.filter((matched): matched is Exclude<typeof matched, null> => matched !== null)
|
|
165
|
+
}
|
|
166
|
+
const parsedCache = new WeakMap<
|
|
167
|
+
BasicRoute[], // eslint-disable-line @typescript-eslint/no-explicit-any
|
|
168
|
+
{ parsedPath: ParsedPath; regexp: RegExp; route: BasicRoute }[] // eslint-disable-line @typescript-eslint/no-explicit-any
|
|
169
|
+
>()
|
|
170
|
+
|
|
171
|
+
export type BasicRoute = { path: string }
|
|
172
|
+
|
|
173
|
+
export interface RouteMatch<R> {
|
|
174
|
+
route: R
|
|
175
|
+
pathParameters: PathParameters
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* 把匹配到的路径参数映射成键值对
|
|
180
|
+
* 没匹配到的内容(optional named node / rest node)值为 undefined
|
|
181
|
+
*/
|
|
182
|
+
function matchPathParameters(parsedPath: ParsedPath, parameterNames: (string | undefined)[]) {
|
|
183
|
+
const parameters: PathParameters = {}
|
|
184
|
+
for (const node of parsedPath) {
|
|
185
|
+
if (node.type === PathNodeType.Text) continue
|
|
186
|
+
else if (node.type === PathNodeType.Named) parameters[node.name] = parameterNames.shift()!
|
|
187
|
+
else parameters['*'] = parameterNames.shift()!
|
|
188
|
+
}
|
|
189
|
+
return parameters
|
|
190
|
+
}
|
|
191
|
+
export type PathParameters = {
|
|
192
|
+
[name: string]: string | undefined
|
|
193
|
+
'*'?: string
|
|
194
|
+
}
|