@tyno/tyno 2.1.3

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.
Files changed (45) hide show
  1. package/README-zh.md +572 -0
  2. package/README.md +555 -0
  3. package/example/app.ts +173 -0
  4. package/example/public/index.html +1 -0
  5. package/example/public/test.json +1 -0
  6. package/package.json +63 -0
  7. package/scripts/build.mjs +97 -0
  8. package/scripts/rename-cjs.mjs +23 -0
  9. package/src/application.ts +304 -0
  10. package/src/cache/drivers/file.ts +79 -0
  11. package/src/cache/drivers/memory.ts +72 -0
  12. package/src/cache/drivers/redis.ts +72 -0
  13. package/src/cache/index.ts +5 -0
  14. package/src/cache/manager.ts +106 -0
  15. package/src/cache/types.ts +24 -0
  16. package/src/cache-facade.ts +64 -0
  17. package/src/compose.ts +139 -0
  18. package/src/context.ts +5 -0
  19. package/src/errors/app-error.ts +37 -0
  20. package/src/errors/http-error.ts +34 -0
  21. package/src/errors/index.ts +4 -0
  22. package/src/errors/runtime-error.ts +19 -0
  23. package/src/facade/index.ts +3 -0
  24. package/src/index.ts +29 -0
  25. package/src/middlewares/compress.ts +101 -0
  26. package/src/middlewares/cors.ts +57 -0
  27. package/src/middlewares/error-page.ts +89 -0
  28. package/src/middlewares/index.ts +9 -0
  29. package/src/middlewares/request-id.ts +47 -0
  30. package/src/middlewares/static.ts +138 -0
  31. package/src/mime.ts +38 -0
  32. package/src/request/body-parser.ts +61 -0
  33. package/src/request/index.ts +273 -0
  34. package/src/request/multipart-parser.ts +360 -0
  35. package/src/request-global.ts +31 -0
  36. package/src/response/sse.ts +54 -0
  37. package/src/response.ts +177 -0
  38. package/src/router/index.ts +290 -0
  39. package/src/router/node.ts +15 -0
  40. package/src/router/parse-path.ts +18 -0
  41. package/src/types.ts +109 -0
  42. package/test/functional.test.ts +614 -0
  43. package/tsconfig.build.json +13 -0
  44. package/tsconfig.cjs.json +9 -0
  45. package/tsconfig.json +21 -0
