@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.
- package/README-zh.md +572 -0
- package/README.md +555 -0
- package/example/app.ts +173 -0
- package/example/public/index.html +1 -0
- package/example/public/test.json +1 -0
- package/package.json +63 -0
- package/scripts/build.mjs +97 -0
- package/scripts/rename-cjs.mjs +23 -0
- package/src/application.ts +304 -0
- package/src/cache/drivers/file.ts +79 -0
- package/src/cache/drivers/memory.ts +72 -0
- package/src/cache/drivers/redis.ts +72 -0
- package/src/cache/index.ts +5 -0
- package/src/cache/manager.ts +106 -0
- package/src/cache/types.ts +24 -0
- package/src/cache-facade.ts +64 -0
- package/src/compose.ts +139 -0
- package/src/context.ts +5 -0
- package/src/errors/app-error.ts +37 -0
- package/src/errors/http-error.ts +34 -0
- package/src/errors/index.ts +4 -0
- package/src/errors/runtime-error.ts +19 -0
- package/src/facade/index.ts +3 -0
- package/src/index.ts +29 -0
- package/src/middlewares/compress.ts +101 -0
- package/src/middlewares/cors.ts +57 -0
- package/src/middlewares/error-page.ts +89 -0
- package/src/middlewares/index.ts +9 -0
- package/src/middlewares/request-id.ts +47 -0
- package/src/middlewares/static.ts +138 -0
- package/src/mime.ts +38 -0
- package/src/request/body-parser.ts +61 -0
- package/src/request/index.ts +273 -0
- package/src/request/multipart-parser.ts +360 -0
- package/src/request-global.ts +31 -0
- package/src/response/sse.ts +54 -0
- package/src/response.ts +177 -0
- package/src/router/index.ts +290 -0
- package/src/router/node.ts +15 -0
- package/src/router/parse-path.ts +18 -0
- package/src/types.ts +109 -0
- package/test/functional.test.ts +614 -0
- package/tsconfig.build.json +13 -0
- package/tsconfig.cjs.json +9 -0
- package/tsconfig.json +21 -0
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
import type { IncomingMessage } from 'node:http';
|
|
2
|
+
import { HttpError } from '../errors/index.ts';
|
|
3
|
+
import { BodyParser } from './body-parser.ts';
|
|
4
|
+
import { MultipartParser } from './multipart-parser.ts';
|
|
5
|
+
import type { BodyParserConfig } from '../types.ts';
|
|
6
|
+
import type { UploadedFile, MultipartResult } from './multipart-parser.ts';
|
|
7
|
+
|
|
8
|
+
/** 用于存储自定义数据的隐藏键 */
|
|
9
|
+
export const DATA_KEY: unique symbol = Symbol('request-custom-data');
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* 封装 Node.js 原生 IncomingMessage。
|
|
13
|
+
* 只读属性:method, url, headers 等,不可被中间件覆盖。
|
|
14
|
+
* 自定义属性会存放到 this[DATA_KEY] 上,通过 Proxy 透明访问。
|
|
15
|
+
*/
|
|
16
|
+
export class Request {
|
|
17
|
+
req: IncomingMessage;
|
|
18
|
+
[DATA_KEY]: Record<string, unknown>;
|
|
19
|
+
|
|
20
|
+
/** 路由参数(由 Router 注入),如 /users/:id → { id: '123' } */
|
|
21
|
+
declare params: Record<string, string>;
|
|
22
|
+
|
|
23
|
+
private _queryCache: Record<string, string> | null = null;
|
|
24
|
+
private _bodyCache: unknown = null;
|
|
25
|
+
private _bodyParsed = false;
|
|
26
|
+
private _filesCache: MultipartResult | null = null;
|
|
27
|
+
private _filesParsed = false;
|
|
28
|
+
private _cookiesCache: Record<string, string> | null = null;
|
|
29
|
+
|
|
30
|
+
private _bodyParser: BodyParser;
|
|
31
|
+
private _multipartParser: MultipartParser;
|
|
32
|
+
|
|
33
|
+
constructor(nodeReq: IncomingMessage, config: BodyParserConfig = {}) {
|
|
34
|
+
this.req = nodeReq;
|
|
35
|
+
this[DATA_KEY] = Object.create(null);
|
|
36
|
+
this._bodyParser = new BodyParser(config);
|
|
37
|
+
this._multipartParser = new MultipartParser(config);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// ==================== 只读原生属性 ====================
|
|
41
|
+
|
|
42
|
+
/** 请求方法(GET / POST / ...) */
|
|
43
|
+
get method(): string { return this.req.method || ''; }
|
|
44
|
+
/** 请求原始 URL(含 query string) */
|
|
45
|
+
get url(): string { return this.req.url || ''; }
|
|
46
|
+
/** 请求头对象 */
|
|
47
|
+
get headers(): Record<string, string | string[] | undefined> {
|
|
48
|
+
return this.req.headers as Record<string, string | string[] | undefined>;
|
|
49
|
+
}
|
|
50
|
+
/** HTTP 协议版本,如 '1.1' / '2.0' */
|
|
51
|
+
get httpVersion(): string { return this.req.httpVersion || '1.1'; }
|
|
52
|
+
|
|
53
|
+
/** 客户端 IP(优先取 X-Forwarded-For) */
|
|
54
|
+
get ip(): string {
|
|
55
|
+
const forwarded = ((this.req.headers['x-forwarded-for'] as string) || '').split(',')[0].trim();
|
|
56
|
+
return forwarded || this.req.socket.remoteAddress || '';
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** 客户端端口 */
|
|
60
|
+
get port(): number {
|
|
61
|
+
return (this.req.socket.remotePort as number) || 0;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** 服务器本地 IP */
|
|
65
|
+
get localAddress(): string {
|
|
66
|
+
return this.req.socket.localAddress || '';
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** 服务器本地端口 */
|
|
70
|
+
get localPort(): number {
|
|
71
|
+
return (this.req.socket.localPort as number) || 0;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** 传输协议:'http' 或 'https' */
|
|
75
|
+
get protocol(): string {
|
|
76
|
+
return (this.req.socket as unknown as Record<string, unknown>).encrypted ? 'https' : 'http';
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** 是否为 HTTPS 安全连接 */
|
|
80
|
+
get secure(): boolean {
|
|
81
|
+
return !!(this.req.socket as unknown as Record<string, unknown>).encrypted;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** User-Agent */
|
|
85
|
+
get userAgent(): string { return (this.req.headers['user-agent'] as string) || ''; }
|
|
86
|
+
|
|
87
|
+
/** 请求路径(不含 query string) */
|
|
88
|
+
get path(): string {
|
|
89
|
+
const url = this.req.url || '';
|
|
90
|
+
const idx = url.indexOf('?');
|
|
91
|
+
return idx === -1 ? url : url.slice(0, idx);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Host 头(含端口,如 localhost:4567) */
|
|
95
|
+
get host(): string { return (this.req.headers.host as string) || ''; }
|
|
96
|
+
|
|
97
|
+
/** 不含端口的 hostname */
|
|
98
|
+
get hostname(): string {
|
|
99
|
+
const host = this.host;
|
|
100
|
+
const idx = host.lastIndexOf(':');
|
|
101
|
+
return idx > 0 ? host.slice(0, idx) : host;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** 完整 URL(含协议,如 https://example.com/path?a=1) */
|
|
105
|
+
get fullUrl(): string {
|
|
106
|
+
return `${this.protocol}://${this.host}${this.url}`;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* 判断是否为 AJAX 请求(基于 X-Requested-With 头)
|
|
111
|
+
*/
|
|
112
|
+
isAjax(): boolean {
|
|
113
|
+
return (this.header('x-requested-with') || '').toLowerCase() === 'xmlhttprequest';
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* 判断是否为 AJAX 请求(isAjax 别名)
|
|
118
|
+
*/
|
|
119
|
+
isXHR(): boolean { return this.isAjax(); }
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* 判断请求是否期望 JSON 响应(基于 Accept 头)
|
|
123
|
+
*/
|
|
124
|
+
wantsJSON(): boolean {
|
|
125
|
+
const accept = (this.header('accept') || '').toLowerCase();
|
|
126
|
+
return accept.includes('application/json') || accept.includes('text/json');
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ==================== Query ====================
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* 获取全部 query 参数,或取单个 key 的值。
|
|
133
|
+
*
|
|
134
|
+
* @example
|
|
135
|
+
* req.query() // → { id: '1', name: 'Alice' }
|
|
136
|
+
* req.query('id') // → '1'
|
|
137
|
+
* req.query('missing') // → null
|
|
138
|
+
* req.query('missing', 'default') // → 'default'
|
|
139
|
+
*/
|
|
140
|
+
query(): Record<string, string>;
|
|
141
|
+
query(key: string): string | null;
|
|
142
|
+
query(key: string, defaultValue: string): string;
|
|
143
|
+
query(key?: string, defaultValue: string | null = null): Record<string, string> | string | null {
|
|
144
|
+
if (!this._queryCache) {
|
|
145
|
+
const url = this.url || '';
|
|
146
|
+
const idx = url.indexOf('?');
|
|
147
|
+
const qs = idx !== -1 ? url.slice(idx + 1) : '';
|
|
148
|
+
this._queryCache = Object.fromEntries(new URLSearchParams(qs));
|
|
149
|
+
}
|
|
150
|
+
if (key === undefined) return this._queryCache;
|
|
151
|
+
const val = this._queryCache[key];
|
|
152
|
+
return val !== undefined ? val : defaultValue;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** `query(key, default?)` 的别名 */
|
|
156
|
+
get(key: string, defaultValue?: string | null): string | null {
|
|
157
|
+
return this._getQueryValue(key, defaultValue);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
getQuery(key: string, defaultValue?: string | null): string | null {
|
|
161
|
+
return this._getQueryValue(key, defaultValue);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** 内部:从已缓存的 query 中取值 */
|
|
165
|
+
private _getQueryValue(key: string, defaultValue: string | null = null): string | null {
|
|
166
|
+
// 确保 cache 已填充
|
|
167
|
+
if (!this._queryCache) this.query();
|
|
168
|
+
const val = this._queryCache![key];
|
|
169
|
+
return val !== undefined ? val : defaultValue;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ==================== Body(委托给 BodyParser / MultipartParser) ====================
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* 解析请求体。
|
|
176
|
+
* - multipart/form-data → 返回 fields 部分(文件用 files() 获取)
|
|
177
|
+
* - 其他类型 → 返回解析后的对象/字符串
|
|
178
|
+
* 结果缓存。
|
|
179
|
+
*/
|
|
180
|
+
async body(): Promise<unknown> {
|
|
181
|
+
if (this._bodyParsed) return this._bodyCache;
|
|
182
|
+
const contentType = ((this.req.headers['content-type'] as string) || '').toLowerCase();
|
|
183
|
+
if (contentType.includes('multipart/form-data')) {
|
|
184
|
+
const result = await this.files();
|
|
185
|
+
this._bodyCache = result.fields;
|
|
186
|
+
} else {
|
|
187
|
+
this._bodyCache = await this._bodyParser.parse(this.req);
|
|
188
|
+
}
|
|
189
|
+
this._bodyParsed = true;
|
|
190
|
+
return this._bodyCache;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* 解析文件上传(multipart/form-data)。
|
|
195
|
+
* 返回 { fields, files },结果缓存。
|
|
196
|
+
* 非 multipart 请求返回 { fields: {}, files: [] }。
|
|
197
|
+
*/
|
|
198
|
+
async files(): Promise<MultipartResult> {
|
|
199
|
+
if (this._filesParsed) return this._filesCache!;
|
|
200
|
+
const contentType = ((this.req.headers['content-type'] as string) || '').toLowerCase();
|
|
201
|
+
if (!contentType.includes('multipart/form-data')) {
|
|
202
|
+
this._filesCache = { fields: {}, files: [] };
|
|
203
|
+
} else {
|
|
204
|
+
this._filesCache = await this._multipartParser.parse(this.req);
|
|
205
|
+
}
|
|
206
|
+
this._filesParsed = true;
|
|
207
|
+
if (!this._bodyParsed) {
|
|
208
|
+
this._bodyCache = this._filesCache.fields;
|
|
209
|
+
this._bodyParsed = true;
|
|
210
|
+
}
|
|
211
|
+
return this._filesCache;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** 获取单个上传文件(第一个匹配 fieldname 的文件) */
|
|
215
|
+
async file(fieldname: string): Promise<UploadedFile | null> {
|
|
216
|
+
const result = await this.files();
|
|
217
|
+
return result.files.find(f => f.fieldname === fieldname) || null;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// ==================== 便捷取值 ====================
|
|
221
|
+
|
|
222
|
+
async post(key: string, defaultValue: unknown = null): Promise<unknown> {
|
|
223
|
+
const body = await this.body();
|
|
224
|
+
if (typeof body === 'object' && body !== null) {
|
|
225
|
+
return key in body ? (body as Record<string, unknown>)[key] : defaultValue;
|
|
226
|
+
}
|
|
227
|
+
return defaultValue;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
async param(key: string, defaultValue: unknown = null): Promise<unknown> {
|
|
231
|
+
const q = this.query();
|
|
232
|
+
if (key in q) return q[key];
|
|
233
|
+
return this.post(key, defaultValue);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
async input(key: string, defaultValue: unknown = null, filter: ((val: unknown) => unknown) | null = null): Promise<unknown> {
|
|
237
|
+
let val = await this.param(key, defaultValue);
|
|
238
|
+
if (filter && typeof filter === 'function') val = filter(val);
|
|
239
|
+
return val;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
header(name: string): string | undefined {
|
|
243
|
+
const key = name.toLowerCase();
|
|
244
|
+
for (const h in this.req.headers) {
|
|
245
|
+
if (h.toLowerCase() === key) return this.req.headers[h] as string;
|
|
246
|
+
}
|
|
247
|
+
return undefined;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
get cookies(): Record<string, string> {
|
|
251
|
+
if (!this._cookiesCache) {
|
|
252
|
+
const cookieHeader = (this.req.headers['cookie'] as string) || '';
|
|
253
|
+
this._cookiesCache = Object.fromEntries(
|
|
254
|
+
cookieHeader.split(';').filter(Boolean).map(c => {
|
|
255
|
+
const eqIdx = c.indexOf('=');
|
|
256
|
+
const key = c.slice(0, eqIdx).trim();
|
|
257
|
+
const rawValue = eqIdx !== -1 ? c.slice(eqIdx + 1).trim() : '';
|
|
258
|
+
// URL 解码,解码失败则使用原始值
|
|
259
|
+
let value = rawValue;
|
|
260
|
+
try { value = decodeURIComponent(rawValue); } catch { /* keep raw */ }
|
|
261
|
+
return [key, value];
|
|
262
|
+
})
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
return this._cookiesCache;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
getCookie(key: string): string | null { return this.cookies[key] || null; }
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
export { BodyParser } from './body-parser.ts';
|
|
272
|
+
export { MultipartParser } from './multipart-parser.ts';
|
|
273
|
+
export type { UploadedFile, MultipartResult } from './multipart-parser.ts';
|
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
import os from 'node:os';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import crypto from 'node:crypto';
|
|
5
|
+
import type { IncomingMessage } from 'node:http';
|
|
6
|
+
import { HttpError } from '../errors/index.ts';
|
|
7
|
+
import { parseLimit } from './body-parser.ts';
|
|
8
|
+
import type { BodyParserConfig } from '../types.ts';
|
|
9
|
+
|
|
10
|
+
/** 上传文件信息 */
|
|
11
|
+
export interface UploadedFile {
|
|
12
|
+
fieldname: string;
|
|
13
|
+
filename: string;
|
|
14
|
+
mimetype: string;
|
|
15
|
+
filepath: string;
|
|
16
|
+
size: number;
|
|
17
|
+
buffer?: Buffer;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** multipart 解析结果 */
|
|
21
|
+
export interface MultipartResult {
|
|
22
|
+
fields: Record<string, string | string[]>;
|
|
23
|
+
files: UploadedFile[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** 内部:单个 part 的解析状态 */
|
|
27
|
+
interface PartContext {
|
|
28
|
+
fieldname: string;
|
|
29
|
+
filename: string | null;
|
|
30
|
+
mimetype: string;
|
|
31
|
+
/** 小文件:buffer chunks;大文件:null(直接写磁盘) */
|
|
32
|
+
chunks: Buffer[] | null;
|
|
33
|
+
size: number;
|
|
34
|
+
/** 大文件的写入流 */
|
|
35
|
+
writeStream: fs.WriteStream | null;
|
|
36
|
+
filepath: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* 流式 MultipartParser:边读取 chunk 边解析 multipart/form-data 请求体。
|
|
41
|
+
*
|
|
42
|
+
* 核心设计:
|
|
43
|
+
* - 状态机驱动,不在内存中缓存完整请求体
|
|
44
|
+
* - 文件数据通过 fs.createWriteStream 流式写入磁盘
|
|
45
|
+
* - 小于 bufferLimit 的文件保留在内存 buffer 中
|
|
46
|
+
* - 使用 Buffer.indexOf 快速扫描 boundary 标记
|
|
47
|
+
* - 超限立即 destroy 连接
|
|
48
|
+
*/
|
|
49
|
+
export class MultipartParser {
|
|
50
|
+
private uploadLimit: number;
|
|
51
|
+
private uploadDir: string;
|
|
52
|
+
private keepExtensions: boolean;
|
|
53
|
+
private bufferLimit: number;
|
|
54
|
+
|
|
55
|
+
constructor(config: BodyParserConfig = {}) {
|
|
56
|
+
this.uploadLimit = parseLimit(config.uploadLimit ?? '10mb');
|
|
57
|
+
this.uploadDir = config.uploadDir || os.tmpdir();
|
|
58
|
+
this.keepExtensions = config.keepExtensions ?? false;
|
|
59
|
+
this.bufferLimit = parseLimit(config.uploadBufferLimit ?? '512kb');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async parse(req: IncomingMessage): Promise<MultipartResult> {
|
|
63
|
+
const contentType = (req.headers['content-type'] as string) || '';
|
|
64
|
+
const boundary = this._extractBoundary(contentType);
|
|
65
|
+
if (!boundary) throw new HttpError(400, 'Invalid multipart content-type: missing boundary');
|
|
66
|
+
|
|
67
|
+
const boundaryStr = '--' + boundary;
|
|
68
|
+
const searchBoundary = Buffer.from('\r\n' + boundaryStr);
|
|
69
|
+
const initialBoundary = Buffer.from(boundaryStr);
|
|
70
|
+
const endMarker = Buffer.from(boundaryStr + '--');
|
|
71
|
+
|
|
72
|
+
const result: MultipartResult = { fields: {}, files: [] };
|
|
73
|
+
|
|
74
|
+
let buffer = Buffer.alloc(0);
|
|
75
|
+
let totalSize = 0;
|
|
76
|
+
let headerStr = '';
|
|
77
|
+
let currentPart: PartContext | null = null;
|
|
78
|
+
let inHeaders = true;
|
|
79
|
+
let initialBoundaryFound = false;
|
|
80
|
+
|
|
81
|
+
// 处理完成后,将 field 写入 result
|
|
82
|
+
const commitField = (fieldname: string, value: string) => {
|
|
83
|
+
const existing = result.fields[fieldname];
|
|
84
|
+
if (existing === undefined) {
|
|
85
|
+
result.fields[fieldname] = value;
|
|
86
|
+
} else if (Array.isArray(existing)) {
|
|
87
|
+
existing.push(value);
|
|
88
|
+
} else {
|
|
89
|
+
result.fields[fieldname] = [existing, value];
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
// 创建 file part 上下文
|
|
94
|
+
const startFilePart = (fieldname: string, filename: string, mimetype: string): PartContext => {
|
|
95
|
+
const ext = this.keepExtensions && filename.includes('.') ? path.extname(filename) : '';
|
|
96
|
+
const tmpName = `tyno_${Date.now()}_${crypto.randomBytes(8).toString('hex')}${ext}`;
|
|
97
|
+
const filepath = path.join(this.uploadDir, tmpName);
|
|
98
|
+
return {
|
|
99
|
+
fieldname,
|
|
100
|
+
filename,
|
|
101
|
+
mimetype,
|
|
102
|
+
chunks: [],
|
|
103
|
+
size: 0,
|
|
104
|
+
writeStream: null,
|
|
105
|
+
filepath
|
|
106
|
+
};
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
// 解析 part 头,返回 { fieldname, filename, mimetype }
|
|
110
|
+
const parsePartHeaders = (headers: string): { fieldname: string; filename: string | null; mimetype: string } => {
|
|
111
|
+
const lines = headers.split('\r\n');
|
|
112
|
+
let fieldname = '';
|
|
113
|
+
let filename: string | null = null;
|
|
114
|
+
let mimetype = 'application/octet-stream';
|
|
115
|
+
|
|
116
|
+
for (const line of lines) {
|
|
117
|
+
const colonIdx = line.indexOf(':');
|
|
118
|
+
if (colonIdx === -1) continue;
|
|
119
|
+
const key = line.slice(0, colonIdx).trim().toLowerCase();
|
|
120
|
+
const value = line.slice(colonIdx + 1).trim();
|
|
121
|
+
|
|
122
|
+
if (key === 'content-disposition') {
|
|
123
|
+
const nameMatch = /name="([^"]*)"/.exec(value);
|
|
124
|
+
if (nameMatch) fieldname = nameMatch[1];
|
|
125
|
+
const fileMatch = /filename="([^"]*)"/.exec(value);
|
|
126
|
+
if (fileMatch) filename = fileMatch[1];
|
|
127
|
+
} else if (key === 'content-type') {
|
|
128
|
+
mimetype = value;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return { fieldname, filename, mimetype };
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
// 从 buffer 中查找搜索标记,返回索引;未找到完整匹配返回 -1;部分匹配(可能跨 chunk)返回 -1
|
|
135
|
+
const findBoundary = (buf: Buffer, search: Buffer, startPos: number): number => {
|
|
136
|
+
return buf.indexOf(search, startPos);
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
// 开始处理流
|
|
140
|
+
for await (const rawChunk of req as AsyncIterable<Buffer>) {
|
|
141
|
+
const chunk = rawChunk as Buffer;
|
|
142
|
+
totalSize += chunk.length;
|
|
143
|
+
if (totalSize > this.uploadLimit) {
|
|
144
|
+
if (currentPart?.writeStream) {
|
|
145
|
+
currentPart.writeStream.close();
|
|
146
|
+
fs.promises.unlink(currentPart.filepath).catch(() => {});
|
|
147
|
+
}
|
|
148
|
+
req.destroy();
|
|
149
|
+
throw new HttpError(413, 'Upload too large');
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
buffer = Buffer.concat([buffer, chunk]);
|
|
153
|
+
|
|
154
|
+
// 如果正在写文件,流式处理
|
|
155
|
+
if (currentPart && currentPart.filename !== null) {
|
|
156
|
+
const part = currentPart;
|
|
157
|
+
// 在 buffer 中查找 boundary
|
|
158
|
+
const bidx = findBoundary(buffer, searchBoundary, 0);
|
|
159
|
+
|
|
160
|
+
if (bidx >= 0) {
|
|
161
|
+
// 找到 boundary:把 boundary 前的数据写入文件
|
|
162
|
+
if (bidx > 0) {
|
|
163
|
+
const fileData = buffer.subarray(0, bidx);
|
|
164
|
+
// 去掉末尾的 \r\n(如果有)
|
|
165
|
+
let cleanLen = fileData.length;
|
|
166
|
+
if (cleanLen >= 2 && fileData[cleanLen - 2] === 0x0d && fileData[cleanLen - 1] === 0x0a) {
|
|
167
|
+
cleanLen -= 2;
|
|
168
|
+
}
|
|
169
|
+
if (cleanLen > 0) {
|
|
170
|
+
if (part.writeStream) {
|
|
171
|
+
part.writeStream.write(fileData.subarray(0, cleanLen));
|
|
172
|
+
} else {
|
|
173
|
+
part.chunks!.push(fileData.subarray(0, cleanLen));
|
|
174
|
+
part.size += cleanLen;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// 完成文件
|
|
180
|
+
if (part.writeStream) {
|
|
181
|
+
part.writeStream.end();
|
|
182
|
+
part.size = part.writeStream.bytesWritten;
|
|
183
|
+
result.files.push({
|
|
184
|
+
fieldname: part.fieldname,
|
|
185
|
+
filename: part.filename || 'unknown',
|
|
186
|
+
mimetype: part.mimetype,
|
|
187
|
+
filepath: part.filepath,
|
|
188
|
+
size: part.size
|
|
189
|
+
});
|
|
190
|
+
} else if (part.chunks && part.chunks.length > 0) {
|
|
191
|
+
const buf = Buffer.concat(part.chunks);
|
|
192
|
+
// 小文件仅保留在内存 buffer 中,不写磁盘
|
|
193
|
+
result.files.push({
|
|
194
|
+
fieldname: part.fieldname,
|
|
195
|
+
filename: part.filename || 'unknown',
|
|
196
|
+
mimetype: part.mimetype,
|
|
197
|
+
filepath: part.filepath,
|
|
198
|
+
size: buf.length,
|
|
199
|
+
buffer: buf
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// 处理 boundary 之后的内容
|
|
204
|
+
const after = buffer.subarray(bidx + searchBoundary.length);
|
|
205
|
+
// 检查是否是结束标记
|
|
206
|
+
if (after.length >= 2 && after[0] === 0x2d && after[1] === 0x2d) {
|
|
207
|
+
// --boundary-- → 结束
|
|
208
|
+
return result;
|
|
209
|
+
}
|
|
210
|
+
// 跳过 \r\n
|
|
211
|
+
let dataStart = 0;
|
|
212
|
+
if (after.length >= 2 && after[0] === 0x0d && after[1] === 0x0a) dataStart = 2;
|
|
213
|
+
buffer = after.subarray(dataStart);
|
|
214
|
+
currentPart = null;
|
|
215
|
+
inHeaders = true;
|
|
216
|
+
continue;
|
|
217
|
+
} else {
|
|
218
|
+
// 未找到 boundary:尽量写入文件,保留尾部以防 boundary 跨 chunk
|
|
219
|
+
const keepLen = searchBoundary.length + 4; // 保留足够检测 boundary+\r\n 的尾部
|
|
220
|
+
if (buffer.length > keepLen) {
|
|
221
|
+
const writeLen = buffer.length - keepLen;
|
|
222
|
+
if (part.writeStream) {
|
|
223
|
+
part.writeStream.write(buffer.subarray(0, writeLen));
|
|
224
|
+
} else {
|
|
225
|
+
part.chunks!.push(buffer.subarray(0, writeLen));
|
|
226
|
+
part.size += writeLen;
|
|
227
|
+
// 如果超过 bufferLimit,切换到磁盘
|
|
228
|
+
if (part.size > this.bufferLimit && !part.writeStream) {
|
|
229
|
+
// 将已有的 chunks 写入磁盘
|
|
230
|
+
const ws = fs.createWriteStream(part.filepath);
|
|
231
|
+
for (const c of part.chunks!) ws.write(c);
|
|
232
|
+
part.writeStream = ws;
|
|
233
|
+
part.chunks = null;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
buffer = buffer.subarray(writeLen);
|
|
237
|
+
}
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// 未在处理文件:扫描 headers 和 field body
|
|
243
|
+
while (buffer.length > 0) {
|
|
244
|
+
if (!initialBoundaryFound) {
|
|
245
|
+
// 查找初始 boundary
|
|
246
|
+
if (buffer.length < initialBoundary.length + 2) break; // 等待更多数据
|
|
247
|
+
if (buffer.indexOf(initialBoundary) === 0) {
|
|
248
|
+
initialBoundaryFound = true;
|
|
249
|
+
// 跳过 boundary 和后面的 \r\n
|
|
250
|
+
let skip = initialBoundary.length;
|
|
251
|
+
if (buffer.length > skip + 1 && buffer[skip] === 0x0d && buffer[skip + 1] === 0x0a) skip += 2;
|
|
252
|
+
buffer = buffer.subarray(skip);
|
|
253
|
+
inHeaders = true;
|
|
254
|
+
continue;
|
|
255
|
+
} else {
|
|
256
|
+
// 非 multipart 数据:不应该发生
|
|
257
|
+
throw new HttpError(400, 'Invalid multipart body: initial boundary not found');
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if (inHeaders) {
|
|
262
|
+
// 查找 headers 结束标记 \r\n\r\n
|
|
263
|
+
const headerEnd = buffer.indexOf('\r\n\r\n');
|
|
264
|
+
if (headerEnd === -1) break; // 等待更多数据
|
|
265
|
+
|
|
266
|
+
headerStr = buffer.subarray(0, headerEnd).toString();
|
|
267
|
+
const { fieldname, filename, mimetype } = parsePartHeaders(headerStr);
|
|
268
|
+
if (!fieldname) throw new HttpError(400, 'Missing field name in multipart part');
|
|
269
|
+
|
|
270
|
+
// 跳过 headers 结束标记 \r\n\r\n,body 从其后开始
|
|
271
|
+
buffer = buffer.subarray(headerEnd + 4);
|
|
272
|
+
|
|
273
|
+
if (filename !== null) {
|
|
274
|
+
// 文件 part
|
|
275
|
+
currentPart = startFilePart(fieldname, filename, mimetype);
|
|
276
|
+
inHeaders = false;
|
|
277
|
+
break; // 文件处理在上面的文件循环中进行
|
|
278
|
+
} else {
|
|
279
|
+
// 字段 part:查找 boundary
|
|
280
|
+
const bidx = findBoundary(buffer, searchBoundary, 0);
|
|
281
|
+
if (bidx >= 0) {
|
|
282
|
+
const fieldData = buffer.subarray(0, bidx);
|
|
283
|
+
// 去掉末尾的 \r\n
|
|
284
|
+
let cleanLen = fieldData.length;
|
|
285
|
+
if (cleanLen >= 2 && fieldData[cleanLen - 2] === 0x0d && fieldData[cleanLen - 1] === 0x0a) {
|
|
286
|
+
cleanLen -= 2;
|
|
287
|
+
}
|
|
288
|
+
const value = fieldData.subarray(0, cleanLen).toString();
|
|
289
|
+
commitField(fieldname, value);
|
|
290
|
+
|
|
291
|
+
const after = buffer.subarray(bidx + searchBoundary.length);
|
|
292
|
+
if (after.length >= 2 && after[0] === 0x2d && after[1] === 0x2d) {
|
|
293
|
+
return result;
|
|
294
|
+
}
|
|
295
|
+
let nextStart = 0;
|
|
296
|
+
if (after.length >= 2 && after[0] === 0x0d && after[1] === 0x0a) nextStart = 2;
|
|
297
|
+
buffer = after.subarray(nextStart);
|
|
298
|
+
inHeaders = true;
|
|
299
|
+
continue;
|
|
300
|
+
} else {
|
|
301
|
+
break; // 等待更多数据
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// 流结束:处理未完成的部分
|
|
309
|
+
if (currentPart && currentPart.filename !== null) {
|
|
310
|
+
// 在剩余 buffer 中查找 boundary
|
|
311
|
+
const bidx = buffer.indexOf(searchBoundary);
|
|
312
|
+
let fileData: Buffer;
|
|
313
|
+
if (bidx >= 0) {
|
|
314
|
+
// 找到 boundary:提取 boundary 之前的文件数据
|
|
315
|
+
fileData = buffer.subarray(0, bidx);
|
|
316
|
+
} else {
|
|
317
|
+
// 未找到 boundary(异常情况):使用全部数据,去掉末尾可能的 \r\n
|
|
318
|
+
fileData = buffer;
|
|
319
|
+
}
|
|
320
|
+
// 去掉末尾的 \r\n
|
|
321
|
+
let cleanLen = fileData.length;
|
|
322
|
+
if (cleanLen >= 2 && fileData[cleanLen - 2] === 0x0d && fileData[cleanLen - 1] === 0x0a) {
|
|
323
|
+
cleanLen -= 2;
|
|
324
|
+
}
|
|
325
|
+
if (cleanLen > 0) {
|
|
326
|
+
if (currentPart.writeStream) {
|
|
327
|
+
currentPart.writeStream.write(fileData.subarray(0, cleanLen));
|
|
328
|
+
currentPart.writeStream.end();
|
|
329
|
+
currentPart.size = currentPart.writeStream.bytesWritten;
|
|
330
|
+
result.files.push({
|
|
331
|
+
fieldname: currentPart.fieldname,
|
|
332
|
+
filename: currentPart.filename || 'unknown',
|
|
333
|
+
mimetype: currentPart.mimetype,
|
|
334
|
+
filepath: currentPart.filepath,
|
|
335
|
+
size: currentPart.size
|
|
336
|
+
});
|
|
337
|
+
} else if (currentPart.chunks) {
|
|
338
|
+
currentPart.chunks.push(fileData.subarray(0, cleanLen));
|
|
339
|
+
const buf = Buffer.concat(currentPart.chunks);
|
|
340
|
+
// 小文件仅保留在内存 buffer 中,不写磁盘
|
|
341
|
+
result.files.push({
|
|
342
|
+
fieldname: currentPart.fieldname,
|
|
343
|
+
filename: currentPart.filename || 'unknown',
|
|
344
|
+
mimetype: currentPart.mimetype,
|
|
345
|
+
filepath: currentPart.filepath,
|
|
346
|
+
size: buf.length,
|
|
347
|
+
buffer: buf
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
return result;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
private _extractBoundary(contentType: string): string | null {
|
|
357
|
+
const m = /boundary=(?:"([^"]+)"|([^;]+))/i.exec(contentType);
|
|
358
|
+
return m ? (m[1] || m[2]).trim() : null;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { requestStore } from './context.ts';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 全局门面 Request 对象。
|
|
5
|
+
* 在任何地方直接使用 `request`,其背后会自动从 AsyncLocalStorage 获取当前请求的 Request Proxy。
|
|
6
|
+
*/
|
|
7
|
+
const requestProxy = new Proxy({} as Record<string, unknown>, {
|
|
8
|
+
get(_target, prop: string | symbol): unknown {
|
|
9
|
+
const req = requestStore.getStore();
|
|
10
|
+
if (!req) {
|
|
11
|
+
throw new Error(
|
|
12
|
+
'No active request context. Make sure you are inside an async middleware or the request lifecycle.'
|
|
13
|
+
);
|
|
14
|
+
}
|
|
15
|
+
return (req as unknown as Record<string | symbol, unknown>)[prop];
|
|
16
|
+
},
|
|
17
|
+
set(_target, prop: string | symbol, value: unknown): boolean {
|
|
18
|
+
const req = requestStore.getStore();
|
|
19
|
+
if (!req) throw new Error('No active request context');
|
|
20
|
+
(req as unknown as Record<string | symbol, unknown>)[prop] = value;
|
|
21
|
+
return true;
|
|
22
|
+
},
|
|
23
|
+
deleteProperty(_target, prop: string | symbol): boolean {
|
|
24
|
+
const req = requestStore.getStore();
|
|
25
|
+
if (!req) throw new Error('No active request context');
|
|
26
|
+
delete (req as unknown as Record<string | symbol, unknown>)[prop];
|
|
27
|
+
return true;
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
export default requestProxy;
|