@morojs/moro 1.7.27 → 1.7.29
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/dist/core/config/config-sources.js +62 -4
- package/dist/core/config/config-sources.js.map +1 -1
- package/dist/core/config/config-validator.js +26 -0
- package/dist/core/config/config-validator.js.map +1 -1
- package/dist/core/config/schema.js +11 -1
- package/dist/core/config/schema.js.map +1 -1
- package/dist/core/framework.d.ts +0 -1
- package/dist/core/framework.js +14 -25
- package/dist/core/framework.js.map +1 -1
- package/dist/core/http/http-server.d.ts +124 -12
- package/dist/core/http/http-server.js +1067 -833
- package/dist/core/http/http-server.js.map +1 -1
- package/dist/core/http/uws-http-server.d.ts +72 -5
- package/dist/core/http/uws-http-server.js +718 -150
- package/dist/core/http/uws-http-server.js.map +1 -1
- package/dist/core/logger/logger.d.ts +9 -2
- package/dist/core/logger/logger.js +114 -15
- package/dist/core/logger/logger.js.map +1 -1
- package/dist/core/middleware/built-in/compression/core.js +47 -29
- package/dist/core/middleware/built-in/compression/core.js.map +1 -1
- package/dist/core/middleware/built-in/error-tracker/middleware.d.ts +5 -2
- package/dist/core/middleware/built-in/error-tracker/middleware.js +13 -11
- package/dist/core/middleware/built-in/error-tracker/middleware.js.map +1 -1
- package/dist/core/middleware/built-in/index.d.ts +3 -3
- package/dist/core/middleware/built-in/performance-monitor/middleware.d.ts +2 -1
- package/dist/core/middleware/built-in/performance-monitor/middleware.js +7 -6
- package/dist/core/middleware/built-in/performance-monitor/middleware.js.map +1 -1
- package/dist/core/middleware/built-in/request-logger/middleware.d.ts +2 -1
- package/dist/core/middleware/built-in/request-logger/middleware.js +5 -5
- package/dist/core/middleware/built-in/request-logger/middleware.js.map +1 -1
- package/dist/core/middleware/built-in/validation/core.js +61 -27
- package/dist/core/middleware/built-in/validation/core.js.map +1 -1
- package/dist/core/routing/radix-tree.d.ts +1 -0
- package/dist/core/routing/radix-tree.js +48 -10
- package/dist/core/routing/radix-tree.js.map +1 -1
- package/dist/core/routing/unified-router.d.ts +16 -0
- package/dist/core/routing/unified-router.js +39 -12
- package/dist/core/routing/unified-router.js.map +1 -1
- package/dist/core/utilities/hooks.d.ts +2 -0
- package/dist/core/utilities/hooks.js +12 -0
- package/dist/core/utilities/hooks.js.map +1 -1
- package/dist/moro.d.ts +4 -0
- package/dist/moro.js +94 -70
- package/dist/moro.js.map +1 -1
- package/dist/types/config.d.ts +7 -0
- package/package.json +1 -1
|
@@ -1,25 +1,889 @@
|
|
|
1
1
|
// src/core/http-server.ts
|
|
2
|
-
import { createServer } from 'http';
|
|
2
|
+
import { IncomingMessage, ServerResponse, createServer } from 'http';
|
|
3
3
|
import * as zlib from 'zlib';
|
|
4
4
|
import { promisify } from 'util';
|
|
5
|
+
import { randomUUID } from 'crypto';
|
|
5
6
|
import { createFrameworkLogger } from '../logger/index.js';
|
|
6
7
|
import { PathMatcher } from '../routing/path-matcher.js';
|
|
7
8
|
import { ObjectPoolManager } from '../pooling/object-pool-manager.js';
|
|
8
9
|
const gzip = promisify(zlib.gzip);
|
|
9
10
|
const deflate = promisify(zlib.deflate);
|
|
11
|
+
const JSON_CONTENT_TYPE = 'application/json; charset=utf-8';
|
|
12
|
+
const STATUS_MESSAGES = {
|
|
13
|
+
200: 'OK',
|
|
14
|
+
201: 'Created',
|
|
15
|
+
202: 'Accepted',
|
|
16
|
+
204: 'No Content',
|
|
17
|
+
301: 'Moved Permanently',
|
|
18
|
+
302: 'Found',
|
|
19
|
+
304: 'Not Modified',
|
|
20
|
+
400: 'Bad Request',
|
|
21
|
+
401: 'Unauthorized',
|
|
22
|
+
403: 'Forbidden',
|
|
23
|
+
404: 'Not Found',
|
|
24
|
+
409: 'Conflict',
|
|
25
|
+
410: 'Gone',
|
|
26
|
+
422: 'Unprocessable Entity',
|
|
27
|
+
429: 'Too Many Requests',
|
|
28
|
+
500: 'Internal Server Error',
|
|
29
|
+
502: 'Bad Gateway',
|
|
30
|
+
503: 'Service Unavailable',
|
|
31
|
+
};
|
|
32
|
+
const TYPE_SHORTHANDS = {
|
|
33
|
+
json: 'application/json; charset=utf-8',
|
|
34
|
+
html: 'text/html; charset=utf-8',
|
|
35
|
+
text: 'text/plain; charset=utf-8',
|
|
36
|
+
txt: 'text/plain; charset=utf-8',
|
|
37
|
+
xml: 'application/xml',
|
|
38
|
+
js: 'application/javascript',
|
|
39
|
+
css: 'text/css; charset=utf-8',
|
|
40
|
+
form: 'application/x-www-form-urlencoded',
|
|
41
|
+
};
|
|
42
|
+
function parseQueryString(queryString) {
|
|
43
|
+
const result = {};
|
|
44
|
+
if (!queryString)
|
|
45
|
+
return result;
|
|
46
|
+
const pairs = queryString.split('&');
|
|
47
|
+
const pairsLen = pairs.length;
|
|
48
|
+
for (let i = 0; i < pairsLen; i++) {
|
|
49
|
+
const pair = pairs[i];
|
|
50
|
+
const equalIndex = pair.indexOf('=');
|
|
51
|
+
if (equalIndex === -1) {
|
|
52
|
+
result[decodeURIComponent(pair)] = '';
|
|
53
|
+
}
|
|
54
|
+
else {
|
|
55
|
+
const key = decodeURIComponent(pair.substring(0, equalIndex));
|
|
56
|
+
const value = decodeURIComponent(pair.substring(equalIndex + 1));
|
|
57
|
+
result[key] = value;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return result;
|
|
61
|
+
}
|
|
62
|
+
function parseCookieHeader(cookieHeader) {
|
|
63
|
+
const cookies = {};
|
|
64
|
+
if (!cookieHeader)
|
|
65
|
+
return cookies;
|
|
66
|
+
const cookieParts = cookieHeader.split(';');
|
|
67
|
+
const cookiePartsLen = cookieParts.length;
|
|
68
|
+
for (let i = 0; i < cookiePartsLen; i++) {
|
|
69
|
+
const cookie = cookieParts[i];
|
|
70
|
+
const equalIndex = cookie.indexOf('=');
|
|
71
|
+
if (equalIndex > 0) {
|
|
72
|
+
const name = cookie.substring(0, equalIndex).trim();
|
|
73
|
+
const value = cookie.substring(equalIndex + 1);
|
|
74
|
+
if (name && value) {
|
|
75
|
+
try {
|
|
76
|
+
cookies[name] = decodeURIComponent(value);
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
// Malformed percent-encoding: keep the raw value rather than throwing a 500
|
|
80
|
+
cookies[name] = value;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return cookies;
|
|
86
|
+
}
|
|
87
|
+
// ---------------------------------------------------------------------------
|
|
88
|
+
// Prototype-based request/response enhancement.
|
|
89
|
+
//
|
|
90
|
+
// All helper methods live on shared prototypes (defined once per process)
|
|
91
|
+
// instead of being re-created as ~40 closures on every request, and derived
|
|
92
|
+
// request fields (query, cookies, hostname, ips, ...) are lazy memoized
|
|
93
|
+
// getters so a request only pays for what its handler actually reads.
|
|
94
|
+
// Each MoroHttpServer binds per-instance subclasses whose prototype carries
|
|
95
|
+
// the `_server` back-reference, so methods can reach server config (logger,
|
|
96
|
+
// compression settings) without any per-request allocation.
|
|
97
|
+
// ---------------------------------------------------------------------------
|
|
98
|
+
export class MoroIncomingMessage extends IncomingMessage {
|
|
99
|
+
// Hot per-request fields (assigned in handleRequest; declared here for stable shape)
|
|
100
|
+
params = {};
|
|
101
|
+
body = null;
|
|
102
|
+
path = '';
|
|
103
|
+
// Lazy backing fields - single hidden-class shape, created at construction
|
|
104
|
+
_queryString = null;
|
|
105
|
+
_query = undefined;
|
|
106
|
+
_cookies = undefined;
|
|
107
|
+
_context = undefined;
|
|
108
|
+
_requestId = undefined;
|
|
109
|
+
_ip = undefined;
|
|
110
|
+
_originalUrl = undefined;
|
|
111
|
+
_hostname = undefined;
|
|
112
|
+
_protocol = undefined;
|
|
113
|
+
_secure = undefined;
|
|
114
|
+
_xhr = undefined;
|
|
115
|
+
_ips = undefined;
|
|
116
|
+
_subdomains = undefined;
|
|
117
|
+
get query() {
|
|
118
|
+
let q = this._query;
|
|
119
|
+
if (q === undefined) {
|
|
120
|
+
const qs = this._queryString;
|
|
121
|
+
q = qs ? parseQueryString(qs) : {};
|
|
122
|
+
this._query = q;
|
|
123
|
+
}
|
|
124
|
+
return q;
|
|
125
|
+
}
|
|
126
|
+
set query(value) {
|
|
127
|
+
this._query = value;
|
|
128
|
+
}
|
|
129
|
+
get cookies() {
|
|
130
|
+
let c = this._cookies;
|
|
131
|
+
if (c === undefined) {
|
|
132
|
+
const header = this.headers.cookie;
|
|
133
|
+
c = header ? parseCookieHeader(header) : {};
|
|
134
|
+
this._cookies = c;
|
|
135
|
+
}
|
|
136
|
+
return c;
|
|
137
|
+
}
|
|
138
|
+
set cookies(value) {
|
|
139
|
+
this._cookies = value;
|
|
140
|
+
}
|
|
141
|
+
get context() {
|
|
142
|
+
let ctx = this._context;
|
|
143
|
+
if (ctx === undefined) {
|
|
144
|
+
ctx = {};
|
|
145
|
+
this._context = ctx;
|
|
146
|
+
}
|
|
147
|
+
return ctx;
|
|
148
|
+
}
|
|
149
|
+
set context(value) {
|
|
150
|
+
this._context = value;
|
|
151
|
+
}
|
|
152
|
+
get requestId() {
|
|
153
|
+
let id = this._requestId;
|
|
154
|
+
if (id === undefined) {
|
|
155
|
+
const server = this._server;
|
|
156
|
+
id = server && server.requestTrackingEnabled ? randomUUID() : '';
|
|
157
|
+
this._requestId = id;
|
|
158
|
+
}
|
|
159
|
+
return id;
|
|
160
|
+
}
|
|
161
|
+
set requestId(value) {
|
|
162
|
+
this._requestId = value;
|
|
163
|
+
}
|
|
164
|
+
get ip() {
|
|
165
|
+
let v = this._ip;
|
|
166
|
+
if (v === undefined) {
|
|
167
|
+
v = (this.socket && this.socket.remoteAddress) || '';
|
|
168
|
+
this._ip = v;
|
|
169
|
+
}
|
|
170
|
+
return v;
|
|
171
|
+
}
|
|
172
|
+
set ip(value) {
|
|
173
|
+
this._ip = value;
|
|
174
|
+
}
|
|
175
|
+
get originalUrl() {
|
|
176
|
+
const v = this._originalUrl;
|
|
177
|
+
return v !== undefined ? v : this.url || '';
|
|
178
|
+
}
|
|
179
|
+
set originalUrl(value) {
|
|
180
|
+
this._originalUrl = value;
|
|
181
|
+
}
|
|
182
|
+
get hostname() {
|
|
183
|
+
let v = this._hostname;
|
|
184
|
+
if (v === undefined) {
|
|
185
|
+
const host = (this.headers.host || '');
|
|
186
|
+
v = host ? host.split(':')[0] : '';
|
|
187
|
+
this._hostname = v;
|
|
188
|
+
}
|
|
189
|
+
return v;
|
|
190
|
+
}
|
|
191
|
+
set hostname(value) {
|
|
192
|
+
this._hostname = value;
|
|
193
|
+
}
|
|
194
|
+
get protocol() {
|
|
195
|
+
let v = this._protocol;
|
|
196
|
+
if (v === undefined) {
|
|
197
|
+
const forwardedProto = this.headers['x-forwarded-proto'];
|
|
198
|
+
v = forwardedProto
|
|
199
|
+
? forwardedProto.split(',')[0].trim()
|
|
200
|
+
: this.socket?.encrypted
|
|
201
|
+
? 'https'
|
|
202
|
+
: 'http';
|
|
203
|
+
this._protocol = v;
|
|
204
|
+
}
|
|
205
|
+
return v;
|
|
206
|
+
}
|
|
207
|
+
set protocol(value) {
|
|
208
|
+
this._protocol = value;
|
|
209
|
+
}
|
|
210
|
+
get secure() {
|
|
211
|
+
const v = this._secure;
|
|
212
|
+
return v !== undefined ? v : this.protocol === 'https';
|
|
213
|
+
}
|
|
214
|
+
set secure(value) {
|
|
215
|
+
this._secure = value;
|
|
216
|
+
}
|
|
217
|
+
get xhr() {
|
|
218
|
+
let v = this._xhr;
|
|
219
|
+
if (v === undefined) {
|
|
220
|
+
const xrw = this.headers['x-requested-with'];
|
|
221
|
+
v = !!xrw && xrw.toLowerCase() === 'xmlhttprequest';
|
|
222
|
+
this._xhr = v;
|
|
223
|
+
}
|
|
224
|
+
return v;
|
|
225
|
+
}
|
|
226
|
+
set xhr(value) {
|
|
227
|
+
this._xhr = value;
|
|
228
|
+
}
|
|
229
|
+
get ips() {
|
|
230
|
+
let v = this._ips;
|
|
231
|
+
if (v === undefined) {
|
|
232
|
+
const forwardedFor = this.headers['x-forwarded-for'];
|
|
233
|
+
v = forwardedFor
|
|
234
|
+
? forwardedFor
|
|
235
|
+
.split(',')
|
|
236
|
+
.map(s => s.trim())
|
|
237
|
+
.filter(Boolean)
|
|
238
|
+
: [];
|
|
239
|
+
this._ips = v;
|
|
240
|
+
}
|
|
241
|
+
return v;
|
|
242
|
+
}
|
|
243
|
+
set ips(value) {
|
|
244
|
+
this._ips = value;
|
|
245
|
+
}
|
|
246
|
+
get subdomains() {
|
|
247
|
+
let v = this._subdomains;
|
|
248
|
+
if (v === undefined) {
|
|
249
|
+
const hostnameParts = this.hostname.split('.');
|
|
250
|
+
// Matches Express: subdomains are parts before the last two (domain + tld),
|
|
251
|
+
// reversed (closest subdomain first).
|
|
252
|
+
v = hostnameParts.length > 2 ? hostnameParts.slice(0, -2).reverse() : [];
|
|
253
|
+
this._subdomains = v;
|
|
254
|
+
}
|
|
255
|
+
return v;
|
|
256
|
+
}
|
|
257
|
+
set subdomains(value) {
|
|
258
|
+
this._subdomains = value;
|
|
259
|
+
}
|
|
260
|
+
// Express-compatible request helpers (shared prototype methods, not closures)
|
|
261
|
+
get(name) {
|
|
262
|
+
const lower = name.toLowerCase();
|
|
263
|
+
if (lower === 'referer' || lower === 'referrer') {
|
|
264
|
+
return (this.headers.referer || this.headers.referrer);
|
|
265
|
+
}
|
|
266
|
+
const v = this.headers[lower];
|
|
267
|
+
return Array.isArray(v) ? v[0] : v;
|
|
268
|
+
}
|
|
269
|
+
header(name) {
|
|
270
|
+
return this.get(name);
|
|
271
|
+
}
|
|
272
|
+
is(type) {
|
|
273
|
+
const ct = (this.headers['content-type'] || '');
|
|
274
|
+
if (!ct)
|
|
275
|
+
return false;
|
|
276
|
+
const mime = ct.split(';')[0].trim().toLowerCase();
|
|
277
|
+
const t = type.toLowerCase();
|
|
278
|
+
if (t.indexOf('/') === -1) {
|
|
279
|
+
// e.g. 'json' → match */json or +json suffix
|
|
280
|
+
return mime.endsWith(`/${t}`) || mime.endsWith(`+${t}`);
|
|
281
|
+
}
|
|
282
|
+
if (t.endsWith('/*')) {
|
|
283
|
+
return mime.startsWith(t.slice(0, -1));
|
|
284
|
+
}
|
|
285
|
+
return mime === t;
|
|
286
|
+
}
|
|
287
|
+
accepts(types) {
|
|
288
|
+
const accept = (this.headers.accept || '*/*');
|
|
289
|
+
if (!types)
|
|
290
|
+
return accept;
|
|
291
|
+
const wanted = Array.isArray(types) ? types : [types];
|
|
292
|
+
if (accept === '*/*' || accept === '')
|
|
293
|
+
return wanted[0] || false;
|
|
294
|
+
const acceptTypes = accept.split(',').map(s => s.split(';')[0].trim().toLowerCase());
|
|
295
|
+
for (const w of wanted) {
|
|
296
|
+
const wl = w.toLowerCase();
|
|
297
|
+
const wMime = wl.indexOf('/') === -1 ? `application/${wl}` : wl;
|
|
298
|
+
for (const at of acceptTypes) {
|
|
299
|
+
if (at === '*/*')
|
|
300
|
+
return w;
|
|
301
|
+
if (at === wMime)
|
|
302
|
+
return w;
|
|
303
|
+
if (at.endsWith('/*') && wMime.startsWith(at.slice(0, -1)))
|
|
304
|
+
return w;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
return false;
|
|
308
|
+
}
|
|
309
|
+
acceptsLanguages(langs) {
|
|
310
|
+
const acceptLang = (this.headers['accept-language'] || '');
|
|
311
|
+
if (!langs)
|
|
312
|
+
return acceptLang || false;
|
|
313
|
+
const wanted = Array.isArray(langs) ? langs : [langs];
|
|
314
|
+
if (!acceptLang)
|
|
315
|
+
return wanted[0] || false;
|
|
316
|
+
// Parse with q values; sort by q desc to match Express's "best match wins"
|
|
317
|
+
const accepted = acceptLang
|
|
318
|
+
.split(',')
|
|
319
|
+
.map(s => {
|
|
320
|
+
const parts = s.trim().split(';');
|
|
321
|
+
const tag = parts[0].toLowerCase();
|
|
322
|
+
const qPart = parts.find(p => p.trim().startsWith('q='));
|
|
323
|
+
const q = qPart ? parseFloat(qPart.trim().slice(2)) : 1;
|
|
324
|
+
return { tag, q: isNaN(q) ? 0 : q };
|
|
325
|
+
})
|
|
326
|
+
.filter(a => a.q > 0)
|
|
327
|
+
.sort((a, b) => b.q - a.q);
|
|
328
|
+
for (const { tag } of accepted) {
|
|
329
|
+
for (const w of wanted) {
|
|
330
|
+
const wl = w.toLowerCase();
|
|
331
|
+
if (tag === '*' || tag === wl || tag.split('-')[0] === wl.split('-')[0])
|
|
332
|
+
return w;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
return false;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
export class MoroServerResponse extends ServerResponse {
|
|
339
|
+
_locals = undefined;
|
|
340
|
+
get locals() {
|
|
341
|
+
let l = this._locals;
|
|
342
|
+
if (l === undefined) {
|
|
343
|
+
l = {};
|
|
344
|
+
this._locals = l;
|
|
345
|
+
}
|
|
346
|
+
return l;
|
|
347
|
+
}
|
|
348
|
+
set locals(value) {
|
|
349
|
+
this._locals = value;
|
|
350
|
+
}
|
|
351
|
+
status(code) {
|
|
352
|
+
this.statusCode = code;
|
|
353
|
+
return this;
|
|
354
|
+
}
|
|
355
|
+
json(data) {
|
|
356
|
+
if (this.headersSent)
|
|
357
|
+
return;
|
|
358
|
+
const jsonString = JSON.stringify(data);
|
|
359
|
+
const server = this._server;
|
|
360
|
+
// Compression - EARLY EXIT if disabled or below threshold
|
|
361
|
+
if (server && server.compressionEnabled && jsonString.length > server.compressionThreshold) {
|
|
362
|
+
const acceptEncoding = this.req?.headers['accept-encoding'];
|
|
363
|
+
if (acceptEncoding && acceptEncoding.includes('gzip')) {
|
|
364
|
+
const buffer = Buffer.from(jsonString, 'utf8');
|
|
365
|
+
gzip(buffer)
|
|
366
|
+
.then(compressed => {
|
|
367
|
+
if (this.headersSent)
|
|
368
|
+
return;
|
|
369
|
+
this.writeHead(this.statusCode || 200, {
|
|
370
|
+
'Content-Type': JSON_CONTENT_TYPE,
|
|
371
|
+
'Content-Encoding': 'gzip',
|
|
372
|
+
Vary: 'Accept-Encoding',
|
|
373
|
+
'Content-Length': compressed.length,
|
|
374
|
+
});
|
|
375
|
+
this.end(compressed);
|
|
376
|
+
})
|
|
377
|
+
.catch(err => {
|
|
378
|
+
server.logger.error('Response compression failed', 'HttpServer', {
|
|
379
|
+
error: err instanceof Error ? err.message : String(err),
|
|
380
|
+
});
|
|
381
|
+
if (!this.headersSent) {
|
|
382
|
+
this.writeHead(this.statusCode || 200, {
|
|
383
|
+
'Content-Type': JSON_CONTENT_TYPE,
|
|
384
|
+
'Content-Length': Buffer.byteLength(jsonString),
|
|
385
|
+
});
|
|
386
|
+
this.end(jsonString);
|
|
387
|
+
}
|
|
388
|
+
else {
|
|
389
|
+
this.end();
|
|
390
|
+
}
|
|
391
|
+
});
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
else if (acceptEncoding && acceptEncoding.includes('deflate')) {
|
|
395
|
+
const buffer = Buffer.from(jsonString, 'utf8');
|
|
396
|
+
deflate(buffer)
|
|
397
|
+
.then(compressed => {
|
|
398
|
+
if (this.headersSent)
|
|
399
|
+
return;
|
|
400
|
+
this.writeHead(this.statusCode || 200, {
|
|
401
|
+
'Content-Type': JSON_CONTENT_TYPE,
|
|
402
|
+
'Content-Encoding': 'deflate',
|
|
403
|
+
Vary: 'Accept-Encoding',
|
|
404
|
+
'Content-Length': compressed.length,
|
|
405
|
+
});
|
|
406
|
+
this.end(compressed);
|
|
407
|
+
})
|
|
408
|
+
.catch(err => {
|
|
409
|
+
server.logger.error('Response compression failed', 'HttpServer', {
|
|
410
|
+
error: err instanceof Error ? err.message : String(err),
|
|
411
|
+
});
|
|
412
|
+
if (!this.headersSent) {
|
|
413
|
+
this.writeHead(this.statusCode || 200, {
|
|
414
|
+
'Content-Type': JSON_CONTENT_TYPE,
|
|
415
|
+
'Content-Length': Buffer.byteLength(jsonString),
|
|
416
|
+
});
|
|
417
|
+
this.end(jsonString);
|
|
418
|
+
}
|
|
419
|
+
else {
|
|
420
|
+
this.end();
|
|
421
|
+
}
|
|
422
|
+
});
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
// SYNC PATH - no compression: single serialize, single write, no Buffer copy
|
|
427
|
+
this.writeHead(this.statusCode || 200, {
|
|
428
|
+
'Content-Type': JSON_CONTENT_TYPE,
|
|
429
|
+
'Content-Length': Buffer.byteLength(jsonString),
|
|
430
|
+
});
|
|
431
|
+
this.end(jsonString);
|
|
432
|
+
}
|
|
433
|
+
send(data) {
|
|
434
|
+
if (this.headersSent)
|
|
435
|
+
return;
|
|
436
|
+
// Auto-detect content type if not already set
|
|
437
|
+
if (!this.getHeader('Content-Type')) {
|
|
438
|
+
if (typeof data === 'string') {
|
|
439
|
+
// Cheap JSON sniff: first non-whitespace char is '{', '[' or '"'
|
|
440
|
+
// (replaces a full JSON.parse of the body just to pick a content type)
|
|
441
|
+
let i = 0;
|
|
442
|
+
const len = data.length;
|
|
443
|
+
while (i < len) {
|
|
444
|
+
const c = data.charCodeAt(i);
|
|
445
|
+
if (c !== 32 && c !== 9 && c !== 10 && c !== 13)
|
|
446
|
+
break;
|
|
447
|
+
i++;
|
|
448
|
+
}
|
|
449
|
+
const first = i < len ? data.charCodeAt(i) : 0;
|
|
450
|
+
if (first === 123 /* { */ || first === 91 /* [ */ || first === 34 /* " */) {
|
|
451
|
+
this.setHeader('Content-Type', JSON_CONTENT_TYPE);
|
|
452
|
+
}
|
|
453
|
+
else {
|
|
454
|
+
this.setHeader('Content-Type', 'text/plain; charset=utf-8');
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
else {
|
|
458
|
+
this.setHeader('Content-Type', 'application/octet-stream');
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
this.end(data);
|
|
462
|
+
}
|
|
463
|
+
cookie(name, value, options = {}) {
|
|
464
|
+
if (this.headersSent) {
|
|
465
|
+
const isCritical = options.critical ||
|
|
466
|
+
name.includes('session') ||
|
|
467
|
+
name.includes('auth') ||
|
|
468
|
+
name.includes('csrf');
|
|
469
|
+
const message = `Cookie '${name}' could not be set - headers already sent`;
|
|
470
|
+
if (isCritical || options.throwOnLateSet) {
|
|
471
|
+
throw new Error(`${message}. This may cause authentication or security issues.`);
|
|
472
|
+
}
|
|
473
|
+
else {
|
|
474
|
+
this._server?.logger.warn(message, 'CookieWarning', {
|
|
475
|
+
cookieName: name,
|
|
476
|
+
critical: isCritical,
|
|
477
|
+
stackTrace: new Error().stack,
|
|
478
|
+
});
|
|
479
|
+
}
|
|
480
|
+
return this;
|
|
481
|
+
}
|
|
482
|
+
const cookieValue = encodeURIComponent(value);
|
|
483
|
+
let cookieString = `${name}=${cookieValue}`;
|
|
484
|
+
if (options.maxAge)
|
|
485
|
+
cookieString += `; Max-Age=${options.maxAge}`;
|
|
486
|
+
if (options.expires)
|
|
487
|
+
cookieString += `; Expires=${options.expires.toUTCString()}`;
|
|
488
|
+
if (options.httpOnly)
|
|
489
|
+
cookieString += '; HttpOnly';
|
|
490
|
+
if (options.secure)
|
|
491
|
+
cookieString += '; Secure';
|
|
492
|
+
if (options.sameSite)
|
|
493
|
+
cookieString += `; SameSite=${options.sameSite}`;
|
|
494
|
+
if (options.domain)
|
|
495
|
+
cookieString += `; Domain=${options.domain}`;
|
|
496
|
+
if (options.path)
|
|
497
|
+
cookieString += `; Path=${options.path}`;
|
|
498
|
+
const existingCookies = this.getHeader('Set-Cookie') || [];
|
|
499
|
+
// Avoid spread operator - direct array manipulation
|
|
500
|
+
const cookies = Array.isArray(existingCookies) ? existingCookies : [existingCookies];
|
|
501
|
+
cookies.push(cookieString);
|
|
502
|
+
this.setHeader('Set-Cookie', cookies);
|
|
503
|
+
return this;
|
|
504
|
+
}
|
|
505
|
+
clearCookie(name, options = {}) {
|
|
506
|
+
// Avoid spread operator - manually set properties
|
|
507
|
+
const clearOptions = {
|
|
508
|
+
expires: new Date(0),
|
|
509
|
+
maxAge: 0,
|
|
510
|
+
};
|
|
511
|
+
// Copy other options manually
|
|
512
|
+
if (options.path !== undefined)
|
|
513
|
+
clearOptions.path = options.path;
|
|
514
|
+
if (options.domain !== undefined)
|
|
515
|
+
clearOptions.domain = options.domain;
|
|
516
|
+
if (options.httpOnly !== undefined)
|
|
517
|
+
clearOptions.httpOnly = options.httpOnly;
|
|
518
|
+
if (options.secure !== undefined)
|
|
519
|
+
clearOptions.secure = options.secure;
|
|
520
|
+
if (options.sameSite !== undefined)
|
|
521
|
+
clearOptions.sameSite = options.sameSite;
|
|
522
|
+
return this.cookie(name, '', clearOptions);
|
|
523
|
+
}
|
|
524
|
+
redirect(url, status = 302) {
|
|
525
|
+
if (this.headersSent)
|
|
526
|
+
return;
|
|
527
|
+
this.statusCode = status;
|
|
528
|
+
const safeUrl = url.replace(/[\r\n]/g, '');
|
|
529
|
+
this.setHeader('Location', safeUrl);
|
|
530
|
+
this.end();
|
|
531
|
+
}
|
|
532
|
+
async sendFile(filePath) {
|
|
533
|
+
if (this.headersSent)
|
|
534
|
+
return;
|
|
535
|
+
try {
|
|
536
|
+
const fs = await import('fs/promises');
|
|
537
|
+
const path = await import('path');
|
|
538
|
+
const extension = path.extname(filePath);
|
|
539
|
+
const mime = await this._server.getMimeType(extension);
|
|
540
|
+
const stats = await fs.stat(filePath);
|
|
541
|
+
const data = await fs.readFile(filePath);
|
|
542
|
+
// Add charset for text-based files
|
|
543
|
+
const contentType = this._server.addCharsetIfNeeded(mime);
|
|
544
|
+
this.setHeader('Content-Type', contentType);
|
|
545
|
+
this.setHeader('Content-Length', stats.size);
|
|
546
|
+
// Add security headers for file downloads
|
|
547
|
+
this.setHeader('X-Content-Type-Options', 'nosniff');
|
|
548
|
+
// Add caching headers
|
|
549
|
+
this.setHeader('Last-Modified', stats.mtime.toUTCString());
|
|
550
|
+
this.setHeader('Cache-Control', 'public, max-age=31536000'); // 1 year for static files
|
|
551
|
+
this.end(data);
|
|
552
|
+
}
|
|
553
|
+
catch {
|
|
554
|
+
this.status(404).json({ success: false, error: 'File not found' });
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
// Standardized response helpers
|
|
558
|
+
success(data, message) {
|
|
559
|
+
const response = {
|
|
560
|
+
success: true,
|
|
561
|
+
data,
|
|
562
|
+
};
|
|
563
|
+
if (message !== undefined) {
|
|
564
|
+
response.message = message;
|
|
565
|
+
}
|
|
566
|
+
this.json(response);
|
|
567
|
+
}
|
|
568
|
+
error(error, code, message) {
|
|
569
|
+
const response = {
|
|
570
|
+
success: false,
|
|
571
|
+
error,
|
|
572
|
+
};
|
|
573
|
+
if (code !== undefined) {
|
|
574
|
+
response.code = code;
|
|
575
|
+
}
|
|
576
|
+
if (message !== undefined) {
|
|
577
|
+
response.message = message;
|
|
578
|
+
}
|
|
579
|
+
this.json(response);
|
|
580
|
+
}
|
|
581
|
+
// Common HTTP error helpers (automatically set status code)
|
|
582
|
+
unauthorized(message = 'Authentication required') {
|
|
583
|
+
this.statusCode = 401;
|
|
584
|
+
this.json({
|
|
585
|
+
success: false,
|
|
586
|
+
error: 'Unauthorized',
|
|
587
|
+
code: 'UNAUTHORIZED',
|
|
588
|
+
message,
|
|
589
|
+
});
|
|
590
|
+
}
|
|
591
|
+
forbidden(message = 'Insufficient permissions') {
|
|
592
|
+
this.statusCode = 403;
|
|
593
|
+
this.json({
|
|
594
|
+
success: false,
|
|
595
|
+
error: 'Forbidden',
|
|
596
|
+
code: 'FORBIDDEN',
|
|
597
|
+
message,
|
|
598
|
+
});
|
|
599
|
+
}
|
|
600
|
+
notFound(resource = 'Resource') {
|
|
601
|
+
this.statusCode = 404;
|
|
602
|
+
this.json({
|
|
603
|
+
success: false,
|
|
604
|
+
error: 'Not Found',
|
|
605
|
+
code: 'NOT_FOUND',
|
|
606
|
+
message: `${resource} not found`,
|
|
607
|
+
});
|
|
608
|
+
}
|
|
609
|
+
badRequest(message = 'Invalid request') {
|
|
610
|
+
this.statusCode = 400;
|
|
611
|
+
this.json({
|
|
612
|
+
success: false,
|
|
613
|
+
error: 'Bad Request',
|
|
614
|
+
code: 'BAD_REQUEST',
|
|
615
|
+
message,
|
|
616
|
+
});
|
|
617
|
+
}
|
|
618
|
+
conflict(message) {
|
|
619
|
+
this.statusCode = 409;
|
|
620
|
+
this.json({
|
|
621
|
+
success: false,
|
|
622
|
+
error: 'Conflict',
|
|
623
|
+
code: 'CONFLICT',
|
|
624
|
+
message,
|
|
625
|
+
});
|
|
626
|
+
}
|
|
627
|
+
internalError(message = 'Internal server error') {
|
|
628
|
+
this.statusCode = 500;
|
|
629
|
+
this.json({
|
|
630
|
+
success: false,
|
|
631
|
+
error: 'Internal Server Error',
|
|
632
|
+
code: 'INTERNAL_ERROR',
|
|
633
|
+
message,
|
|
634
|
+
});
|
|
635
|
+
}
|
|
636
|
+
validationError(errors) {
|
|
637
|
+
this.statusCode = 422;
|
|
638
|
+
this.json({
|
|
639
|
+
success: false,
|
|
640
|
+
error: 'Validation Failed',
|
|
641
|
+
code: 'VALIDATION_ERROR',
|
|
642
|
+
errors,
|
|
643
|
+
});
|
|
644
|
+
}
|
|
645
|
+
rateLimited(retryAfter) {
|
|
646
|
+
this.statusCode = 429;
|
|
647
|
+
if (retryAfter) {
|
|
648
|
+
this.setHeader('Retry-After', retryAfter.toString());
|
|
649
|
+
}
|
|
650
|
+
this.json({
|
|
651
|
+
success: false,
|
|
652
|
+
error: 'Rate Limit Exceeded',
|
|
653
|
+
code: 'RATE_LIMITED',
|
|
654
|
+
message: retryAfter
|
|
655
|
+
? `Too many requests. Retry after ${retryAfter} seconds.`
|
|
656
|
+
: 'Too many requests',
|
|
657
|
+
retryAfter,
|
|
658
|
+
});
|
|
659
|
+
}
|
|
660
|
+
// Common success patterns
|
|
661
|
+
created(data, location) {
|
|
662
|
+
this.statusCode = 201;
|
|
663
|
+
if (location) {
|
|
664
|
+
this.setHeader('Location', location);
|
|
665
|
+
}
|
|
666
|
+
this.json({
|
|
667
|
+
success: true,
|
|
668
|
+
data,
|
|
669
|
+
});
|
|
670
|
+
}
|
|
671
|
+
noContent() {
|
|
672
|
+
this.statusCode = 204;
|
|
673
|
+
this.end();
|
|
674
|
+
}
|
|
675
|
+
paginated(data, pagination) {
|
|
676
|
+
const totalPages = Math.ceil(pagination.total / pagination.limit);
|
|
677
|
+
this.json({
|
|
678
|
+
success: true,
|
|
679
|
+
data,
|
|
680
|
+
pagination: {
|
|
681
|
+
page: pagination.page,
|
|
682
|
+
limit: pagination.limit,
|
|
683
|
+
total: pagination.total,
|
|
684
|
+
totalPages,
|
|
685
|
+
hasNext: pagination.page < totalPages,
|
|
686
|
+
hasPrev: pagination.page > 1,
|
|
687
|
+
},
|
|
688
|
+
});
|
|
689
|
+
}
|
|
690
|
+
// Header management utilities
|
|
691
|
+
hasHeader(name) {
|
|
692
|
+
return this.getHeader(name) !== undefined;
|
|
693
|
+
}
|
|
694
|
+
setBulkHeaders(headers) {
|
|
695
|
+
if (this.headersSent) {
|
|
696
|
+
// Only enumerate keys for warning if headers were already sent
|
|
697
|
+
const attemptedHeaderKeys = [];
|
|
698
|
+
for (const key in headers) {
|
|
699
|
+
attemptedHeaderKeys.push(key);
|
|
700
|
+
}
|
|
701
|
+
this._server?.logger.warn('Cannot set headers - headers already sent', 'HeaderWarning', {
|
|
702
|
+
attemptedHeaders: attemptedHeaderKeys,
|
|
703
|
+
});
|
|
704
|
+
return this;
|
|
705
|
+
}
|
|
706
|
+
for (const key in headers) {
|
|
707
|
+
this.setHeader(key, headers[key]);
|
|
708
|
+
}
|
|
709
|
+
return this;
|
|
710
|
+
}
|
|
711
|
+
appendHeader(name, value) {
|
|
712
|
+
if (this.headersSent) {
|
|
713
|
+
this._server?.logger.warn(`Cannot append to header '${name}' - headers already sent`, 'HeaderWarning');
|
|
714
|
+
return this;
|
|
715
|
+
}
|
|
716
|
+
const existing = this.getHeader(name);
|
|
717
|
+
if (existing) {
|
|
718
|
+
const values = Array.isArray(existing) ? existing : [existing.toString()];
|
|
719
|
+
const newValues = Array.isArray(value) ? value : [value];
|
|
720
|
+
this.setHeader(name, [...values, ...newValues]);
|
|
721
|
+
}
|
|
722
|
+
else {
|
|
723
|
+
this.setHeader(name, value);
|
|
724
|
+
}
|
|
725
|
+
return this;
|
|
726
|
+
}
|
|
727
|
+
// Express-compatible response helpers
|
|
728
|
+
set(field, value) {
|
|
729
|
+
if (this.headersSent)
|
|
730
|
+
return this;
|
|
731
|
+
if (typeof field === 'string') {
|
|
732
|
+
if (value !== undefined) {
|
|
733
|
+
this.setHeader(field, value);
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
else {
|
|
737
|
+
for (const key in field) {
|
|
738
|
+
this.setHeader(key, field[key]);
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
return this;
|
|
742
|
+
}
|
|
743
|
+
get(field) {
|
|
744
|
+
return this.getHeader(field);
|
|
745
|
+
}
|
|
746
|
+
append(field, value) {
|
|
747
|
+
return this.appendHeader(field, value);
|
|
748
|
+
}
|
|
749
|
+
type(contentType) {
|
|
750
|
+
if (this.headersSent)
|
|
751
|
+
return this;
|
|
752
|
+
// If no "/" treat as shorthand (e.g. "json" → "application/json")
|
|
753
|
+
let ct = contentType;
|
|
754
|
+
if (ct.indexOf('/') === -1) {
|
|
755
|
+
ct = TYPE_SHORTHANDS[ct.toLowerCase()] || `application/${ct}`;
|
|
756
|
+
}
|
|
757
|
+
this.setHeader('Content-Type', ct);
|
|
758
|
+
return this;
|
|
759
|
+
}
|
|
760
|
+
sendStatus(code) {
|
|
761
|
+
if (this.headersSent)
|
|
762
|
+
return;
|
|
763
|
+
this.statusCode = code;
|
|
764
|
+
const body = STATUS_MESSAGES[code] || String(code);
|
|
765
|
+
this.setHeader('Content-Type', 'text/plain; charset=utf-8');
|
|
766
|
+
this.end(body);
|
|
767
|
+
}
|
|
768
|
+
location(url) {
|
|
769
|
+
if (this.headersSent)
|
|
770
|
+
return this;
|
|
771
|
+
const safe = url.replace(/[\r\n]/g, '');
|
|
772
|
+
this.setHeader('Location', safe);
|
|
773
|
+
return this;
|
|
774
|
+
}
|
|
775
|
+
vary(field) {
|
|
776
|
+
if (this.headersSent)
|
|
777
|
+
return this;
|
|
778
|
+
const existing = this.getHeader('Vary');
|
|
779
|
+
const existingList = existing
|
|
780
|
+
? String(existing)
|
|
781
|
+
.split(',')
|
|
782
|
+
.map(s => s.trim())
|
|
783
|
+
.filter(Boolean)
|
|
784
|
+
: [];
|
|
785
|
+
const incoming = Array.isArray(field) ? field : [field];
|
|
786
|
+
for (const f of incoming) {
|
|
787
|
+
if (!existingList.some(e => e.toLowerCase() === f.toLowerCase())) {
|
|
788
|
+
existingList.push(f);
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
this.setHeader('Vary', existingList.join(', '));
|
|
792
|
+
return this;
|
|
793
|
+
}
|
|
794
|
+
attachment(filename) {
|
|
795
|
+
if (this.headersSent)
|
|
796
|
+
return this;
|
|
797
|
+
if (filename) {
|
|
798
|
+
const safeName = filename.replace(/[\r\n"]/g, '');
|
|
799
|
+
this.setHeader('Content-Disposition', `attachment; filename="${safeName}"`);
|
|
800
|
+
// Set Content-Type from extension if not already set
|
|
801
|
+
if (!this.getHeader('Content-Type')) {
|
|
802
|
+
// fire-and-forget mime lookup; not awaited to keep signature chainable
|
|
803
|
+
this._server.getMimeType(filename.substring(filename.lastIndexOf('.'))).then(mime => {
|
|
804
|
+
if (!this.getHeader('Content-Type') && !this.headersSent) {
|
|
805
|
+
this.setHeader('Content-Type', mime);
|
|
806
|
+
}
|
|
807
|
+
});
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
else {
|
|
811
|
+
this.setHeader('Content-Disposition', 'attachment');
|
|
812
|
+
}
|
|
813
|
+
return this;
|
|
814
|
+
}
|
|
815
|
+
async download(filePath, filename) {
|
|
816
|
+
this.attachment(filename || filePath.split('/').pop());
|
|
817
|
+
return this.sendFile(filePath);
|
|
818
|
+
}
|
|
819
|
+
format(handlers) {
|
|
820
|
+
const types = Object.keys(handlers).filter(k => k !== 'default');
|
|
821
|
+
const matched = this.req.accepts(types);
|
|
822
|
+
const chosen = matched ? handlers[matched] : handlers.default;
|
|
823
|
+
if (!chosen) {
|
|
824
|
+
this.statusCode = 406;
|
|
825
|
+
this.setHeader('Content-Type', 'text/plain; charset=utf-8');
|
|
826
|
+
this.end('Not Acceptable');
|
|
827
|
+
return;
|
|
828
|
+
}
|
|
829
|
+
this.vary('Accept');
|
|
830
|
+
if (matched && typeof matched === 'string') {
|
|
831
|
+
this.type(matched);
|
|
832
|
+
}
|
|
833
|
+
chosen();
|
|
834
|
+
}
|
|
835
|
+
links(links) {
|
|
836
|
+
if (this.headersSent)
|
|
837
|
+
return this;
|
|
838
|
+
const parts = [];
|
|
839
|
+
for (const rel in links) {
|
|
840
|
+
parts.push(`<${links[rel]}>; rel="${rel}"`);
|
|
841
|
+
}
|
|
842
|
+
if (parts.length > 0) {
|
|
843
|
+
const existing = this.getHeader('Link');
|
|
844
|
+
const combined = existing ? `${existing}, ${parts.join(', ')}` : parts.join(', ');
|
|
845
|
+
this.setHeader('Link', combined);
|
|
846
|
+
}
|
|
847
|
+
return this;
|
|
848
|
+
}
|
|
849
|
+
// Response state utilities
|
|
850
|
+
canSetHeaders() {
|
|
851
|
+
return !this.headersSent;
|
|
852
|
+
}
|
|
853
|
+
getResponseState() {
|
|
854
|
+
return {
|
|
855
|
+
headersSent: this.headersSent,
|
|
856
|
+
statusCode: this.statusCode,
|
|
857
|
+
headers: this.getHeaders ? this.getHeaders() : {},
|
|
858
|
+
finished: this.finished || false,
|
|
859
|
+
writable: this.writable,
|
|
860
|
+
};
|
|
861
|
+
}
|
|
862
|
+
}
|
|
10
863
|
export class MoroHttpServer {
|
|
11
864
|
server;
|
|
12
865
|
routes = [];
|
|
13
866
|
globalMiddleware = [];
|
|
867
|
+
/** @internal read by MoroServerResponse prototype methods */
|
|
14
868
|
compressionEnabled = true;
|
|
869
|
+
/** @internal read by MoroServerResponse prototype methods */
|
|
15
870
|
compressionThreshold = 1024;
|
|
871
|
+
/** @internal read by MoroIncomingMessage prototype methods */
|
|
16
872
|
requestTrackingEnabled = true; // Generate request IDs
|
|
873
|
+
/** @internal read by MoroServerResponse prototype methods */
|
|
17
874
|
logger = createFrameworkLogger('HttpServer');
|
|
18
875
|
hookManager;
|
|
19
876
|
requestCounter = 0;
|
|
20
877
|
errorHandler;
|
|
21
878
|
requestDecorations = {};
|
|
22
879
|
responseDecorations = {};
|
|
880
|
+
// Per-server request/response subclasses; their prototype carries the
|
|
881
|
+
// `_server` back-reference plus user decorations (applied once, not per request)
|
|
882
|
+
RequestClass;
|
|
883
|
+
ResponseClass;
|
|
884
|
+
// Direct router dispatch slot - runs after global middleware without the
|
|
885
|
+
// per-middleware promise machinery (set from Moro.listen via setRouterHandler)
|
|
886
|
+
routerHandler;
|
|
23
887
|
// Body size limits - defaults overridden by config in constructor
|
|
24
888
|
maxBodySize = 10 * 1024 * 1024; // Default 10MB (overridden by server.bodySizeLimit config)
|
|
25
889
|
maxUploadSize = 100 * 1024 * 1024; // Default 100MB for file uploads (future: configurable)
|
|
@@ -43,7 +907,19 @@ export class MoroHttpServer {
|
|
|
43
907
|
rateLimited: Buffer.from('{"success":false,"error":"Rate limit exceeded"}'),
|
|
44
908
|
};
|
|
45
909
|
constructor(options) {
|
|
46
|
-
|
|
910
|
+
// Per-instance subclasses so prototype methods can reach this server's
|
|
911
|
+
// config (logger, compression) with zero per-request allocation.
|
|
912
|
+
// eslint-disable-next-line @typescript-eslint/no-this-alias
|
|
913
|
+
const boundServer = this;
|
|
914
|
+
class BoundRequest extends MoroIncomingMessage {
|
|
915
|
+
}
|
|
916
|
+
class BoundResponse extends MoroServerResponse {
|
|
917
|
+
}
|
|
918
|
+
BoundRequest.prototype._server = boundServer;
|
|
919
|
+
BoundResponse.prototype._server = boundServer;
|
|
920
|
+
this.RequestClass = BoundRequest;
|
|
921
|
+
this.ResponseClass = BoundResponse;
|
|
922
|
+
this.server = createServer({ IncomingMessage: BoundRequest, ServerResponse: BoundResponse }, this.handleRequest.bind(this));
|
|
47
923
|
// Configure body size limits from options
|
|
48
924
|
if (options?.maxBodySize) {
|
|
49
925
|
this.maxBodySize = options.maxBodySize;
|
|
@@ -56,6 +932,11 @@ export class MoroHttpServer {
|
|
|
56
932
|
this.server.headersTimeout = 6000; // 6 seconds
|
|
57
933
|
this.server.timeout = 30000; // 30 seconds request timeout
|
|
58
934
|
}
|
|
935
|
+
// Direct router dispatch - called after global middleware, before the legacy
|
|
936
|
+
// route table. Returning true (or a promise of true) means the request was handled.
|
|
937
|
+
setRouterHandler(fn) {
|
|
938
|
+
this.routerHandler = fn;
|
|
939
|
+
}
|
|
59
940
|
// Configure server for maximum performance (can disable all overhead)
|
|
60
941
|
configurePerformance(config = {}) {
|
|
61
942
|
if (config.compression !== undefined) {
|
|
@@ -78,12 +959,30 @@ export class MoroHttpServer {
|
|
|
78
959
|
setErrorHandler(fn) {
|
|
79
960
|
this.errorHandler = fn;
|
|
80
961
|
}
|
|
81
|
-
// Apply
|
|
962
|
+
// Apply request/response decoration maps (called from Moro.decorateRequest/Reply).
|
|
963
|
+
// Decorations are installed on the per-server prototypes once, so requests pay
|
|
964
|
+
// nothing for them; per-request writes to the same key simply shadow the prototype.
|
|
82
965
|
setRequestDecorations(decorations) {
|
|
83
966
|
this.requestDecorations = decorations;
|
|
967
|
+
for (const key in decorations) {
|
|
968
|
+
Object.defineProperty(this.RequestClass.prototype, key, {
|
|
969
|
+
value: decorations[key],
|
|
970
|
+
writable: true,
|
|
971
|
+
enumerable: true,
|
|
972
|
+
configurable: true,
|
|
973
|
+
});
|
|
974
|
+
}
|
|
84
975
|
}
|
|
85
976
|
setResponseDecorations(decorations) {
|
|
86
977
|
this.responseDecorations = decorations;
|
|
978
|
+
for (const key in decorations) {
|
|
979
|
+
Object.defineProperty(this.ResponseClass.prototype, key, {
|
|
980
|
+
value: decorations[key],
|
|
981
|
+
writable: true,
|
|
982
|
+
enumerable: true,
|
|
983
|
+
configurable: true,
|
|
984
|
+
});
|
|
985
|
+
}
|
|
87
986
|
}
|
|
88
987
|
// Middleware management
|
|
89
988
|
use(middleware) {
|
|
@@ -158,48 +1057,90 @@ export class MoroHttpServer {
|
|
|
158
1057
|
};
|
|
159
1058
|
}
|
|
160
1059
|
async handleRequest(req, res) {
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
const
|
|
1060
|
+
// req/res arrive as the per-server subclasses (MoroIncomingMessage /
|
|
1061
|
+
// MoroServerResponse) - all helpers are already on their prototypes.
|
|
1062
|
+
const httpReq = req;
|
|
1063
|
+
const httpRes = res;
|
|
165
1064
|
try {
|
|
166
|
-
//
|
|
1065
|
+
// URL split - query string parsing is lazy (MoroIncomingMessage#query getter)
|
|
167
1066
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
168
1067
|
const urlString = req.url;
|
|
169
1068
|
const queryIndex = urlString.indexOf('?');
|
|
170
1069
|
if (queryIndex === -1) {
|
|
171
|
-
// No query string
|
|
172
1070
|
httpReq.path = urlString;
|
|
173
|
-
httpReq.query = {};
|
|
174
1071
|
}
|
|
175
1072
|
else {
|
|
176
|
-
// Has query string - parse efficiently with pooled object
|
|
177
1073
|
httpReq.path = urlString.substring(0, queryIndex);
|
|
178
|
-
httpReq.
|
|
1074
|
+
httpReq._queryString = urlString.substring(queryIndex + 1);
|
|
179
1075
|
}
|
|
180
|
-
//
|
|
1076
|
+
// Intern method string for fast reference equality comparison (50-100% faster)
|
|
1077
|
+
switch (req.method) {
|
|
1078
|
+
case 'POST':
|
|
1079
|
+
httpReq.method = MoroHttpServer.METHOD_POST;
|
|
1080
|
+
break;
|
|
1081
|
+
case 'PUT':
|
|
1082
|
+
httpReq.method = MoroHttpServer.METHOD_PUT;
|
|
1083
|
+
break;
|
|
1084
|
+
case 'PATCH':
|
|
1085
|
+
httpReq.method = MoroHttpServer.METHOD_PATCH;
|
|
1086
|
+
break;
|
|
1087
|
+
case 'GET':
|
|
1088
|
+
httpReq.method = MoroHttpServer.METHOD_GET;
|
|
1089
|
+
break;
|
|
1090
|
+
case 'DELETE':
|
|
1091
|
+
httpReq.method = MoroHttpServer.METHOD_DELETE;
|
|
1092
|
+
break;
|
|
1093
|
+
case 'HEAD':
|
|
1094
|
+
httpReq.method = MoroHttpServer.METHOD_HEAD;
|
|
1095
|
+
break;
|
|
1096
|
+
case 'OPTIONS':
|
|
1097
|
+
httpReq.method = MoroHttpServer.METHOD_OPTIONS;
|
|
1098
|
+
break;
|
|
1099
|
+
}
|
|
1100
|
+
// Method checking - use reference equality for interned strings
|
|
181
1101
|
if (httpReq.method === MoroHttpServer.METHOD_POST ||
|
|
182
1102
|
httpReq.method === MoroHttpServer.METHOD_PUT ||
|
|
183
1103
|
httpReq.method === MoroHttpServer.METHOD_PATCH) {
|
|
184
1104
|
httpReq.body = await this.parseBody(req);
|
|
185
1105
|
}
|
|
186
|
-
// Execute hooks before request processing -
|
|
187
|
-
|
|
188
|
-
|
|
1106
|
+
// Execute hooks before request processing - skipped entirely (no context
|
|
1107
|
+
// object, no promise) when no 'request' hooks are registered
|
|
1108
|
+
const hookManager = this.hookManager;
|
|
1109
|
+
if (hookManager && (hookManager.hasHooks === undefined || hookManager.hasHooks('request'))) {
|
|
1110
|
+
await hookManager.execute('request', {
|
|
189
1111
|
request: httpReq,
|
|
190
1112
|
response: httpRes,
|
|
191
1113
|
});
|
|
192
1114
|
}
|
|
193
|
-
// Execute global middleware first - EARLY EXIT if none registered
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
1115
|
+
// Execute global middleware first - EARLY EXIT if none registered.
|
|
1116
|
+
// executeMiddleware returns undefined when the whole chain completed
|
|
1117
|
+
// synchronously, so a sync chain costs zero promise allocations.
|
|
1118
|
+
if (this.globalMiddleware.length > 0) {
|
|
1119
|
+
const mwResult = this.executeMiddleware(this.globalMiddleware, httpReq, httpRes);
|
|
1120
|
+
if (mwResult)
|
|
1121
|
+
await mwResult;
|
|
197
1122
|
}
|
|
198
1123
|
// If middleware handled the request, don't continue
|
|
199
1124
|
if (httpRes.headersSent) {
|
|
200
1125
|
return;
|
|
201
1126
|
}
|
|
202
|
-
//
|
|
1127
|
+
// Unified router direct dispatch - no middleware-chain promise wrapper
|
|
1128
|
+
const routerHandler = this.routerHandler;
|
|
1129
|
+
if (routerHandler) {
|
|
1130
|
+
const handled = routerHandler(httpReq, httpRes);
|
|
1131
|
+
if (handled) {
|
|
1132
|
+
if (typeof handled.then === 'function') {
|
|
1133
|
+
if (await handled)
|
|
1134
|
+
return;
|
|
1135
|
+
}
|
|
1136
|
+
else {
|
|
1137
|
+
return;
|
|
1138
|
+
}
|
|
1139
|
+
}
|
|
1140
|
+
if (httpRes.headersSent)
|
|
1141
|
+
return;
|
|
1142
|
+
}
|
|
1143
|
+
// Find matching route (legacy direct-route table)
|
|
203
1144
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
204
1145
|
const route = this.findRoute(req.method, httpReq.path);
|
|
205
1146
|
if (!route) {
|
|
@@ -210,21 +1151,22 @@ export class MoroHttpServer {
|
|
|
210
1151
|
httpRes.end(MoroHttpServer.RESPONSE_TEMPLATES.notFound);
|
|
211
1152
|
return;
|
|
212
1153
|
}
|
|
213
|
-
// Extract path parameters
|
|
1154
|
+
// Extract path parameters
|
|
214
1155
|
const matches = httpReq.path.match(route.pattern);
|
|
215
1156
|
if (matches) {
|
|
216
|
-
|
|
217
|
-
httpReq.params = this.acquireParamObject();
|
|
1157
|
+
const params = {};
|
|
218
1158
|
const paramNames = route.paramNames;
|
|
219
1159
|
const paramNamesLen = paramNames.length;
|
|
220
1160
|
for (let i = 0; i < paramNamesLen; i++) {
|
|
221
|
-
|
|
1161
|
+
params[paramNames[i]] = matches[i + 1];
|
|
222
1162
|
}
|
|
1163
|
+
httpReq.params = params;
|
|
223
1164
|
}
|
|
224
1165
|
// Execute middleware chain - EARLY EXIT if no route middleware
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
1166
|
+
if (route.middleware.length > 0) {
|
|
1167
|
+
const mwResult = this.executeMiddleware(route.middleware, httpReq, httpRes);
|
|
1168
|
+
if (mwResult)
|
|
1169
|
+
await mwResult;
|
|
228
1170
|
}
|
|
229
1171
|
// Execute handler - Don't await sync handlers
|
|
230
1172
|
const handlerResult = route.handler(httpReq, httpRes);
|
|
@@ -241,6 +1183,16 @@ export class MoroHttpServer {
|
|
|
241
1183
|
requestPath: req.url,
|
|
242
1184
|
requestMethod: req.method,
|
|
243
1185
|
});
|
|
1186
|
+
// Payload-too-large from parseBody: respond 413 (matches the old bodySize
|
|
1187
|
+
// middleware semantics, now enforced in-server)
|
|
1188
|
+
if (error?.statusCode === 413 && !httpRes.headersSent) {
|
|
1189
|
+
httpRes.statusCode = 413;
|
|
1190
|
+
httpRes.json({
|
|
1191
|
+
success: false,
|
|
1192
|
+
error: 'Request entity too large',
|
|
1193
|
+
});
|
|
1194
|
+
return;
|
|
1195
|
+
}
|
|
244
1196
|
this.logger.error('Request error', 'RequestHandler', {
|
|
245
1197
|
error: error instanceof Error ? error.message : String(error),
|
|
246
1198
|
requestId: httpReq.requestId,
|
|
@@ -298,66 +1250,6 @@ export class MoroHttpServer {
|
|
|
298
1250
|
}
|
|
299
1251
|
}
|
|
300
1252
|
}
|
|
301
|
-
finally {
|
|
302
|
-
// Always release pooled objects back to the pool
|
|
303
|
-
// This prevents memory leaks and ensures consistent performance
|
|
304
|
-
// Check if object is empty without Object.keys()
|
|
305
|
-
if (originalParams) {
|
|
306
|
-
let isEmpty = true;
|
|
307
|
-
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
308
|
-
for (const _key in originalParams) {
|
|
309
|
-
isEmpty = false;
|
|
310
|
-
break;
|
|
311
|
-
}
|
|
312
|
-
if (isEmpty) {
|
|
313
|
-
this.releaseParamObject(originalParams);
|
|
314
|
-
}
|
|
315
|
-
}
|
|
316
|
-
if (httpReq.params && httpReq.params !== originalParams) {
|
|
317
|
-
let isEmpty = true;
|
|
318
|
-
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
319
|
-
for (const _key in httpReq.params) {
|
|
320
|
-
isEmpty = false;
|
|
321
|
-
break;
|
|
322
|
-
}
|
|
323
|
-
if (isEmpty) {
|
|
324
|
-
this.releaseParamObject(httpReq.params);
|
|
325
|
-
}
|
|
326
|
-
}
|
|
327
|
-
}
|
|
328
|
-
// Additional cleanup on response completion to ensure objects are returned to pool
|
|
329
|
-
res.once('finish', () => {
|
|
330
|
-
// Check if object is empty without Object.keys()
|
|
331
|
-
if (originalParams) {
|
|
332
|
-
let isEmpty = true;
|
|
333
|
-
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
334
|
-
for (const _key in originalParams) {
|
|
335
|
-
isEmpty = false;
|
|
336
|
-
break;
|
|
337
|
-
}
|
|
338
|
-
if (isEmpty) {
|
|
339
|
-
this.releaseParamObject(originalParams);
|
|
340
|
-
}
|
|
341
|
-
}
|
|
342
|
-
if (httpReq.params && httpReq.params !== originalParams) {
|
|
343
|
-
let isEmpty = true;
|
|
344
|
-
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
345
|
-
for (const _key in httpReq.params) {
|
|
346
|
-
isEmpty = false;
|
|
347
|
-
break;
|
|
348
|
-
}
|
|
349
|
-
if (isEmpty) {
|
|
350
|
-
this.releaseParamObject(httpReq.params);
|
|
351
|
-
}
|
|
352
|
-
}
|
|
353
|
-
});
|
|
354
|
-
}
|
|
355
|
-
// Use shared object pool for parameter objects
|
|
356
|
-
acquireParamObject() {
|
|
357
|
-
return this.poolManager.acquireParams();
|
|
358
|
-
}
|
|
359
|
-
releaseParamObject(params) {
|
|
360
|
-
this.poolManager.releaseParams(params);
|
|
361
1253
|
}
|
|
362
1254
|
// Force cleanup of all pooled objects
|
|
363
1255
|
forceCleanupPools() {
|
|
@@ -429,697 +1321,7 @@ export class MoroHttpServer {
|
|
|
429
1321
|
}
|
|
430
1322
|
return normalized;
|
|
431
1323
|
}
|
|
432
|
-
|
|
433
|
-
const httpReq = req;
|
|
434
|
-
httpReq.params = this.acquireParamObject();
|
|
435
|
-
httpReq.query = {};
|
|
436
|
-
httpReq.body = null;
|
|
437
|
-
httpReq.path = '';
|
|
438
|
-
httpReq.ip = req.socket.remoteAddress || '';
|
|
439
|
-
// Request ID generation using pool manager (if enabled)
|
|
440
|
-
httpReq.requestId = this.requestTrackingEnabled ? this.poolManager.generateRequestId() : '';
|
|
441
|
-
httpReq.headers = req.headers;
|
|
442
|
-
// Intern method string for fast reference equality comparison (50-100% faster)
|
|
443
|
-
const method = req.method;
|
|
444
|
-
switch (method) {
|
|
445
|
-
case 'POST':
|
|
446
|
-
httpReq.method = MoroHttpServer.METHOD_POST;
|
|
447
|
-
break;
|
|
448
|
-
case 'PUT':
|
|
449
|
-
httpReq.method = MoroHttpServer.METHOD_PUT;
|
|
450
|
-
break;
|
|
451
|
-
case 'PATCH':
|
|
452
|
-
httpReq.method = MoroHttpServer.METHOD_PATCH;
|
|
453
|
-
break;
|
|
454
|
-
case 'GET':
|
|
455
|
-
httpReq.method = MoroHttpServer.METHOD_GET;
|
|
456
|
-
break;
|
|
457
|
-
case 'DELETE':
|
|
458
|
-
httpReq.method = MoroHttpServer.METHOD_DELETE;
|
|
459
|
-
break;
|
|
460
|
-
case 'HEAD':
|
|
461
|
-
httpReq.method = MoroHttpServer.METHOD_HEAD;
|
|
462
|
-
break;
|
|
463
|
-
case 'OPTIONS':
|
|
464
|
-
httpReq.method = MoroHttpServer.METHOD_OPTIONS;
|
|
465
|
-
break;
|
|
466
|
-
default:
|
|
467
|
-
httpReq.method = method;
|
|
468
|
-
}
|
|
469
|
-
// Parse cookies - EARLY EXIT if no cookie header
|
|
470
|
-
const cookieHeader = req.headers.cookie;
|
|
471
|
-
if (cookieHeader) {
|
|
472
|
-
httpReq.cookies = this.parseCookies(cookieHeader);
|
|
473
|
-
}
|
|
474
|
-
else {
|
|
475
|
-
httpReq.cookies = {};
|
|
476
|
-
}
|
|
477
|
-
// Per-request typed context bag
|
|
478
|
-
httpReq.context = {};
|
|
479
|
-
// Apply user-registered request decorations
|
|
480
|
-
for (const key in this.requestDecorations) {
|
|
481
|
-
httpReq[key] = this.requestDecorations[key];
|
|
482
|
-
}
|
|
483
|
-
// Express-compatible request helpers
|
|
484
|
-
httpReq.get = httpReq.header = (name) => {
|
|
485
|
-
const lower = name.toLowerCase();
|
|
486
|
-
if (lower === 'referer' || lower === 'referrer') {
|
|
487
|
-
return (httpReq.headers.referer || httpReq.headers.referrer);
|
|
488
|
-
}
|
|
489
|
-
const v = httpReq.headers[lower];
|
|
490
|
-
return Array.isArray(v) ? v[0] : v;
|
|
491
|
-
};
|
|
492
|
-
httpReq.is = (type) => {
|
|
493
|
-
const ct = (httpReq.headers['content-type'] || '');
|
|
494
|
-
if (!ct)
|
|
495
|
-
return false;
|
|
496
|
-
const mime = ct.split(';')[0].trim().toLowerCase();
|
|
497
|
-
const t = type.toLowerCase();
|
|
498
|
-
if (t.indexOf('/') === -1) {
|
|
499
|
-
// e.g. 'json' → match */json or +json suffix
|
|
500
|
-
return mime.endsWith(`/${t}`) || mime.endsWith(`+${t}`);
|
|
501
|
-
}
|
|
502
|
-
if (t.endsWith('/*')) {
|
|
503
|
-
return mime.startsWith(t.slice(0, -1));
|
|
504
|
-
}
|
|
505
|
-
return mime === t;
|
|
506
|
-
};
|
|
507
|
-
httpReq.accepts = (types) => {
|
|
508
|
-
const accept = (httpReq.headers.accept || '*/*');
|
|
509
|
-
if (!types)
|
|
510
|
-
return accept;
|
|
511
|
-
const wanted = Array.isArray(types) ? types : [types];
|
|
512
|
-
if (accept === '*/*' || accept === '')
|
|
513
|
-
return wanted[0] || false;
|
|
514
|
-
const acceptTypes = accept.split(',').map(s => s.split(';')[0].trim().toLowerCase());
|
|
515
|
-
for (const w of wanted) {
|
|
516
|
-
const wl = w.toLowerCase();
|
|
517
|
-
const wMime = wl.indexOf('/') === -1 ? `application/${wl}` : wl;
|
|
518
|
-
for (const at of acceptTypes) {
|
|
519
|
-
if (at === '*/*')
|
|
520
|
-
return w;
|
|
521
|
-
if (at === wMime)
|
|
522
|
-
return w;
|
|
523
|
-
if (at.endsWith('/*') && wMime.startsWith(at.slice(0, -1)))
|
|
524
|
-
return w;
|
|
525
|
-
}
|
|
526
|
-
}
|
|
527
|
-
return false;
|
|
528
|
-
};
|
|
529
|
-
httpReq.acceptsLanguages = (langs) => {
|
|
530
|
-
const acceptLang = (httpReq.headers['accept-language'] || '');
|
|
531
|
-
if (!langs)
|
|
532
|
-
return acceptLang || false;
|
|
533
|
-
const wanted = Array.isArray(langs) ? langs : [langs];
|
|
534
|
-
if (!acceptLang)
|
|
535
|
-
return wanted[0] || false;
|
|
536
|
-
// Parse with q values; sort by q desc to match Express's "best match wins"
|
|
537
|
-
const accepted = acceptLang
|
|
538
|
-
.split(',')
|
|
539
|
-
.map(s => {
|
|
540
|
-
const parts = s.trim().split(';');
|
|
541
|
-
const tag = parts[0].toLowerCase();
|
|
542
|
-
const qPart = parts.find(p => p.trim().startsWith('q='));
|
|
543
|
-
const q = qPart ? parseFloat(qPart.trim().slice(2)) : 1;
|
|
544
|
-
return { tag, q: isNaN(q) ? 0 : q };
|
|
545
|
-
})
|
|
546
|
-
.filter(a => a.q > 0)
|
|
547
|
-
.sort((a, b) => b.q - a.q);
|
|
548
|
-
for (const { tag } of accepted) {
|
|
549
|
-
for (const w of wanted) {
|
|
550
|
-
const wl = w.toLowerCase();
|
|
551
|
-
if (tag === '*' || tag === wl || tag.split('-')[0] === wl.split('-')[0])
|
|
552
|
-
return w;
|
|
553
|
-
}
|
|
554
|
-
}
|
|
555
|
-
return false;
|
|
556
|
-
};
|
|
557
|
-
const host = (httpReq.headers.host || '');
|
|
558
|
-
httpReq.hostname = host ? host.split(':')[0] : '';
|
|
559
|
-
const forwardedProto = httpReq.headers['x-forwarded-proto'];
|
|
560
|
-
httpReq.protocol = forwardedProto
|
|
561
|
-
? forwardedProto.split(',')[0].trim()
|
|
562
|
-
: req.socket.encrypted
|
|
563
|
-
? 'https'
|
|
564
|
-
: 'http';
|
|
565
|
-
httpReq.secure = httpReq.protocol === 'https';
|
|
566
|
-
const xrw = httpReq.headers['x-requested-with'];
|
|
567
|
-
httpReq.xhr = !!xrw && xrw.toLowerCase() === 'xmlhttprequest';
|
|
568
|
-
httpReq.originalUrl = req.url || '';
|
|
569
|
-
const forwardedFor = httpReq.headers['x-forwarded-for'];
|
|
570
|
-
httpReq.ips = forwardedFor
|
|
571
|
-
? forwardedFor
|
|
572
|
-
.split(',')
|
|
573
|
-
.map(s => s.trim())
|
|
574
|
-
.filter(Boolean)
|
|
575
|
-
: [];
|
|
576
|
-
const hostnameParts = httpReq.hostname.split('.');
|
|
577
|
-
// Matches Express: subdomains are parts before the last two (domain + tld),
|
|
578
|
-
// reversed (closest subdomain first).
|
|
579
|
-
httpReq.subdomains = hostnameParts.length > 2 ? hostnameParts.slice(0, -2).reverse() : [];
|
|
580
|
-
return httpReq;
|
|
581
|
-
}
|
|
582
|
-
parseCookies(cookieHeader) {
|
|
583
|
-
const cookies = {};
|
|
584
|
-
// EARLY EXIT if no cookie header
|
|
585
|
-
if (!cookieHeader)
|
|
586
|
-
return cookies;
|
|
587
|
-
const cookieParts = cookieHeader.split(';');
|
|
588
|
-
const cookiePartsLen = cookieParts.length;
|
|
589
|
-
for (let i = 0; i < cookiePartsLen; i++) {
|
|
590
|
-
const cookie = cookieParts[i];
|
|
591
|
-
const equalIndex = cookie.indexOf('=');
|
|
592
|
-
if (equalIndex > 0) {
|
|
593
|
-
const name = cookie.substring(0, equalIndex).trim();
|
|
594
|
-
const value = cookie.substring(equalIndex + 1);
|
|
595
|
-
if (name && value) {
|
|
596
|
-
cookies[name] = decodeURIComponent(value);
|
|
597
|
-
}
|
|
598
|
-
}
|
|
599
|
-
}
|
|
600
|
-
return cookies;
|
|
601
|
-
}
|
|
602
|
-
enhanceResponse(res, req) {
|
|
603
|
-
const httpRes = res;
|
|
604
|
-
// Store request reference for access to headers (needed for compression, logging, etc.)
|
|
605
|
-
httpRes.req = req;
|
|
606
|
-
// BULLETPROOF status method - always works
|
|
607
|
-
httpRes.status = (code) => {
|
|
608
|
-
httpRes.statusCode = code;
|
|
609
|
-
return httpRes;
|
|
610
|
-
};
|
|
611
|
-
httpRes.json = async (data) => {
|
|
612
|
-
if (httpRes.headersSent)
|
|
613
|
-
return;
|
|
614
|
-
// Simple, optimized JSON serialization - let V8 handle the optimization
|
|
615
|
-
const jsonString = JSON.stringify(data);
|
|
616
|
-
// Large response check - stream if needed
|
|
617
|
-
if (jsonString.length > 32768) {
|
|
618
|
-
// Large response - stream it
|
|
619
|
-
return this.streamLargeResponse(httpRes, data);
|
|
620
|
-
}
|
|
621
|
-
// Use efficient buffer allocation - let Node.js handle optimization
|
|
622
|
-
const finalBuffer = Buffer.from(jsonString, 'utf8');
|
|
623
|
-
// Optimized header setting - set multiple headers at once when possible
|
|
624
|
-
const headers = {
|
|
625
|
-
'Content-Type': 'application/json; charset=utf-8',
|
|
626
|
-
};
|
|
627
|
-
// Compression with buffer pool - EARLY EXIT if disabled or below threshold
|
|
628
|
-
// Only make this async if compression is actually happening
|
|
629
|
-
if (this.compressionEnabled && finalBuffer.length > this.compressionThreshold) {
|
|
630
|
-
const acceptEncoding = httpRes.req.headers['accept-encoding'];
|
|
631
|
-
if (acceptEncoding && acceptEncoding.includes('gzip')) {
|
|
632
|
-
// ASYNC PATH - compression needed
|
|
633
|
-
gzip(finalBuffer).then(compressed => {
|
|
634
|
-
headers['Content-Encoding'] = 'gzip';
|
|
635
|
-
headers['Content-Length'] = compressed.length;
|
|
636
|
-
// Batch write all headers at once (50-100% faster)
|
|
637
|
-
httpRes.writeHead(httpRes.statusCode || 200, headers);
|
|
638
|
-
httpRes.end(compressed);
|
|
639
|
-
});
|
|
640
|
-
return;
|
|
641
|
-
}
|
|
642
|
-
else if (acceptEncoding && acceptEncoding.includes('deflate')) {
|
|
643
|
-
// ASYNC PATH - compression needed
|
|
644
|
-
deflate(finalBuffer).then(compressed => {
|
|
645
|
-
headers['Content-Encoding'] = 'deflate';
|
|
646
|
-
headers['Content-Length'] = compressed.length;
|
|
647
|
-
// Batch write all headers at once
|
|
648
|
-
httpRes.writeHead(httpRes.statusCode || 200, headers);
|
|
649
|
-
httpRes.end(compressed);
|
|
650
|
-
});
|
|
651
|
-
return;
|
|
652
|
-
}
|
|
653
|
-
}
|
|
654
|
-
// SYNC PATH - no compression, fast path
|
|
655
|
-
headers['Content-Length'] = finalBuffer.length;
|
|
656
|
-
// Batch write all headers at once
|
|
657
|
-
httpRes.writeHead(httpRes.statusCode || 200, headers);
|
|
658
|
-
httpRes.end(finalBuffer);
|
|
659
|
-
};
|
|
660
|
-
httpRes.send = (data) => {
|
|
661
|
-
if (httpRes.headersSent)
|
|
662
|
-
return;
|
|
663
|
-
// Auto-detect content type if not already set
|
|
664
|
-
if (!httpRes.getHeader('Content-Type')) {
|
|
665
|
-
if (typeof data === 'string') {
|
|
666
|
-
// Check if it's JSON
|
|
667
|
-
try {
|
|
668
|
-
JSON.parse(data);
|
|
669
|
-
httpRes.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
670
|
-
}
|
|
671
|
-
catch {
|
|
672
|
-
// Default to plain text
|
|
673
|
-
httpRes.setHeader('Content-Type', 'text/plain; charset=utf-8');
|
|
674
|
-
}
|
|
675
|
-
}
|
|
676
|
-
else {
|
|
677
|
-
// Buffer data - default to octet-stream
|
|
678
|
-
httpRes.setHeader('Content-Type', 'application/octet-stream');
|
|
679
|
-
}
|
|
680
|
-
}
|
|
681
|
-
httpRes.end(data);
|
|
682
|
-
};
|
|
683
|
-
httpRes.cookie = (name, value, options = {}) => {
|
|
684
|
-
if (httpRes.headersSent) {
|
|
685
|
-
const isCritical = options.critical ||
|
|
686
|
-
name.includes('session') ||
|
|
687
|
-
name.includes('auth') ||
|
|
688
|
-
name.includes('csrf');
|
|
689
|
-
const message = `Cookie '${name}' could not be set - headers already sent`;
|
|
690
|
-
if (isCritical || options.throwOnLateSet) {
|
|
691
|
-
throw new Error(`${message}. This may cause authentication or security issues.`);
|
|
692
|
-
}
|
|
693
|
-
else {
|
|
694
|
-
this.logger.warn(message, 'CookieWarning', {
|
|
695
|
-
cookieName: name,
|
|
696
|
-
critical: isCritical,
|
|
697
|
-
stackTrace: new Error().stack,
|
|
698
|
-
});
|
|
699
|
-
}
|
|
700
|
-
return httpRes;
|
|
701
|
-
}
|
|
702
|
-
const cookieValue = encodeURIComponent(value);
|
|
703
|
-
let cookieString = `${name}=${cookieValue}`;
|
|
704
|
-
if (options.maxAge)
|
|
705
|
-
cookieString += `; Max-Age=${options.maxAge}`;
|
|
706
|
-
if (options.expires)
|
|
707
|
-
cookieString += `; Expires=${options.expires.toUTCString()}`;
|
|
708
|
-
if (options.httpOnly)
|
|
709
|
-
cookieString += '; HttpOnly';
|
|
710
|
-
if (options.secure)
|
|
711
|
-
cookieString += '; Secure';
|
|
712
|
-
if (options.sameSite)
|
|
713
|
-
cookieString += `; SameSite=${options.sameSite}`;
|
|
714
|
-
if (options.domain)
|
|
715
|
-
cookieString += `; Domain=${options.domain}`;
|
|
716
|
-
if (options.path)
|
|
717
|
-
cookieString += `; Path=${options.path}`;
|
|
718
|
-
const existingCookies = httpRes.getHeader('Set-Cookie') || [];
|
|
719
|
-
// Avoid spread operator - direct array manipulation
|
|
720
|
-
const cookies = Array.isArray(existingCookies)
|
|
721
|
-
? existingCookies
|
|
722
|
-
: [existingCookies];
|
|
723
|
-
cookies.push(cookieString);
|
|
724
|
-
httpRes.setHeader('Set-Cookie', cookies);
|
|
725
|
-
return httpRes;
|
|
726
|
-
};
|
|
727
|
-
httpRes.clearCookie = (name, options = {}) => {
|
|
728
|
-
// Avoid spread operator - manually set properties
|
|
729
|
-
const clearOptions = {
|
|
730
|
-
expires: new Date(0),
|
|
731
|
-
maxAge: 0,
|
|
732
|
-
};
|
|
733
|
-
// Copy other options manually
|
|
734
|
-
if (options.path !== undefined)
|
|
735
|
-
clearOptions.path = options.path;
|
|
736
|
-
if (options.domain !== undefined)
|
|
737
|
-
clearOptions.domain = options.domain;
|
|
738
|
-
if (options.httpOnly !== undefined)
|
|
739
|
-
clearOptions.httpOnly = options.httpOnly;
|
|
740
|
-
if (options.secure !== undefined)
|
|
741
|
-
clearOptions.secure = options.secure;
|
|
742
|
-
if (options.sameSite !== undefined)
|
|
743
|
-
clearOptions.sameSite = options.sameSite;
|
|
744
|
-
return httpRes.cookie(name, '', clearOptions);
|
|
745
|
-
};
|
|
746
|
-
httpRes.redirect = (url, status = 302) => {
|
|
747
|
-
if (httpRes.headersSent)
|
|
748
|
-
return;
|
|
749
|
-
httpRes.statusCode = status;
|
|
750
|
-
const safeUrl = url.replace(/[\r\n]/g, '');
|
|
751
|
-
httpRes.setHeader('Location', safeUrl);
|
|
752
|
-
httpRes.end();
|
|
753
|
-
};
|
|
754
|
-
httpRes.sendFile = async (filePath) => {
|
|
755
|
-
if (httpRes.headersSent)
|
|
756
|
-
return;
|
|
757
|
-
try {
|
|
758
|
-
const fs = await import('fs/promises');
|
|
759
|
-
const path = await import('path');
|
|
760
|
-
const extension = path.extname(filePath);
|
|
761
|
-
const mime = await this.getMimeType(extension);
|
|
762
|
-
const stats = await fs.stat(filePath);
|
|
763
|
-
const data = await fs.readFile(filePath);
|
|
764
|
-
// Add charset for text-based files
|
|
765
|
-
const contentType = this.addCharsetIfNeeded(mime);
|
|
766
|
-
httpRes.setHeader('Content-Type', contentType);
|
|
767
|
-
httpRes.setHeader('Content-Length', stats.size);
|
|
768
|
-
// Add security headers for file downloads
|
|
769
|
-
httpRes.setHeader('X-Content-Type-Options', 'nosniff');
|
|
770
|
-
// Add caching headers
|
|
771
|
-
httpRes.setHeader('Last-Modified', stats.mtime.toUTCString());
|
|
772
|
-
httpRes.setHeader('Cache-Control', 'public, max-age=31536000'); // 1 year for static files
|
|
773
|
-
httpRes.end(data);
|
|
774
|
-
}
|
|
775
|
-
catch {
|
|
776
|
-
httpRes.status(404).json({ success: false, error: 'File not found' });
|
|
777
|
-
}
|
|
778
|
-
};
|
|
779
|
-
// Standardized response helpers
|
|
780
|
-
httpRes.success = (data, message) => {
|
|
781
|
-
const response = {
|
|
782
|
-
success: true,
|
|
783
|
-
data,
|
|
784
|
-
};
|
|
785
|
-
if (message !== undefined) {
|
|
786
|
-
response.message = message;
|
|
787
|
-
}
|
|
788
|
-
httpRes.json(response);
|
|
789
|
-
};
|
|
790
|
-
httpRes.error = (error, code, message) => {
|
|
791
|
-
const response = {
|
|
792
|
-
success: false,
|
|
793
|
-
error,
|
|
794
|
-
};
|
|
795
|
-
if (code !== undefined) {
|
|
796
|
-
response.code = code;
|
|
797
|
-
}
|
|
798
|
-
if (message !== undefined) {
|
|
799
|
-
response.message = message;
|
|
800
|
-
}
|
|
801
|
-
httpRes.json(response);
|
|
802
|
-
};
|
|
803
|
-
// Common HTTP error helpers (automatically set status code)
|
|
804
|
-
httpRes.unauthorized = (message = 'Authentication required') => {
|
|
805
|
-
httpRes.statusCode = 401;
|
|
806
|
-
httpRes.json({
|
|
807
|
-
success: false,
|
|
808
|
-
error: 'Unauthorized',
|
|
809
|
-
code: 'UNAUTHORIZED',
|
|
810
|
-
message,
|
|
811
|
-
});
|
|
812
|
-
};
|
|
813
|
-
httpRes.forbidden = (message = 'Insufficient permissions') => {
|
|
814
|
-
httpRes.statusCode = 403;
|
|
815
|
-
httpRes.json({
|
|
816
|
-
success: false,
|
|
817
|
-
error: 'Forbidden',
|
|
818
|
-
code: 'FORBIDDEN',
|
|
819
|
-
message,
|
|
820
|
-
});
|
|
821
|
-
};
|
|
822
|
-
httpRes.notFound = (resource = 'Resource') => {
|
|
823
|
-
httpRes.statusCode = 404;
|
|
824
|
-
httpRes.json({
|
|
825
|
-
success: false,
|
|
826
|
-
error: 'Not Found',
|
|
827
|
-
code: 'NOT_FOUND',
|
|
828
|
-
message: `${resource} not found`,
|
|
829
|
-
});
|
|
830
|
-
};
|
|
831
|
-
httpRes.badRequest = (message = 'Invalid request') => {
|
|
832
|
-
httpRes.statusCode = 400;
|
|
833
|
-
httpRes.json({
|
|
834
|
-
success: false,
|
|
835
|
-
error: 'Bad Request',
|
|
836
|
-
code: 'BAD_REQUEST',
|
|
837
|
-
message,
|
|
838
|
-
});
|
|
839
|
-
};
|
|
840
|
-
httpRes.conflict = (message) => {
|
|
841
|
-
httpRes.statusCode = 409;
|
|
842
|
-
httpRes.json({
|
|
843
|
-
success: false,
|
|
844
|
-
error: 'Conflict',
|
|
845
|
-
code: 'CONFLICT',
|
|
846
|
-
message,
|
|
847
|
-
});
|
|
848
|
-
};
|
|
849
|
-
httpRes.internalError = (message = 'Internal server error') => {
|
|
850
|
-
httpRes.statusCode = 500;
|
|
851
|
-
httpRes.json({
|
|
852
|
-
success: false,
|
|
853
|
-
error: 'Internal Server Error',
|
|
854
|
-
code: 'INTERNAL_ERROR',
|
|
855
|
-
message,
|
|
856
|
-
});
|
|
857
|
-
};
|
|
858
|
-
httpRes.validationError = (errors) => {
|
|
859
|
-
httpRes.statusCode = 422;
|
|
860
|
-
httpRes.json({
|
|
861
|
-
success: false,
|
|
862
|
-
error: 'Validation Failed',
|
|
863
|
-
code: 'VALIDATION_ERROR',
|
|
864
|
-
errors,
|
|
865
|
-
});
|
|
866
|
-
};
|
|
867
|
-
httpRes.rateLimited = (retryAfter) => {
|
|
868
|
-
httpRes.statusCode = 429;
|
|
869
|
-
if (retryAfter) {
|
|
870
|
-
httpRes.setHeader('Retry-After', retryAfter.toString());
|
|
871
|
-
}
|
|
872
|
-
httpRes.json({
|
|
873
|
-
success: false,
|
|
874
|
-
error: 'Rate Limit Exceeded',
|
|
875
|
-
code: 'RATE_LIMITED',
|
|
876
|
-
message: retryAfter
|
|
877
|
-
? `Too many requests. Retry after ${retryAfter} seconds.`
|
|
878
|
-
: 'Too many requests',
|
|
879
|
-
retryAfter,
|
|
880
|
-
});
|
|
881
|
-
};
|
|
882
|
-
// Common success patterns
|
|
883
|
-
httpRes.created = (data, location) => {
|
|
884
|
-
httpRes.statusCode = 201;
|
|
885
|
-
if (location) {
|
|
886
|
-
httpRes.setHeader('Location', location);
|
|
887
|
-
}
|
|
888
|
-
httpRes.json({
|
|
889
|
-
success: true,
|
|
890
|
-
data,
|
|
891
|
-
});
|
|
892
|
-
};
|
|
893
|
-
httpRes.noContent = () => {
|
|
894
|
-
httpRes.statusCode = 204;
|
|
895
|
-
httpRes.end();
|
|
896
|
-
};
|
|
897
|
-
httpRes.paginated = (data, pagination) => {
|
|
898
|
-
const totalPages = Math.ceil(pagination.total / pagination.limit);
|
|
899
|
-
httpRes.json({
|
|
900
|
-
success: true,
|
|
901
|
-
data,
|
|
902
|
-
pagination: {
|
|
903
|
-
page: pagination.page,
|
|
904
|
-
limit: pagination.limit,
|
|
905
|
-
total: pagination.total,
|
|
906
|
-
totalPages,
|
|
907
|
-
hasNext: pagination.page < totalPages,
|
|
908
|
-
hasPrev: pagination.page > 1,
|
|
909
|
-
},
|
|
910
|
-
});
|
|
911
|
-
};
|
|
912
|
-
// Header management utilities
|
|
913
|
-
httpRes.hasHeader = (name) => {
|
|
914
|
-
return httpRes.getHeader(name) !== undefined;
|
|
915
|
-
};
|
|
916
|
-
// Note: removeHeader is inherited from ServerResponse, we don't override it
|
|
917
|
-
httpRes.setBulkHeaders = (headers) => {
|
|
918
|
-
if (httpRes.headersSent) {
|
|
919
|
-
// Only enumerate keys for warning if headers were already sent
|
|
920
|
-
const attemptedHeaderKeys = [];
|
|
921
|
-
for (const key in headers) {
|
|
922
|
-
attemptedHeaderKeys.push(key);
|
|
923
|
-
}
|
|
924
|
-
this.logger.warn('Cannot set headers - headers already sent', 'HeaderWarning', {
|
|
925
|
-
attemptedHeaders: attemptedHeaderKeys,
|
|
926
|
-
});
|
|
927
|
-
return httpRes;
|
|
928
|
-
}
|
|
929
|
-
for (const key in headers) {
|
|
930
|
-
httpRes.setHeader(key, headers[key]);
|
|
931
|
-
}
|
|
932
|
-
return httpRes;
|
|
933
|
-
};
|
|
934
|
-
httpRes.appendHeader = (name, value) => {
|
|
935
|
-
if (httpRes.headersSent) {
|
|
936
|
-
this.logger.warn(`Cannot append to header '${name}' - headers already sent`, 'HeaderWarning');
|
|
937
|
-
return httpRes;
|
|
938
|
-
}
|
|
939
|
-
const existing = httpRes.getHeader(name);
|
|
940
|
-
if (existing) {
|
|
941
|
-
const values = Array.isArray(existing) ? existing : [existing.toString()];
|
|
942
|
-
const newValues = Array.isArray(value) ? value : [value];
|
|
943
|
-
httpRes.setHeader(name, [...values, ...newValues]);
|
|
944
|
-
}
|
|
945
|
-
else {
|
|
946
|
-
httpRes.setHeader(name, value);
|
|
947
|
-
}
|
|
948
|
-
return httpRes;
|
|
949
|
-
};
|
|
950
|
-
// Per-response typed state bag (Express-compatible)
|
|
951
|
-
httpRes.locals = {};
|
|
952
|
-
// Apply user-registered response decorations
|
|
953
|
-
for (const key in this.responseDecorations) {
|
|
954
|
-
httpRes[key] = this.responseDecorations[key];
|
|
955
|
-
}
|
|
956
|
-
// Express-compatible response helpers
|
|
957
|
-
httpRes.set = (field, value) => {
|
|
958
|
-
if (httpRes.headersSent)
|
|
959
|
-
return httpRes;
|
|
960
|
-
if (typeof field === 'string') {
|
|
961
|
-
if (value !== undefined) {
|
|
962
|
-
httpRes.setHeader(field, value);
|
|
963
|
-
}
|
|
964
|
-
}
|
|
965
|
-
else {
|
|
966
|
-
for (const key in field) {
|
|
967
|
-
httpRes.setHeader(key, field[key]);
|
|
968
|
-
}
|
|
969
|
-
}
|
|
970
|
-
return httpRes;
|
|
971
|
-
};
|
|
972
|
-
httpRes.get = (field) => {
|
|
973
|
-
return httpRes.getHeader(field);
|
|
974
|
-
};
|
|
975
|
-
httpRes.append = (field, value) => {
|
|
976
|
-
return httpRes.appendHeader(field, value);
|
|
977
|
-
};
|
|
978
|
-
httpRes.type = (contentType) => {
|
|
979
|
-
if (httpRes.headersSent)
|
|
980
|
-
return httpRes;
|
|
981
|
-
// If no "/" treat as shorthand (e.g. "json" → "application/json")
|
|
982
|
-
let ct = contentType;
|
|
983
|
-
if (ct.indexOf('/') === -1) {
|
|
984
|
-
const shorthands = {
|
|
985
|
-
json: 'application/json; charset=utf-8',
|
|
986
|
-
html: 'text/html; charset=utf-8',
|
|
987
|
-
text: 'text/plain; charset=utf-8',
|
|
988
|
-
txt: 'text/plain; charset=utf-8',
|
|
989
|
-
xml: 'application/xml',
|
|
990
|
-
js: 'application/javascript',
|
|
991
|
-
css: 'text/css; charset=utf-8',
|
|
992
|
-
form: 'application/x-www-form-urlencoded',
|
|
993
|
-
};
|
|
994
|
-
ct = shorthands[ct.toLowerCase()] || `application/${ct}`;
|
|
995
|
-
}
|
|
996
|
-
httpRes.setHeader('Content-Type', ct);
|
|
997
|
-
return httpRes;
|
|
998
|
-
};
|
|
999
|
-
httpRes.sendStatus = (code) => {
|
|
1000
|
-
if (httpRes.headersSent)
|
|
1001
|
-
return;
|
|
1002
|
-
httpRes.statusCode = code;
|
|
1003
|
-
const messages = {
|
|
1004
|
-
200: 'OK',
|
|
1005
|
-
201: 'Created',
|
|
1006
|
-
202: 'Accepted',
|
|
1007
|
-
204: 'No Content',
|
|
1008
|
-
301: 'Moved Permanently',
|
|
1009
|
-
302: 'Found',
|
|
1010
|
-
304: 'Not Modified',
|
|
1011
|
-
400: 'Bad Request',
|
|
1012
|
-
401: 'Unauthorized',
|
|
1013
|
-
403: 'Forbidden',
|
|
1014
|
-
404: 'Not Found',
|
|
1015
|
-
409: 'Conflict',
|
|
1016
|
-
410: 'Gone',
|
|
1017
|
-
422: 'Unprocessable Entity',
|
|
1018
|
-
429: 'Too Many Requests',
|
|
1019
|
-
500: 'Internal Server Error',
|
|
1020
|
-
502: 'Bad Gateway',
|
|
1021
|
-
503: 'Service Unavailable',
|
|
1022
|
-
};
|
|
1023
|
-
const body = messages[code] || String(code);
|
|
1024
|
-
httpRes.setHeader('Content-Type', 'text/plain; charset=utf-8');
|
|
1025
|
-
httpRes.end(body);
|
|
1026
|
-
};
|
|
1027
|
-
httpRes.location = (url) => {
|
|
1028
|
-
if (httpRes.headersSent)
|
|
1029
|
-
return httpRes;
|
|
1030
|
-
const safe = url.replace(/[\r\n]/g, '');
|
|
1031
|
-
httpRes.setHeader('Location', safe);
|
|
1032
|
-
return httpRes;
|
|
1033
|
-
};
|
|
1034
|
-
httpRes.vary = (field) => {
|
|
1035
|
-
if (httpRes.headersSent)
|
|
1036
|
-
return httpRes;
|
|
1037
|
-
const existing = httpRes.getHeader('Vary');
|
|
1038
|
-
const existingList = existing
|
|
1039
|
-
? String(existing)
|
|
1040
|
-
.split(',')
|
|
1041
|
-
.map(s => s.trim())
|
|
1042
|
-
.filter(Boolean)
|
|
1043
|
-
: [];
|
|
1044
|
-
const incoming = Array.isArray(field) ? field : [field];
|
|
1045
|
-
for (const f of incoming) {
|
|
1046
|
-
if (!existingList.some(e => e.toLowerCase() === f.toLowerCase())) {
|
|
1047
|
-
existingList.push(f);
|
|
1048
|
-
}
|
|
1049
|
-
}
|
|
1050
|
-
httpRes.setHeader('Vary', existingList.join(', '));
|
|
1051
|
-
return httpRes;
|
|
1052
|
-
};
|
|
1053
|
-
httpRes.attachment = (filename) => {
|
|
1054
|
-
if (httpRes.headersSent)
|
|
1055
|
-
return httpRes;
|
|
1056
|
-
if (filename) {
|
|
1057
|
-
const safeName = filename.replace(/[\r\n"]/g, '');
|
|
1058
|
-
httpRes.setHeader('Content-Disposition', `attachment; filename="${safeName}"`);
|
|
1059
|
-
// Set Content-Type from extension if not already set
|
|
1060
|
-
if (!httpRes.getHeader('Content-Type')) {
|
|
1061
|
-
// fire-and-forget mime lookup; not awaited to keep signature chainable
|
|
1062
|
-
this.getMimeType(filename.substring(filename.lastIndexOf('.'))).then(mime => {
|
|
1063
|
-
if (!httpRes.getHeader('Content-Type') && !httpRes.headersSent) {
|
|
1064
|
-
httpRes.setHeader('Content-Type', mime);
|
|
1065
|
-
}
|
|
1066
|
-
});
|
|
1067
|
-
}
|
|
1068
|
-
}
|
|
1069
|
-
else {
|
|
1070
|
-
httpRes.setHeader('Content-Disposition', 'attachment');
|
|
1071
|
-
}
|
|
1072
|
-
return httpRes;
|
|
1073
|
-
};
|
|
1074
|
-
httpRes.download = async (filePath, filename) => {
|
|
1075
|
-
httpRes.attachment(filename || filePath.split('/').pop());
|
|
1076
|
-
return httpRes.sendFile(filePath);
|
|
1077
|
-
};
|
|
1078
|
-
httpRes.format = (handlers) => {
|
|
1079
|
-
const types = Object.keys(handlers).filter(k => k !== 'default');
|
|
1080
|
-
const matched = req.accepts(types);
|
|
1081
|
-
const chosen = matched ? handlers[matched] : handlers.default;
|
|
1082
|
-
if (!chosen) {
|
|
1083
|
-
httpRes.statusCode = 406;
|
|
1084
|
-
httpRes.setHeader('Content-Type', 'text/plain; charset=utf-8');
|
|
1085
|
-
httpRes.end('Not Acceptable');
|
|
1086
|
-
return;
|
|
1087
|
-
}
|
|
1088
|
-
httpRes.vary('Accept');
|
|
1089
|
-
if (matched && typeof matched === 'string') {
|
|
1090
|
-
httpRes.type(matched);
|
|
1091
|
-
}
|
|
1092
|
-
chosen();
|
|
1093
|
-
};
|
|
1094
|
-
httpRes.links = (links) => {
|
|
1095
|
-
if (httpRes.headersSent)
|
|
1096
|
-
return httpRes;
|
|
1097
|
-
const parts = [];
|
|
1098
|
-
for (const rel in links) {
|
|
1099
|
-
parts.push(`<${links[rel]}>; rel="${rel}"`);
|
|
1100
|
-
}
|
|
1101
|
-
if (parts.length > 0) {
|
|
1102
|
-
const existing = httpRes.getHeader('Link');
|
|
1103
|
-
const combined = existing ? `${existing}, ${parts.join(', ')}` : parts.join(', ');
|
|
1104
|
-
httpRes.setHeader('Link', combined);
|
|
1105
|
-
}
|
|
1106
|
-
return httpRes;
|
|
1107
|
-
};
|
|
1108
|
-
// Response state utilities
|
|
1109
|
-
httpRes.canSetHeaders = () => {
|
|
1110
|
-
return !httpRes.headersSent;
|
|
1111
|
-
};
|
|
1112
|
-
httpRes.getResponseState = () => {
|
|
1113
|
-
return {
|
|
1114
|
-
headersSent: httpRes.headersSent,
|
|
1115
|
-
statusCode: httpRes.statusCode,
|
|
1116
|
-
headers: httpRes.getHeaders ? httpRes.getHeaders() : {},
|
|
1117
|
-
finished: httpRes.finished || false,
|
|
1118
|
-
writable: httpRes.writable,
|
|
1119
|
-
};
|
|
1120
|
-
};
|
|
1121
|
-
return httpRes;
|
|
1122
|
-
}
|
|
1324
|
+
/** @internal called by MoroServerResponse prototype methods */
|
|
1123
1325
|
async getMimeType(ext) {
|
|
1124
1326
|
const mimeTypes = {
|
|
1125
1327
|
'.html': 'text/html',
|
|
@@ -1138,6 +1340,7 @@ export class MoroHttpServer {
|
|
|
1138
1340
|
};
|
|
1139
1341
|
return mimeTypes[ext.toLowerCase()] || 'application/octet-stream';
|
|
1140
1342
|
}
|
|
1343
|
+
/** @internal called by MoroServerResponse prototype methods */
|
|
1141
1344
|
addCharsetIfNeeded(mimeType) {
|
|
1142
1345
|
// Add charset for text-based content types - optimized with early exit
|
|
1143
1346
|
// Check most common cases first
|
|
@@ -1161,6 +1364,14 @@ export class MoroHttpServer {
|
|
|
1161
1364
|
const maxSize = isMultipart
|
|
1162
1365
|
? this.maxUploadSize // Configurable file upload limit (default 100MB)
|
|
1163
1366
|
: this.maxBodySize; // Configurable body size limit (default 10MB, from server.bodySizeLimit config)
|
|
1367
|
+
// Early rejection from the declared Content-Length - nothing is read off the wire
|
|
1368
|
+
if (contentLength > maxSize) {
|
|
1369
|
+
const error = new Error('Request entity too large');
|
|
1370
|
+
error.statusCode = 413;
|
|
1371
|
+
error.limit = maxSize;
|
|
1372
|
+
error.received = contentLength;
|
|
1373
|
+
return Promise.reject(error);
|
|
1374
|
+
}
|
|
1164
1375
|
// For very large payloads, return a streaming interface instead of buffering
|
|
1165
1376
|
if (contentLength > maxSize / 2) {
|
|
1166
1377
|
// Stream for payloads > 50MB (uploads) or > 5MB (JSON/form)
|
|
@@ -1176,14 +1387,19 @@ export class MoroHttpServer {
|
|
|
1176
1387
|
const errorMessage = isMultipart
|
|
1177
1388
|
? `File upload too large. Maximum ${Math.floor(maxSize / (1024 * 1024))}MB allowed.`
|
|
1178
1389
|
: 'Request body too large';
|
|
1179
|
-
|
|
1390
|
+
const error = new Error(errorMessage);
|
|
1391
|
+
error.statusCode = 413;
|
|
1392
|
+
error.limit = maxSize;
|
|
1393
|
+
reject(error);
|
|
1180
1394
|
return;
|
|
1181
1395
|
}
|
|
1182
1396
|
chunks.push(chunk);
|
|
1183
1397
|
});
|
|
1184
1398
|
req.on('end', () => {
|
|
1185
1399
|
try {
|
|
1186
|
-
|
|
1400
|
+
// Single-chunk fast path (most JSON bodies arrive in one chunk);
|
|
1401
|
+
// otherwise concat with the known total length to avoid a re-scan
|
|
1402
|
+
const body = chunks.length === 1 ? chunks[0] : Buffer.concat(chunks, totalLength);
|
|
1187
1403
|
if (contentType.includes('application/json')) {
|
|
1188
1404
|
resolve(JSON.parse(body.toString()));
|
|
1189
1405
|
}
|
|
@@ -1410,30 +1626,14 @@ export class MoroHttpServer {
|
|
|
1410
1626
|
}
|
|
1411
1627
|
return result;
|
|
1412
1628
|
}
|
|
1413
|
-
// Legacy
|
|
1629
|
+
// Legacy methods for backward compatibility - the hot path now parses query
|
|
1630
|
+
// strings lazily via the MoroIncomingMessage#query getter (no pooling: a fresh
|
|
1631
|
+
// object literal is cheaper than pool bookkeeping)
|
|
1414
1632
|
parseQueryString(queryString) {
|
|
1415
|
-
return
|
|
1633
|
+
return parseQueryString(queryString);
|
|
1416
1634
|
}
|
|
1417
|
-
// Optimized query string parser with object pooling
|
|
1418
1635
|
parseQueryStringPooled(queryString) {
|
|
1419
|
-
|
|
1420
|
-
return {};
|
|
1421
|
-
const result = this.poolManager.acquireQuery();
|
|
1422
|
-
const pairs = queryString.split('&');
|
|
1423
|
-
const pairsLen = pairs.length;
|
|
1424
|
-
for (let i = 0; i < pairsLen; i++) {
|
|
1425
|
-
const pair = pairs[i];
|
|
1426
|
-
const equalIndex = pair.indexOf('=');
|
|
1427
|
-
if (equalIndex === -1) {
|
|
1428
|
-
result[decodeURIComponent(pair)] = '';
|
|
1429
|
-
}
|
|
1430
|
-
else {
|
|
1431
|
-
const key = decodeURIComponent(pair.substring(0, equalIndex));
|
|
1432
|
-
const value = decodeURIComponent(pair.substring(equalIndex + 1));
|
|
1433
|
-
result[key] = value;
|
|
1434
|
-
}
|
|
1435
|
-
}
|
|
1436
|
-
return result;
|
|
1636
|
+
return parseQueryString(queryString);
|
|
1437
1637
|
}
|
|
1438
1638
|
// Advanced route optimization: cache + static routes + segment grouping
|
|
1439
1639
|
routeCache = new Map();
|
|
@@ -1498,13 +1698,23 @@ export class MoroHttpServer {
|
|
|
1498
1698
|
// Middleware execution with Express-compatible error propagation.
|
|
1499
1699
|
// Supports: 3-arg (req, res, next), 4-arg (err, req, res, next) error middlewares,
|
|
1500
1700
|
// and next(err) to skip forward to the next error middleware.
|
|
1501
|
-
|
|
1701
|
+
//
|
|
1702
|
+
// Sync-aware dispatch: middleware that calls next() synchronously (or throws
|
|
1703
|
+
// synchronously) advances the chain in a plain loop with ZERO promise
|
|
1704
|
+
// allocations. A promise is only created when a middleware actually completes
|
|
1705
|
+
// asynchronously. Returns undefined when the whole chain ran synchronously.
|
|
1706
|
+
executeMiddleware(middleware, req, res) {
|
|
1707
|
+
return this.dispatchMiddleware(middleware, req, res, 0, undefined);
|
|
1708
|
+
}
|
|
1709
|
+
dispatchMiddleware(middleware, req, res, startIndex, initialError) {
|
|
1502
1710
|
const len = middleware.length;
|
|
1503
|
-
let activeError =
|
|
1504
|
-
|
|
1711
|
+
let activeError = initialError;
|
|
1712
|
+
let i = startIndex;
|
|
1713
|
+
while (i < len) {
|
|
1505
1714
|
if (res.headersSent)
|
|
1506
1715
|
return;
|
|
1507
1716
|
const mw = middleware[i];
|
|
1717
|
+
i++;
|
|
1508
1718
|
const isErrorHandler = mw.length >= 4;
|
|
1509
1719
|
// Non-error middleware is skipped while an error is active; error middleware
|
|
1510
1720
|
// is skipped while no error is active. Matches Express semantics.
|
|
@@ -1512,36 +1722,60 @@ export class MoroHttpServer {
|
|
|
1512
1722
|
continue;
|
|
1513
1723
|
if (activeError === undefined && isErrorHandler)
|
|
1514
1724
|
continue;
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
});
|
|
1535
|
-
}
|
|
1536
|
-
else if (!resolved) {
|
|
1537
|
-
next();
|
|
1538
|
-
}
|
|
1725
|
+
let settled = false;
|
|
1726
|
+
let settledError = undefined;
|
|
1727
|
+
let asyncResolve;
|
|
1728
|
+
const next = (err) => {
|
|
1729
|
+
if (settled)
|
|
1730
|
+
return;
|
|
1731
|
+
settled = true;
|
|
1732
|
+
settledError = err;
|
|
1733
|
+
if (asyncResolve)
|
|
1734
|
+
asyncResolve(err);
|
|
1735
|
+
};
|
|
1736
|
+
let result;
|
|
1737
|
+
try {
|
|
1738
|
+
result = isErrorHandler ? mw(activeError, req, res, next) : mw(req, res, next);
|
|
1739
|
+
}
|
|
1740
|
+
catch (err) {
|
|
1741
|
+
if (!settled) {
|
|
1742
|
+
settled = true;
|
|
1743
|
+
settledError = err;
|
|
1539
1744
|
}
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1745
|
+
}
|
|
1746
|
+
const isThenable = result && typeof result.then === 'function';
|
|
1747
|
+
if (settled) {
|
|
1748
|
+
// Completed synchronously (next() called sync, or threw sync).
|
|
1749
|
+
// Matches the old behavior: a still-pending returned promise no longer
|
|
1750
|
+
// gates the chain once next() has been called; swallow late rejections.
|
|
1751
|
+
if (isThenable) {
|
|
1752
|
+
result.then(undefined, () => { });
|
|
1543
1753
|
}
|
|
1544
|
-
|
|
1754
|
+
activeError = settledError;
|
|
1755
|
+
continue;
|
|
1756
|
+
}
|
|
1757
|
+
if (!isThenable) {
|
|
1758
|
+
// Sync-looking middleware that neither called next() nor returned a
|
|
1759
|
+
// promise: it will call next() later (e.g. from an event callback).
|
|
1760
|
+
// Fall back to a promise for the remainder of the chain.
|
|
1761
|
+
const resumeIndex = i;
|
|
1762
|
+
return new Promise(resolve => {
|
|
1763
|
+
asyncResolve = resolve;
|
|
1764
|
+
}).then(err => this.dispatchMiddleware(middleware, req, res, resumeIndex, err));
|
|
1765
|
+
}
|
|
1766
|
+
// Async middleware: completion is next() OR promise settle, whichever
|
|
1767
|
+
// happens first (same semantics as the previous implementation).
|
|
1768
|
+
const resumeIndex = i;
|
|
1769
|
+
return new Promise(resolve => {
|
|
1770
|
+
asyncResolve = resolve;
|
|
1771
|
+
result.then(() => {
|
|
1772
|
+
if (!settled)
|
|
1773
|
+
next();
|
|
1774
|
+
}, (err) => {
|
|
1775
|
+
if (!settled)
|
|
1776
|
+
next(err);
|
|
1777
|
+
});
|
|
1778
|
+
}).then(err => this.dispatchMiddleware(middleware, req, res, resumeIndex, err));
|
|
1545
1779
|
}
|
|
1546
1780
|
// If an error is still unhandled after the chain, re-throw so the top-level
|
|
1547
1781
|
// catch in handleRequest can invoke the registered errorHandler / default 500.
|