package/example/app.ts ADDED
@@ -0,0 +1,173 @@
1
+ import { Tyno, Router, Response, NotFound, RuntimeError, cors, requestId, compress, cache, response } from '../src/index.ts';
2
+ import type { TynoRequest, NextFunction } from '../src/index.ts';
3
+
4
+ // debug + body + cache 配置在构造函数中统一设置
5
+ const app = new Tyno({
6
+ debug: true,
7
+ body: { limit: '2mb', uploadLimit: '20mb', keepExtensions: true, uploadBufferLimit: '512kb' },
8
+ cache: { driver: 'memory', prefix: 'app:', ttl: 60 }
9
+ });
10
+
11
+ // ==================== 事件系统 ====================
12
+
13
+ // 请求到达
14
+ app.on('request', (req: TynoRequest) => {
15
+ console.log(`[event] request: ${req.method} ${req.path}`);
16
+ });
17
+
18
+ // 响应发送前
19
+ app.on('response', (req: TynoRequest, res: Response) => {
20
+ console.log(`[event] response: ${res.status} ${req.path}`);
21
+ });
22
+
23
+ // 异常发生时(有监听器才触发,否则静默处理)
24
+ app.on('error', (err: unknown, req: TynoRequest) => {
25
+ console.error(`[event] error: ${(err as { message?: string }).message} at ${req.path}`);
26
+ });
27
+
28
+ // 服务启动成功
29
+ app.on('ready', () => {
30
+ console.log('[event] ready: server is listening');
31
+ });
32
+
33
+ // 初始化器:在 listen() 启动服务前自动执行
34
+ app.initialize(async (app) => {
35
+ console.log('[init] Initializing application...');
36
+ console.log('[init] Application initialized successfully.');
37
+ });
38
+
39
+ app.initialize(async () => {
40
+ console.log('[init] Cache pre-warming...');
41
+ });
42
+
43
+ // 请求 ID 中间件(自动注入 X-Request-Id)
44
+ app.use(requestId());
45
+
46
+ // 响应压缩中间件
47
+ app.use(compress());
48
+
49
+ // CORS
50
+ app.use(cors({ origin: '*' }));
51
+
52
+ // 全局日志中间件
53
+ app.use(async (req: TynoRequest, next: NextFunction) => {
54
+ console.log(`--> ${req.method} ${req.path} [${req.requestId}]`);
55
+ const res = await next();
56
+ if (res instanceof Response) {
57
+ console.log(`<-- ${req.method} ${req.path} ${res.status} [${req.requestId}]`);
58
+ }
59
+ return res;
60
+ });
61
+
62
+ // 响应门面:预设 header / cookie,自动合并到最终响应
63
+ app.use(async (req: TynoRequest, next: NextFunction) => {
64
+ response.header('X-Powered-By', 'tyno');
65
+ return next();
66
+ });
67
+
68
+ // 抛出 HttpError / RuntimeError
69
+ app.use(async (req: TynoRequest, next: NextFunction) => {
70
+ if (req.path === '/error') throw new NotFound('资源不存在');
71
+ if (req.path === '/db-fail') {
72
+ throw new RuntimeError('数据库连接失败', { code: 'DB_FAIL', cause: new Error('ECONNREFUSED') });
73
+ }
74
+ return next();
75
+ });
76
+
77
+ // 用户路由(使用 Router 替代子应用挂载)
78
+ const userRouter = new Router({ prefix: '/users' });
79
+ userRouter.use(async (req: TynoRequest, next: NextFunction) => {
80
+ req.user = { id: 1, name: 'Alice' };
81
+ return next();
82
+ });
83
+ userRouter.get('/:id(\\d+)', (req: TynoRequest) => ({ id: req.params.id, user: req.user }));
84
+ userRouter.post('/', async (req: TynoRequest) => ({ created: await req.body() }));
85
+ userRouter.get('/*', (req: TynoRequest) => ({ wild: req.params['*'] }));
86
+ app.use(userRouter.routes());
87
+
88
+ // API 路由(使用 Router 替代子应用挂载)
89
+ const apiRouter = new Router({ prefix: '/api' });
90
+ apiRouter.use(async (req: TynoRequest, next: NextFunction) => {
91
+ return next();
92
+ });
93
+ apiRouter.get('/status', () => Response.json({ status: 'ok', from: 'api-router' }));
94
+ apiRouter.get('/data', () => Response.json({ items: [1, 2, 3] }));
95
+ app.use(apiRouter.routes());
96
+
97
+ // 文件上传路由
98
+ app.use(async (req: TynoRequest, next: NextFunction) => {
99
+ if (req.path === '/upload' && req.method === 'POST') {
100
+ const result = await req.files();
101
+ return Response.json({
102
+ fields: result.fields,
103
+ files: result.files.map(f => ({
104
+ name: f.filename, size: f.size, type: f.mimetype, path: f.filepath
105
+ }))
106
+ });
107
+ }
108
+ return next();
109
+ });
110
+
111
+ // SSE 流式响应
112
+ app.use(async (req: TynoRequest, next: NextFunction) => {
113
+ if (req.path === '/stream') {
114
+ async function* gen() {
115
+ yield 'first';
116
+ await new Promise(r => setTimeout(r, 100));
117
+ yield { chunk: 2, time: Date.now() };
118
+ yield 'finished';
119
+ }
120
+ return gen();
121
+ }
122
+ return next();
123
+ });
124
+
125
+ // 静态文件服务(需要创建 public 目录)
126
+ app.use(async (req: TynoRequest, next: NextFunction) => {
127
+ if (req.path === '/hello') return 'Hello World!';
128
+ if (req.path === '/json') return Response.json({ ok: true, framework: 'tyno' });
129
+ if (req.path === '/cookie-test') {
130
+ return Response.json({ cookie: req.getCookie('test_cookie') })
131
+ .setCookie('test_cookie', 'hello world', { httpOnly: true, maxAge: 3600 });
132
+ }
133
+ if (req.path === '/query-demo') {
134
+ return Response.json({
135
+ all: req.query(),
136
+ name: req.query('name'),
137
+ viaGet: req.get('name'),
138
+ missing: req.get('missing', 'default')
139
+ });
140
+ }
141
+ // 缓存 demo:使用全局门面 cache
142
+ if (req.path === '/cache-demo') {
143
+ let hits = await cache.get<number>('cache-demo-hits') ?? 0;
144
+ hits++;
145
+ await cache.set('cache-demo-hits', hits, 300);
146
+ return Response.json({ message: 'Hello from cache!', hits });
147
+ }
148
+ // remember demo:缓存 miss 时调用 factory
149
+ if (req.path === '/cache-remember') {
150
+ const data = await cache.remember('heavy:computation', 60, async () => {
151
+ // 模拟耗时计算
152
+ return { computed: true, ts: Date.now() };
153
+ });
154
+ return Response.json(data);
155
+ }
156
+ return next();
157
+ });
158
+
159
+ app.listen(4567, () => {
160
+ console.log('Server running at http://localhost:4567');
161
+ console.log('Routes:');
162
+ console.log(' GET /hello - Plain text');
163
+ console.log(' GET /json - JSON response');
164
+ console.log(' GET /api/status - API router');
165
+ console.log(' GET /api/data - API router');
166
+ console.log(' GET /users/123 - Route with param');
167
+ console.log(' POST /users - POST route');
168
+ console.log(' GET /error - HttpError demo');
169
+ console.log(' GET /db-fail - RuntimeError demo');
170
+ console.log(' POST /upload - File upload');
171
+ console.log(' GET /stream - SSE stream');
172
+ console.log(' GET /cookie-test - Cookie demo');
173
+ });
@@ -0,0 +1 @@
1
+ <html><body><h1>Hello</h1></body></html>
@@ -0,0 +1 @@
1
+ {"status":"ok"}
package/package.json ADDED
@@ -0,0 +1,63 @@
1
+ {
2
+ "name": "@tyno/tyno",
3
+ "version": "2.1.3",
4
+ "description": "A lightweight Node.js HTTP framework with onion model middleware, trie router, SSE, and TypeScript support.",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "module": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "import": "./dist/index.js",
13
+ "require": "./dist/cjs/index.cjs"
14
+ },
15
+ "./facade": {
16
+ "types": "./dist/facade/index.d.ts",
17
+ "import": "./dist/facade/index.js",
18
+ "require": "./dist/cjs/facade/index.cjs"
19
+ },
20
+ "./middleware": {
21
+ "types": "./dist/middlewares/index.d.ts",
22
+ "import": "./dist/middlewares/index.js",
23
+ "require": "./dist/cjs/middlewares/index.cjs"
24
+ },
25
+ "./router": {
26
+ "types": "./dist/router/index.d.ts",
27
+ "import": "./dist/router/index.js",
28
+ "require": "./dist/cjs/router/index.cjs"
29
+ },
30
+ "./errors": {
31
+ "types": "./dist/errors/index.d.ts",
32
+ "import": "./dist/errors/index.js",
33
+ "require": "./dist/cjs/errors/index.cjs"
34
+ },
35
+ "./cache": {
36
+ "types": "./dist/cache/index.d.ts",
37
+ "import": "./dist/cache/index.js",
38
+ "require": "./dist/cjs/cache/index.cjs"
39
+ },
40
+ "./response": {
41
+ "types": "./dist/response.d.ts",
42
+ "import": "./dist/response.js",
43
+ "require": "./dist/cjs/response.cjs"
44
+ },
45
+ "./types": {
46
+ "types": "./dist/types.d.ts",
47
+ "import": "./dist/types.js",
48
+ "require": "./dist/cjs/types.cjs"
49
+ }
50
+ },
51
+ "scripts": {
52
+ "dev": "tsx watch example/app.ts",
53
+ "build": "node scripts/build.mjs",
54
+ "typecheck": "tsc --noEmit"
55
+ },
56
+ "author": "weberzhang",
57
+ "license": "MIT",
58
+ "devDependencies": {
59
+ "@types/node": "^20.11.0",
60
+ "tsx": "^4.7.0",
61
+ "typescript": "^5.3.0"
62
+ }
63
+ }
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Build script: generates ESM (.js) and CJS (.cjs) + declarations.
3
+ * Uses esbuild per-file, then rewrites .ts → .js/.cjs in import specifiers.
4
+ */
5
+ import { build } from 'esbuild';
6
+ import { execSync } from 'node:child_process';
7
+ import { readdirSync, statSync, existsSync, mkdirSync, rmSync, readFileSync, writeFileSync } from 'node:fs';
8
+ import { join, dirname } from 'node:path';
9
+
10
+ const srcDir = join(process.cwd(), 'src');
11
+ const distDir = join(process.cwd(), 'dist');
12
+ const distCjsDir = join(distDir, 'cjs');
13
+
14
+ function walk(dir, entries) {
15
+ for (const entry of readdirSync(dir)) {
16
+ const full = join(dir, entry);
17
+ if (statSync(full).isDirectory()) {
18
+ walk(full, entries);
19
+ } else if (entry.endsWith('.ts') && !entry.endsWith('.d.ts')) {
20
+ entries.push(full);
21
+ }
22
+ }
23
+ }
24
+
25
+ /** Replace .ts extensions in import/export/require specifiers */
26
+ function fixExtensions(dir, newExt) {
27
+ function fix(filePath) {
28
+ if (statSync(filePath).isDirectory()) {
29
+ for (const e of readdirSync(filePath)) fix(join(filePath, e));
30
+ } else if (filePath.endsWith('.js') || filePath.endsWith('.cjs')) {
31
+ let content = readFileSync(filePath, 'utf-8');
32
+ // import/export from '.../xxx.ts'
33
+ content = content.replace(/((?:from|import)\s+['"])(\.\.?\/[^'"]+)(\.ts)(['"])/g, `$1$2${newExt}$4`);
34
+ // require('.../xxx.ts') — both var x = require(...) and bare require(...)
35
+ content = content.replace(/(require\s*\(\s*['"])(\.\.?\/[^'"]+)(\.ts)(['"])/g, `$1$2${newExt}$4`);
36
+ writeFileSync(filePath, content);
37
+ }
38
+ }
39
+ fix(dir);
40
+ }
41
+
42
+ async function main() {
43
+ if (existsSync(distDir)) rmSync(distDir, { recursive: true });
44
+ mkdirSync(distDir, { recursive: true });
45
+ mkdirSync(distCjsDir, { recursive: true });
46
+
47
+ const entries = [];
48
+ walk(srcDir, entries);
49
+ console.log(`[build] Found ${entries.length} source files`);
50
+
51
+ // 1. ESM build
52
+ console.log('[build] Building ESM...');
53
+ for (const entry of entries) {
54
+ const outfile = entry.replace(srcDir, distDir).replace(/\.ts$/, '.js');
55
+ mkdirSync(dirname(outfile), { recursive: true });
56
+ await build({
57
+ entryPoints: [entry],
58
+ outfile,
59
+ format: 'esm',
60
+ platform: 'node',
61
+ target: 'node20',
62
+ bundle: false,
63
+ sourcemap: true,
64
+ });
65
+ }
66
+ fixExtensions(distDir, '.js');
67
+ console.log('[build] ESM extensions fixed');
68
+
69
+ // 2. CJS build
70
+ console.log('[build] Building CJS...');
71
+ for (const entry of entries) {
72
+ const outfile = entry.replace(srcDir, distCjsDir).replace(/\.ts$/, '.cjs');
73
+ mkdirSync(dirname(outfile), { recursive: true });
74
+ await build({
75
+ entryPoints: [entry],
76
+ outfile,
77
+ format: 'cjs',
78
+ platform: 'node',
79
+ target: 'node20',
80
+ bundle: false,
81
+ sourcemap: true,
82
+ });
83
+ }
84
+ fixExtensions(distCjsDir, '.cjs');
85
+ console.log('[build] CJS extensions fixed');
86
+
87
+ // 3. Declarations via tsc
88
+ console.log('[build] Generating declarations...');
89
+ execSync('npx tsc -p tsconfig.build.json --emitDeclarationOnly', { stdio: 'inherit' });
90
+
91
+ console.log('[build] Done!');
92
+ }
93
+
94
+ main().catch(err => {
95
+ console.error('[build] Failed:', err);
96
+ process.exit(1);
97
+ });
@@ -0,0 +1,23 @@
1
+ /**
2
+ * 将 dist/cjs/ 中的 .js 文件重命名为 .cjs,并修复内部 import 路径。
3
+ * TypeScript 不直接支持输出 .cjs 扩展名,需要后处理。
4
+ */
5
+ import { readdirSync, renameSync, readFileSync, writeFileSync, statSync } from 'node:fs';
6
+ import { join, extname } from 'node:path';
7
+
8
+ const cjsDir = join(process.cwd(), 'dist/cjs');
9
+
10
+ function walk(dir) {
11
+ for (const entry of readdirSync(dir)) {
12
+ const full = join(dir, entry);
13
+ if (statSync(full).isDirectory()) {
14
+ walk(full);
15
+ } else if (entry.endsWith('.js')) {
16
+ const newPath = full.replace(/\.js$/, '.cjs');
17
+ renameSync(full, newPath);
18
+ }
19
+ }
20
+ }
21
+
22
+ walk(cjsDir);
23
+ console.log('[build] CJS files renamed to .cjs');
@@ -0,0 +1,304 @@
1
+ import http from 'node:http';
2
+ import https from 'node:https';
3
+ import { Readable, PassThrough } from 'node:stream';
4
+ import type { IncomingMessage, ServerResponse } from 'node:http';
5
+ import { EventEmitter } from 'node:events';
6
+ import { Request, DATA_KEY } from './request/index.ts';
7
+ import { Response } from './response.ts';
8
+ import { requestStore } from './context.ts';
9
+ import { compose } from './compose.ts';
10
+ import { AppError } from './errors/index.ts';
11
+ import { createErrorPageHandler } from './middlewares/error-page.ts';
12
+ import { CacheManager } from './cache/index.ts';
13
+ import type { CacheConfig } from './cache/types.ts';
14
+ import type { AnyMiddleware, InitializerFn, ListenOptions, RequestHandler, TynoRequest, InjectOptions, InjectResult, ApplicationOptions, BodyParserConfig } from './types.ts';
15
+
16
+ function createRequestProxy(nodeReq: IncomingMessage, bodyConfig: BodyParserConfig): TynoRequest {
17
+ const realReq = new Request(nodeReq, bodyConfig);
18
+ return new Proxy(realReq, {
19
+ get(target: Request, prop: string | symbol, receiver: unknown): unknown {
20
+ if (prop in target) {
21
+ const value = Reflect.get(target, prop, receiver);
22
+ if (typeof value === 'function') return value.bind(target);
23
+ return value;
24
+ }
25
+ return target[DATA_KEY][prop as string];
26
+ },
27
+ set(target: Request, prop: string | symbol, value: unknown): boolean {
28
+ if (prop in target) return false;
29
+ target[DATA_KEY][prop as string] = value;
30
+ return true;
31
+ },
32
+ deleteProperty(target: Request, prop: string | symbol): boolean {
33
+ if (prop in target) return false;
34
+ delete target[DATA_KEY][prop as string];
35
+ return true;
36
+ }
37
+ }) as unknown as TynoRequest;
38
+ }
39
+
40
+ async function sendResponse(nodeRes: ServerResponse, res: Response): Promise<void> {
41
+ const { status, headers, body } = res;
42
+ if (res._cookies && res._cookies.length) {
43
+ (headers as Record<string, unknown>)['Set-Cookie'] = res._cookies;
44
+ }
45
+ // 使用 Response.get() 的大小写不敏感查找
46
+ const contentType = res.get('Content-Type') || '';
47
+ const isSSE = contentType.startsWith('text/event-stream');
48
+
49
+ if (isSSE && body && typeof (body as AsyncIterable<unknown>)[Symbol.asyncIterator] === 'function') {
50
+ nodeRes.writeHead(status, headers);
51
+ try {
52
+ for await (const chunk of body as AsyncIterable<unknown>) {
53
+ if (nodeRes.writableEnded) break;
54
+ const data = typeof chunk === 'string' ? chunk : JSON.stringify(chunk);
55
+ nodeRes.write(`data: ${data.replace(/\n/g, '\ndata: ')}\n\n`);
56
+ }
57
+ } catch (err: unknown) {
58
+ if (!nodeRes.writableEnded) nodeRes.write(`event: error\ndata: ${(err as Error).message}\n\n`);
59
+ } finally {
60
+ if (!nodeRes.writableEnded) nodeRes.end();
61
+ }
62
+ return;
63
+ }
64
+
65
+ nodeRes.writeHead(status, headers);
66
+ if (body == null) {
67
+ nodeRes.end();
68
+ } else if (typeof (body as NodeJS.ReadableStream).pipe === 'function') {
69
+ (body as NodeJS.ReadableStream).pipe(nodeRes);
70
+ } else {
71
+ nodeRes.end(body as string | Buffer);
72
+ }
73
+ }
74
+
75
+ function buildErrorResponse(err: unknown, debug: boolean): Response {
76
+ if (err instanceof AppError) {
77
+ const status = err.status;
78
+ const message = err.expose ? err.message : 'Internal Server Error';
79
+ const body: Record<string, unknown> = { error: message, status };
80
+ if (err.code != null) body.code = err.code;
81
+ if (debug) { body.stack = err.stack; body.name = err.name; if (err.cause) body.cause = (err.cause as Error).message; }
82
+ return Response.json(body, status);
83
+ }
84
+ const e = err as Error;
85
+ const body: Record<string, unknown> = { error: 'Internal Server Error', status: 500 };
86
+ if (debug) { body.stack = e.stack; body.name = e.name; }
87
+ return Response.json(body, 500);
88
+ }
89
+
90
+ /** 将 response 门面中预设的 header/cookie 合并到响应 */
91
+ function mergeResponseFacade(finalRes: Response): void {
92
+ const pending = Response._getPending();
93
+ if (!pending) return;
94
+ for (const [k, v] of Object.entries(pending.headers)) {
95
+ if (!finalRes.get(k)) finalRes.set(k, v);
96
+ }
97
+ if (pending.cookies.length) {
98
+ finalRes._cookies = [...pending.cookies, ...finalRes._cookies];
99
+ }
100
+ }
101
+
102
+ function logError(err: unknown): void {
103
+ const e = err as Record<string, unknown>;
104
+ const status = (e.status as number) || 500;
105
+ if (status >= 500) {
106
+ const cause = e.cause ? `\n caused by: ${(e.cause as Error).stack || (e.cause as Error).message || String(e.cause)}` : '';
107
+ console.error(`[tyno] ${e.name || 'Error'}: ${e.message}${cause}`);
108
+ }
109
+ }
110
+
111
+ export class Application extends EventEmitter {
112
+ middlewares: AnyMiddleware[] = [];
113
+ server: http.Server | https.Server | null = null;
114
+ debug: boolean;
115
+ bodyConfig: BodyParserConfig;
116
+ private _shutdownHandlers: (() => unknown | Promise<unknown>)[] = [];
117
+ private _initializers: InitializerFn[] = [];
118
+ private _initialized = false;
119
+ private _cache: CacheManager | null = null;
120
+ private cacheConfig: CacheConfig;
121
+
122
+ constructor(options: ApplicationOptions = {}) {
123
+ super();
124
+ this.debug = options.debug ?? (process.env.NODE_ENV !== 'production');
125
+ this.bodyConfig = options.body ?? {};
126
+ this.cacheConfig = options.cache ?? {};
127
+ }
128
+
129
+ /**
130
+ * 获取缓存实例(懒加载,首次调用时初始化驱动)。
131
+ *
132
+ * @example
133
+ * await app.cache().set('key', 'value', 60);
134
+ * const val = await app.cache().get('key');
135
+ */
136
+ cache(): CacheManager {
137
+ if (!this._cache) {
138
+ this._cache = new CacheManager(this.cacheConfig);
139
+ }
140
+ return this._cache;
141
+ }
142
+
143
+ /**
144
+ * 注册初始化器。初始化器在 listen() 启动服务前按注册顺序自动执行。
145
+ */
146
+ initialize(fn: InitializerFn): this {
147
+ if (this._initialized) {
148
+ console.warn('[tyno] initialize() called after server has started, initializer will not run automatically. Consider registering initializers before listen().');
149
+ }
150
+ this._initializers.push(fn);
151
+ return this;
152
+ }
153
+
154
+ /**
155
+ * 注册中间件。支持单个函数或函数数组,按顺序依次执行。
156
+ */
157
+ use(fn: AnyMiddleware | AnyMiddleware[]): this {
158
+ if (Array.isArray(fn)) {
159
+ for (const f of fn) this.middlewares.push(f);
160
+ } else if (typeof fn === 'function') {
161
+ this.middlewares.push(fn);
162
+ }
163
+ return this;
164
+ }
165
+
166
+ /** `use()` 的别名 */
167
+ middleware(fn: AnyMiddleware | AnyMiddleware[]): this {
168
+ return this.use(fn);
169
+ }
170
+
171
+ /**
172
+ * 获取请求处理函数。
173
+ */
174
+ callback(): RequestHandler {
175
+ const middlewares = this.debug
176
+ ? [...this.middlewares, createErrorPageHandler({ dev: true })]
177
+ : this.middlewares;
178
+ return async (nodeReq: IncomingMessage, nodeRes: ServerResponse) => {
179
+ const reqProxy = createRequestProxy(nodeReq, this.bodyConfig);
180
+ // 将 CacheManager 注入请求上下文,供 cache 门面使用
181
+ if (Object.keys(this.cacheConfig).length > 0 || this._cache) {
182
+ (reqProxy as unknown as Record<string, unknown>)['_tyno_cache'] = this.cache();
183
+ }
184
+ await requestStore.run(reqProxy, async () => {
185
+ this.emit('request', reqProxy);
186
+ try {
187
+ const finalResponse = await compose(reqProxy, middlewares);
188
+ mergeResponseFacade(finalResponse);
189
+ this.emit('response', reqProxy, finalResponse);
190
+ await sendResponse(nodeRes, finalResponse);
191
+ this.emit('response:sent', reqProxy, finalResponse);
192
+ } catch (err: unknown) {
193
+ if (this.listenerCount('error') > 0) this.emit('error', err, reqProxy);
194
+ if (!nodeRes.headersSent) {
195
+ const errorResponse = buildErrorResponse(err, this.debug);
196
+ mergeResponseFacade(errorResponse);
197
+ await sendResponse(nodeRes, errorResponse);
198
+ this.emit('response:sent', reqProxy, errorResponse);
199
+ }
200
+ logError(err);
201
+ }
202
+ });
203
+ };
204
+ }
205
+
206
+ /**
207
+ * 启动服务。
208
+ */
209
+ listen(portOrOptions: number | ListenOptions, callback?: () => void): http.Server | https.Server {
210
+ const options: ListenOptions = typeof portOrOptions === 'object' ? portOrOptions : { port: portOrOptions };
211
+ const handler = this.callback();
212
+ this.server = options.tls ? https.createServer(options.tls as https.ServerOptions, handler) : http.createServer(handler);
213
+
214
+ // 启动前自动执行初始化器
215
+ if (!this._initialized && this._initializers.length > 0) {
216
+ const init = async () => {
217
+ for (const fn of this._initializers) {
218
+ await fn(this);
219
+ }
220
+ this._initialized = true;
221
+ this.server!.listen(options.port, options.host, () => {
222
+ this.emit('ready');
223
+ callback?.();
224
+ });
225
+ };
226
+ init().catch((err) => {
227
+ console.error('[tyno] Initializer failed:', err);
228
+ process.exit(1);
229
+ });
230
+ } else {
231
+ this._initialized = true;
232
+ this.server.listen(options.port, options.host, () => {
233
+ this.emit('ready');
234
+ callback?.();
235
+ });
236
+ }
237
+ if (options.gracefulShutdown !== false) {
238
+ const shutdown = () => this.close();
239
+ process.once('SIGINT', shutdown);
240
+ process.once('SIGTERM', shutdown);
241
+ }
242
+ return this.server;
243
+ }
244
+
245
+ close(callback?: () => void): void {
246
+ if (!this.server) return;
247
+ this._cache?.close().catch(() => {});
248
+ Promise.all(this._shutdownHandlers.map(fn => fn())).catch(() => {});
249
+ this.server.close(callback);
250
+ }
251
+
252
+ onShutdown(fn: () => unknown | Promise<unknown>): this {
253
+ if (typeof fn === 'function') this._shutdownHandlers.push(fn);
254
+ return this;
255
+ }
256
+
257
+ async inject(options: InjectOptions = {}): Promise<InjectResult> {
258
+ const { method = 'GET', url = '/', headers = {}, body } = options;
259
+ const rawBody = body ? (typeof body === 'string' ? body : Buffer.isBuffer(body) ? body.toString() : JSON.stringify(body)) : null;
260
+ const bodyBuf = rawBody ? Buffer.from(rawBody) : null;
261
+ const mockReq = Readable.from(bodyBuf ? [bodyBuf] : []) as unknown as IncomingMessage;
262
+ mockReq.method = method.toUpperCase();
263
+ mockReq.url = url;
264
+ mockReq.headers = {};
265
+ for (const [k, v] of Object.entries(headers)) mockReq.headers[k.toLowerCase()] = v;
266
+ if (options.cookies) {
267
+ const cookieStr = Object.entries(options.cookies).map(([k, v]) => `${k}=${v}`).join('; ');
268
+ if (cookieStr) mockReq.headers['cookie'] = cookieStr;
269
+ }
270
+ (mockReq as unknown as Record<string, unknown>).socket = { remoteAddress: '127.0.0.1' };
271
+
272
+ const chunks: Buffer[] = [];
273
+ const mockPassthrough = new PassThrough();
274
+ mockPassthrough.on('data', (chunk: Buffer) => chunks.push(chunk));
275
+ const mockRes = mockPassthrough as unknown as Record<string, unknown>;
276
+ mockRes.statusCode = 200;
277
+ mockRes.headers = {};
278
+ mockRes.headersSent = false;
279
+ mockRes.writeHead = function (status: number, statusMsgOrHeaders?: unknown, headers?: Record<string, unknown>) {
280
+ this.statusCode = status;
281
+ const h = typeof statusMsgOrHeaders === 'string' ? headers : (statusMsgOrHeaders as Record<string, unknown>);
282
+ if (h) {
283
+ // 归一化 header 名为小写
284
+ for (const [k, v] of Object.entries(h)) {
285
+ (this.headers as Record<string, unknown>)[k.toLowerCase()] = v;
286
+ }
287
+ }
288
+ this.headersSent = true;
289
+ };
290
+
291
+ const handler = this.callback();
292
+ await handler(mockReq, mockRes as unknown as ServerResponse);
293
+ if (!mockRes.writableEnded) await new Promise<void>(resolve => mockPassthrough.on('finish', () => resolve()));
294
+ await new Promise<void>(resolve => setImmediate(resolve));
295
+
296
+ const bodyStr = Buffer.concat(chunks).toString();
297
+ return {
298
+ status: mockRes.statusCode as number,
299
+ headers: mockRes.headers as Record<string, unknown>,
300
+ body: bodyStr,
301
+ json<T = unknown>(): T { return JSON.parse(bodyStr) as T; }
302
+ };
303
+ }
304
+ }