@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/src/index.ts ADDED
@@ -0,0 +1,29 @@
1
+ export { Application as Tyno } from './application.ts';
2
+ export { Request, Request as req } from './request/index.ts';
3
+ export { BodyParser, MultipartParser } from './request/index.ts';
4
+ export type { UploadedFile, MultipartResult } from './request/index.ts';
5
+ export { Response, Response as response, Response as res } from './response.ts';
6
+ export { SSEStream } from './response/sse.ts';
7
+ export { compose, normalizeResponse } from './compose.ts';
8
+ export { requestStore } from './context.ts';
9
+ export { default as request } from './request-global.ts';
10
+ export { cache as Cache, cache } from './cache-facade.ts';
11
+ export { Router } from './router/index.ts';
12
+ export type { RouterOptions } from './router/index.ts';
13
+ export { AppError, HttpError, createError, BadRequest, Unauthorized, Forbidden, NotFound, Conflict, PayloadTooLarge, TooManyRequests, InternalServerError, RuntimeError, isRuntimeError } from './errors/index.ts';
14
+ export type { AppErrorOptions } from './errors/index.ts';
15
+ export { cors } from './middlewares/cors.ts';
16
+ export type { CorsOptions } from './middlewares/cors.ts';
17
+ export { serveStatic } from './middlewares/static.ts';
18
+ export type { StaticOptions } from './middlewares/static.ts';
19
+ export { compress } from './middlewares/compress.ts';
20
+ export type { CompressOptions } from './middlewares/compress.ts';
21
+ export { requestId } from './middlewares/request-id.ts';
22
+ export type { RequestIdOptions } from './middlewares/request-id.ts';
23
+ export { CacheManager } from './cache/index.ts';
24
+ export { MemoryDriver, FileDriver, RedisDriver } from './cache/index.ts';
25
+ export type { CacheDriver, CacheConfig } from './cache/types.ts';
26
+ export { lookupMime, MIME_TYPES } from './mime.ts';
27
+ export { asErrorMiddleware } from './compose.ts';
28
+ export { IS_ERROR_MIDDLEWARE } from './types.ts';
29
+ export type { AnyMiddleware, Middleware, TerminalMiddleware, ErrorMiddleware, NextFunction, TynoRequest, ResponseBody, Headers, CookieOptions, BodyOptions, BodyParserConfig, ListenOptions, InjectOptions, InjectResult, ApplicationOptions, InitializerFn } from './types.ts';
@@ -0,0 +1,101 @@
1
+ import zlib from 'node:zlib';
2
+ import { Transform } from 'node:stream';
3
+ import { Response } from '../response.ts';
4
+ import type { Middleware, TynoRequest, NextFunction } from '../types.ts';
5
+
6
+ export interface CompressOptions {
7
+ /** 最小压缩字节数,低于此阈值不压缩(默认 1024) */
8
+ threshold?: number;
9
+ /** 压缩级别 1-9,默认 Z_DEFAULT_COMPRESSION */
10
+ level?: number;
11
+ /** 自定义过滤:返回 true 表示需要压缩 */
12
+ filter?: (contentType: string) => boolean;
13
+ }
14
+
15
+ // 默认不压缩的类型(已压缩或二进制格式)
16
+ const defaultNoCompress = /^(?:image|video|audio|font|application\/(?:zip|gzip|tar|wasm|octet-stream))/;
17
+
18
+ /** 异步 gzip 压缩 */
19
+ function gzipAsync(buf: Buffer, level: number): Promise<Buffer> {
20
+ return new Promise((resolve, reject) => {
21
+ zlib.gzip(buf, { level }, (err, result) => err ? reject(err) : resolve(result));
22
+ });
23
+ }
24
+
25
+ /** 异步 deflate 压缩 */
26
+ function deflateAsync(buf: Buffer, level: number): Promise<Buffer> {
27
+ return new Promise((resolve, reject) => {
28
+ zlib.deflate(buf, { level }, (err, result) => err ? reject(err) : resolve(result));
29
+ });
30
+ }
31
+
32
+ /**
33
+ * 响应压缩中间件,根据 Accept-Encoding 自动选择 gzip/deflate 压缩。
34
+ * 支持普通响应(异步压缩)和流式响应(Transform 流包装)。
35
+ *
36
+ * @example
37
+ * app.use(compress()); // 默认配置
38
+ * app.use(compress({ threshold: 2048 })); // 自定义阈值
39
+ */
40
+ export function compress(options: CompressOptions = {}): Middleware {
41
+ const threshold = options.threshold ?? 1024;
42
+ const level = options.level ?? zlib.constants.Z_DEFAULT_COMPRESSION;
43
+
44
+ return async (req: TynoRequest, next: NextFunction): Promise<unknown> => {
45
+ const res = await next();
46
+
47
+ if (!(res instanceof Response)) return res;
48
+
49
+ // 检查客户端是否支持压缩
50
+ const acceptEncoding = (req.header('accept-encoding') || '').toLowerCase();
51
+ const useGzip = acceptEncoding.includes('gzip');
52
+ const useDeflate = !useGzip && acceptEncoding.includes('deflate');
53
+ if (!useGzip && !useDeflate) return res;
54
+
55
+ // 检查是否需要压缩(通过 Content-Type 过滤)
56
+ const contentType = res.get('Content-Type') || '';
57
+ const shouldCompress = options.filter
58
+ ? options.filter(contentType)
59
+ : !defaultNoCompress.test(contentType);
60
+ if (!shouldCompress) return res;
61
+
62
+ const encoding = useGzip ? 'gzip' : 'deflate';
63
+ const body = res.body;
64
+
65
+ // 流式响应:用 Transform 流包装
66
+ if (body != null && typeof (body as NodeJS.ReadableStream).pipe === 'function') {
67
+ const transform = useGzip
68
+ ? zlib.createGzip({ level })
69
+ : zlib.createDeflate({ level });
70
+ res.body = (body as NodeJS.ReadableStream).pipe(transform);
71
+ res.set('Content-Encoding', encoding);
72
+ res.headers['Transfer-Encoding'] = 'chunked';
73
+ delete res.headers['Content-Length'];
74
+ addVary(res);
75
+ return res;
76
+ }
77
+
78
+ // 普通响应:异步压缩
79
+ if (body == null) return res;
80
+ const raw = Buffer.isBuffer(body) ? body : Buffer.from(String(body));
81
+ if (raw.length < threshold) return res;
82
+
83
+ const compressed = useGzip
84
+ ? await gzipAsync(raw, level)
85
+ : await deflateAsync(raw, level);
86
+
87
+ res.set('Content-Encoding', encoding);
88
+ res.set('Content-Length', compressed.length);
89
+ res.body = compressed;
90
+ addVary(res);
91
+
92
+ return res;
93
+ };
94
+ }
95
+
96
+ function addVary(res: Response): void {
97
+ const vary = res.get('Vary') || '';
98
+ if (!vary.includes('Accept-Encoding')) {
99
+ res.set('Vary', vary ? `${vary}, Accept-Encoding` : 'Accept-Encoding');
100
+ }
101
+ }
@@ -0,0 +1,57 @@
1
+ import { Response } from '../response.ts';
2
+ import type { TynoRequest, NextFunction, Middleware } from '../types.ts';
3
+
4
+ export interface CorsOptions {
5
+ origin?: string | string[] | ((origin: string | undefined) => string);
6
+ allowMethods?: string[];
7
+ allowHeaders?: string[];
8
+ exposeHeaders?: string[];
9
+ credentials?: boolean;
10
+ maxAge?: number | null;
11
+ }
12
+
13
+ export function cors(options: CorsOptions = {}): Middleware {
14
+ const {
15
+ origin = '*',
16
+ allowMethods = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'],
17
+ allowHeaders = [],
18
+ exposeHeaders = [],
19
+ credentials = false,
20
+ maxAge = null
21
+ } = options;
22
+
23
+ const allowMethodsStr = allowMethods.join(', ');
24
+ const allowHeadersStr = allowHeaders.join(', ');
25
+ const exposeHeadersStr = exposeHeaders.join(', ');
26
+
27
+ function resolveOrigin(requestOrigin: string | undefined): string {
28
+ if (origin === '*') return credentials ? (requestOrigin || '*') : '*';
29
+ if (typeof origin === 'function') return origin(requestOrigin);
30
+ if (Array.isArray(origin)) return origin.includes(requestOrigin || '') ? (requestOrigin || '') : '';
31
+ return origin === requestOrigin ? (requestOrigin || '') : '';
32
+ }
33
+
34
+ return async (req: TynoRequest, next: NextFunction): Promise<unknown> => {
35
+ const requestOrigin = req.header('origin') as string | undefined;
36
+ const originValue = resolveOrigin(requestOrigin);
37
+
38
+ if (req.method === 'OPTIONS') {
39
+ const headers: Record<string, string> = {};
40
+ if (originValue) headers['Access-Control-Allow-Origin'] = originValue;
41
+ headers['Access-Control-Allow-Methods'] = allowMethodsStr;
42
+ if (allowHeadersStr) headers['Access-Control-Allow-Headers'] = allowHeadersStr;
43
+ if (exposeHeadersStr) headers['Access-Control-Expose-Headers'] = exposeHeadersStr;
44
+ if (credentials) headers['Access-Control-Allow-Credentials'] = 'true';
45
+ if (maxAge != null) headers['Access-Control-Max-Age'] = String(maxAge);
46
+ return new Response(204, headers, null);
47
+ }
48
+
49
+ const res = await next();
50
+ if (res instanceof Response) {
51
+ if (originValue) res.headers['Access-Control-Allow-Origin'] = originValue;
52
+ if (credentials) res.headers['Access-Control-Allow-Credentials'] = 'true';
53
+ if (exposeHeadersStr) res.headers['Access-Control-Expose-Headers'] = exposeHeadersStr;
54
+ }
55
+ return res;
56
+ };
57
+ }
@@ -0,0 +1,89 @@
1
+ import { Response } from '../response.ts';
2
+ import { AppError } from '../errors/index.ts';
3
+ import type { TynoRequest, NextFunction, ErrorMiddleware } from '../types.ts';
4
+
5
+ export interface ErrorPageOptions {
6
+ dev?: boolean;
7
+ json?: boolean;
8
+ }
9
+
10
+ /**
11
+ * 创建错误页处理函数(供 Application debug 模式内部使用)。
12
+ * 开发环境下返回包含错误堆栈的格式化页面,生产环境仅返回简洁错误信息。
13
+ */
14
+ export function createErrorPageHandler(options: ErrorPageOptions = {}): ErrorMiddleware {
15
+ const isDev = options.dev ?? process.env.NODE_ENV !== 'production';
16
+
17
+ return async (err: unknown, req: TynoRequest, _next: NextFunction): Promise<unknown> => {
18
+ const e = err as Record<string, unknown>;
19
+ const status = (e.status as number) || 500;
20
+ const isAppError = err instanceof AppError;
21
+ const shouldExpose = isAppError ? (err as AppError).expose : false;
22
+
23
+ const accept = (req.header('accept') || '').toLowerCase();
24
+ const wantsJson = options.json ?? !accept.includes('text/html');
25
+
26
+ if (wantsJson) {
27
+ const body: Record<string, unknown> = {
28
+ error: (isDev || shouldExpose) ? (e.message as string || 'Internal Server Error') : 'Internal Server Error',
29
+ status
30
+ };
31
+ if (isDev) {
32
+ body.stack = e.stack;
33
+ body.name = e.name;
34
+ if (isAppError && (err as AppError).code != null) body.code = (err as AppError).code;
35
+ if (e.cause) body.cause = (e.cause as Error).message;
36
+ } else if (isAppError && (err as AppError).code != null) {
37
+ body.code = (err as AppError).code;
38
+ }
39
+ return Response.json(body, status);
40
+ }
41
+
42
+ const message = (isDev || shouldExpose) ? (e.message as string || 'Internal Server Error') : 'Internal Server Error';
43
+ const stackHtml = isDev && e.stack
44
+ ? `<div class="stack"><h3>Stack Trace</h3><pre>${escapeHtml(String(e.stack))}</pre></div>` : '';
45
+ const causeHtml = isDev && e.cause
46
+ ? `<div class="cause"><h3>Caused By</h3><pre>${escapeHtml((e.cause as Error).message)}</pre></div>` : '';
47
+ const codeInfo = isAppError && (err as AppError).code != null ? ` [${(err as AppError).code}]` : '';
48
+
49
+ const html = `<!DOCTYPE html>
50
+ <html lang="en">
51
+ <head>
52
+ <meta charset="utf-8">
53
+ <meta name="viewport" content="width=device-width,initial-scale=1">
54
+ <title>${status} ${escapeHtml(message)}</title>
55
+ <style>
56
+ * { margin: 0; padding: 0; box-sizing: border-box; }
57
+ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, monospace; background: #1a1a2e; color: #e0e0e0; padding: 40px; }
58
+ .container { max-width: 900px; margin: 0 auto; }
59
+ .status { font-size: 72px; font-weight: 700; color: ${status < 500 ? '#f0ad4e' : '#e74c3c'}; }
60
+ .message { font-size: 24px; margin: 10px 0 30px; color: #ccc; }
61
+ .stack pre, .cause pre { background: #0d1117; border: 1px solid #30363d; border-radius: 6px; padding: 16px; overflow-x: auto; font-size: 13px; line-height: 1.6; color: #f8f8f2; }
62
+ .meta { display: flex; gap: 20px; flex-wrap: wrap; margin: 20px 0; }
63
+ .meta-item { background: #16213e; padding: 10px 16px; border-radius: 6px; font-size: 14px; }
64
+ .meta-label { color: #7f8c8d; font-size: 12px; text-transform: uppercase; }
65
+ .meta-value { color: #e0e0e0; font-weight: 600; }
66
+ </style>
67
+ </head>
68
+ <body>
69
+ <div class="container">
70
+ <div class="status">${status}</div>
71
+ <div class="message">${escapeHtml(message)}${codeInfo}</div>
72
+ <div class="meta">
73
+ <div class="meta-item"><div class="meta-label">Method</div><div class="meta-value">${escapeHtml(req.method || '')}</div></div>
74
+ <div class="meta-item"><div class="meta-label">Path</div><div class="meta-value">${escapeHtml(req.path || '')}</div></div>
75
+ ${req.requestId ? `<div class="meta-item"><div class="meta-label">Request ID</div><div class="meta-value">${escapeHtml(String(req.requestId))}</div></div>` : ''}
76
+ </div>
77
+ ${stackHtml}
78
+ ${causeHtml}
79
+ </div>
80
+ </body>
81
+ </html>`;
82
+
83
+ return new Response(status, { 'Content-Type': 'text/html; charset=utf-8' }, html);
84
+ };
85
+ }
86
+
87
+ function escapeHtml(s: string): string {
88
+ return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
89
+ }
@@ -0,0 +1,9 @@
1
+ // 内置中间件导出
2
+ export { cors } from './cors.ts';
3
+ export type { CorsOptions } from './cors.ts';
4
+ export { serveStatic } from './static.ts';
5
+ export type { StaticOptions } from './static.ts';
6
+ export { compress } from './compress.ts';
7
+ export type { CompressOptions } from './compress.ts';
8
+ export { requestId } from './request-id.ts';
9
+ export type { RequestIdOptions } from './request-id.ts';
@@ -0,0 +1,47 @@
1
+ import crypto from 'node:crypto';
2
+ import { Response } from '../response.ts';
3
+ import type { TynoRequest, NextFunction, Middleware } from '../types.ts';
4
+
5
+ export interface RequestIdOptions {
6
+ /** 响应头名称,默认 X-Request-Id */
7
+ header?: string;
8
+ /** 是否从请求头读取已有 ID(默认 true) */
9
+ readFromHeader?: boolean;
10
+ /** 请求头名称(默认同 header) */
11
+ requestHeader?: string;
12
+ }
13
+
14
+ /**
15
+ * 请求 ID 中间件。
16
+ * 自动为每个请求生成唯一 ID(UUID v4),或从请求头读取已有 ID。
17
+ * ID 会注入到 req.requestId 并写入响应头,贯穿全链路日志。
18
+ */
19
+ export function requestId(options: RequestIdOptions = {}): Middleware {
20
+ const headerName = options.header || 'X-Request-Id';
21
+ const readFromHeader = options.readFromHeader !== false;
22
+ const requestHeader = options.requestHeader || headerName.toLowerCase();
23
+
24
+ return async (req: TynoRequest, next: NextFunction): Promise<unknown> => {
25
+ let id: string;
26
+
27
+ if (readFromHeader) {
28
+ const existing = req.header(requestHeader);
29
+ id = existing || crypto.randomUUID();
30
+ } else {
31
+ id = crypto.randomUUID();
32
+ }
33
+
34
+ // 注入到请求上下文
35
+ req.requestId = id;
36
+
37
+ // 执行下游
38
+ const res = await next();
39
+
40
+ // 写入响应头
41
+ if (res instanceof Response) {
42
+ res.headers[headerName] = id;
43
+ }
44
+
45
+ return res;
46
+ };
47
+ }
@@ -0,0 +1,138 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { Response } from '../response.ts';
4
+ import { HttpError } from '../errors/index.ts';
5
+ import { lookupMime } from '../mime.ts';
6
+ import type { TynoRequest, NextFunction, Middleware } from '../types.ts';
7
+
8
+ export interface StaticOptions {
9
+ prefix?: string;
10
+ index?: string;
11
+ maxAge?: number;
12
+ etag?: boolean;
13
+ }
14
+
15
+ export function serveStatic(rootDir: string, options: StaticOptions = {}): Middleware {
16
+ const root = path.resolve(rootDir);
17
+ const prefix = options.prefix || '';
18
+ const indexFile = options.index ?? 'index.html';
19
+ const maxAge = options.maxAge ?? 0;
20
+ const enableEtag = options.etag !== false;
21
+
22
+ return async (req: TynoRequest, next: NextFunction): Promise<unknown> => {
23
+ if (req.method !== 'GET' && req.method !== 'HEAD') return next();
24
+
25
+ let urlPath: string = req.path;
26
+ if (prefix) {
27
+ if (!urlPath.startsWith(prefix)) return next();
28
+ urlPath = urlPath.slice(prefix.length) || '/';
29
+ }
30
+
31
+ let relPath: string;
32
+ try { relPath = decodeURIComponent(urlPath); } catch { return next(); }
33
+ const filePath = path.normalize(path.join(root, relPath));
34
+ if (!filePath.startsWith(root)) throw new HttpError(403, 'Forbidden');
35
+
36
+ let stat: fs.Stats;
37
+ try {
38
+ stat = await fs.promises.stat(filePath);
39
+ } catch (e: unknown) {
40
+ const err = e as NodeJS.ErrnoException;
41
+ if (err.code === 'ENOENT' || err.code === 'ENOTDIR') return next();
42
+ throw e;
43
+ }
44
+
45
+ if (stat.isDirectory()) {
46
+ const indexFilePath = path.join(filePath, indexFile);
47
+ try {
48
+ stat = await fs.promises.stat(indexFilePath);
49
+ return buildFileResponse(indexFilePath, stat, req, maxAge, enableEtag);
50
+ } catch { return next(); }
51
+ }
52
+
53
+ return buildFileResponse(filePath, stat, req, maxAge, enableEtag);
54
+ };
55
+ }
56
+
57
+ function buildFileResponse(filePath: string, stat: fs.Stats, req: TynoRequest, maxAge: number, enableEtag: boolean): Response {
58
+ const ext = path.extname(filePath).slice(1);
59
+ const type = lookupMime(ext);
60
+ const etag = enableEtag ? `"${stat.size.toString(16)}-${stat.mtimeMs.toString(16)}"` : null;
61
+
62
+ // Range 请求处理
63
+ const rangeHeader = req.header('range');
64
+ if (rangeHeader && stat.size > 0) {
65
+ return buildRangeResponse(filePath, stat, type, etag, rangeHeader);
66
+ }
67
+
68
+ // 条件请求 304
69
+ if (etag && req.header('if-none-match') === etag) {
70
+ return new Response(304, {}, null);
71
+ }
72
+
73
+ const headers: Record<string, string | number> = {
74
+ 'Content-Type': type,
75
+ 'Content-Length': stat.size,
76
+ 'Cache-Control': `public, max-age=${maxAge}`,
77
+ 'Last-Modified': stat.mtime.toUTCString(),
78
+ 'Accept-Ranges': 'bytes'
79
+ };
80
+ if (etag) headers['ETag'] = etag;
81
+
82
+ const body = req.method === 'HEAD' ? null : fs.createReadStream(filePath);
83
+ return new Response(200, headers, body);
84
+ }
85
+
86
+ /**
87
+ * 处理 Range 请求,返回 206 Partial Content。
88
+ * 支持格式:
89
+ * bytes=start-end — 指定范围
90
+ * bytes=start- — 从 start 到末尾
91
+ * bytes=-suffix — 最后 suffix 个字节
92
+ */
93
+ function buildRangeResponse(filePath: string, stat: fs.Stats, type: string, etag: string | null, rangeHeader: string): Response {
94
+ const total = stat.size;
95
+ const match = rangeHeader.match(/bytes=(\d*)-(\d*)/i);
96
+
97
+ if (!match) {
98
+ // 非法 Range,返回 416
99
+ return new Response(416, {
100
+ 'Content-Type': type,
101
+ 'Content-Range': `bytes */${total}`
102
+ }, 'Range Not Satisfiable');
103
+ }
104
+
105
+ let start: number;
106
+ let end: number;
107
+
108
+ if (match[1] === '' && match[2] !== '') {
109
+ // bytes=-suffix → 最后 suffix 字节
110
+ const suffix = parseInt(match[2], 10);
111
+ start = Math.max(0, total - suffix);
112
+ end = total - 1;
113
+ } else {
114
+ start = match[1] ? parseInt(match[1], 10) : 0;
115
+ end = match[2] ? Math.min(parseInt(match[2], 10), total - 1) : total - 1;
116
+ }
117
+
118
+ if (start > end || start >= total) {
119
+ return new Response(416, {
120
+ 'Content-Type': type,
121
+ 'Content-Range': `bytes */${total}`
122
+ }, 'Range Not Satisfiable');
123
+ }
124
+
125
+ const length = end - start + 1;
126
+ const headers: Record<string, string | number> = {
127
+ 'Content-Type': type,
128
+ 'Content-Length': length,
129
+ 'Content-Range': `bytes ${start}-${end}/${total}`,
130
+ 'Cache-Control': 'public, max-age=3600',
131
+ 'Accept-Ranges': 'bytes'
132
+ };
133
+ if (etag) headers['ETag'] = etag;
134
+
135
+ const body = fs.createReadStream(filePath, { start, end });
136
+ return new Response(206, headers, body);
137
+ }
138
+
package/src/mime.ts ADDED
@@ -0,0 +1,38 @@
1
+ /** 常见 MIME 类型映射 */
2
+ export const MIME_TYPES: Record<string, string> = {
3
+ html: 'text/html; charset=utf-8',
4
+ htm: 'text/html; charset=utf-8',
5
+ css: 'text/css; charset=utf-8',
6
+ js: 'application/javascript; charset=utf-8',
7
+ mjs: 'application/javascript; charset=utf-8',
8
+ json: 'application/json; charset=utf-8',
9
+ txt: 'text/plain; charset=utf-8',
10
+ xml: 'application/xml; charset=utf-8',
11
+ svg: 'image/svg+xml',
12
+ png: 'image/png',
13
+ jpg: 'image/jpeg',
14
+ jpeg: 'image/jpeg',
15
+ gif: 'image/gif',
16
+ webp: 'image/webp',
17
+ ico: 'image/x-icon',
18
+ bmp: 'image/bmp',
19
+ pdf: 'application/pdf',
20
+ zip: 'application/zip',
21
+ gz: 'application/gzip',
22
+ tar: 'application/x-tar',
23
+ mp3: 'audio/mpeg',
24
+ mp4: 'video/mp4',
25
+ webm: 'video/webm',
26
+ woff: 'font/woff',
27
+ woff2: 'font/woff2',
28
+ ttf: 'font/ttf',
29
+ otf: 'font/otf',
30
+ csv: 'text/csv; charset=utf-8',
31
+ md: 'text/markdown; charset=utf-8',
32
+ wasm: 'application/wasm'
33
+ };
34
+
35
+ /** 根据扩展名获取 MIME 类型,未知则返回 application/octet-stream */
36
+ export function lookupMime(ext: string): string {
37
+ return MIME_TYPES[ext.toLowerCase()] || 'application/octet-stream';
38
+ }
@@ -0,0 +1,61 @@
1
+ import type { IncomingMessage } from 'node:http';
2
+ import { HttpError } from '../errors/index.ts';
3
+ import type { BodyParserConfig } from '../types.ts';
4
+
5
+ /** 将 '1mb' / '1024' 等字符串解析为字节数 */
6
+ export function parseLimit(s: number | string): number {
7
+ if (typeof s === 'number') return s;
8
+ const m = /^(\d+(?:\.\d+)?)\s*(b|kb|mb|gb)?$/i.exec(s);
9
+ if (!m) return 1024 * 1024;
10
+ const n = parseFloat(m[1]);
11
+ const unit = (m[2] || '').toLowerCase();
12
+ const mult = unit === 'b' ? 1 : unit === 'kb' ? 1024 : unit === 'mb' ? 1024 * 1024 : unit === 'gb' ? 1024 * 1024 * 1024 : 1;
13
+ return Math.floor(n * mult);
14
+ }
15
+
16
+ /**
17
+ * 读取原始请求体到 Buffer,带大小限制。
18
+ * 超限时立即销毁连接(不再接收数据),抛出 413 错误。
19
+ */
20
+ export async function readRawBody(req: IncomingMessage, limit: number): Promise<Buffer> {
21
+ const chunks: Buffer[] = [];
22
+ let size = 0;
23
+ for await (const rawChunk of req as AsyncIterable<Buffer>) {
24
+ const chunk = Buffer.isBuffer(rawChunk) ? rawChunk : Buffer.from(rawChunk as string | Buffer);
25
+ size += chunk.length;
26
+ if (size > limit) {
27
+ req.destroy(); // 立即终止连接,不再接收后续数据
28
+ throw new HttpError(413, 'Payload Too Large');
29
+ }
30
+ chunks.push(chunk);
31
+ }
32
+ return Buffer.concat(chunks);
33
+ }
34
+
35
+ /**
36
+ * BodyParser:解析 JSON / URL-encoded / 文本请求体。
37
+ */
38
+ export class BodyParser {
39
+ private limit: number;
40
+
41
+ constructor(config: BodyParserConfig = {}) {
42
+ this.limit = parseLimit(config.limit ?? '1mb');
43
+ }
44
+
45
+ /**
46
+ * 解析请求体,根据 Content-Type 自动选择解析方式。
47
+ */
48
+ async parse(req: IncomingMessage): Promise<unknown> {
49
+ const contentType = ((req.headers['content-type'] as string) || '').toLowerCase();
50
+ const raw = await readRawBody(req, this.limit);
51
+
52
+ if (contentType.includes('application/json')) {
53
+ try { return JSON.parse(raw.toString()); }
54
+ catch { return raw.toString(); }
55
+ }
56
+ if (contentType.includes('application/x-www-form-urlencoded')) {
57
+ return Object.fromEntries(new URLSearchParams(raw.toString()));
58
+ }
59
+ return raw.toString();
60
+ }
61
+ }