@morojs/moro 1.7.28 → 1.7.30
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/schema.js +3 -1
- package/dist/core/config/schema.js.map +1 -1
- package/dist/core/framework.d.ts +1 -1
- package/dist/core/framework.js +42 -28
- package/dist/core/framework.js.map +1 -1
- package/dist/core/http/http-server.d.ts +116 -12
- package/dist/core/http/http-server.js +1171 -862
- 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 +2 -0
- package/dist/core/logger/logger.js +32 -6
- 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 +1 -0
- package/dist/moro.js +44 -68
- package/dist/moro.js.map +1 -1
- package/package.json +1 -1
|
@@ -1,25 +1,893 @@
|
|
|
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
|
|
100
|
+
// stable shape). Everything else lives behind the single `_lazy` slot -
|
|
101
|
+
// per-request construction pays 5 field initializations instead of 16.
|
|
102
|
+
params = {};
|
|
103
|
+
body = null;
|
|
104
|
+
path = '';
|
|
105
|
+
_queryString = null;
|
|
106
|
+
// Single slot for every lazily-computed/assigned helper value (query,
|
|
107
|
+
// cookies, requestId, hostname, ...). Allocated on first access - a plain
|
|
108
|
+
// JSON GET that never touches the helpers never allocates it, and the
|
|
109
|
+
// request object keeps one stable hidden-class shape either way.
|
|
110
|
+
_lazy = undefined;
|
|
111
|
+
get query() {
|
|
112
|
+
const L = this._lazy ?? (this._lazy = {});
|
|
113
|
+
let q = L.query;
|
|
114
|
+
if (q === undefined) {
|
|
115
|
+
const qs = this._queryString;
|
|
116
|
+
q = qs ? parseQueryString(qs) : {};
|
|
117
|
+
L.query = q;
|
|
118
|
+
}
|
|
119
|
+
return q;
|
|
120
|
+
}
|
|
121
|
+
set query(value) {
|
|
122
|
+
(this._lazy ?? (this._lazy = {})).query = value;
|
|
123
|
+
}
|
|
124
|
+
get cookies() {
|
|
125
|
+
const L = this._lazy ?? (this._lazy = {});
|
|
126
|
+
let c = L.cookies;
|
|
127
|
+
if (c === undefined) {
|
|
128
|
+
const header = this.headers.cookie;
|
|
129
|
+
c = header ? parseCookieHeader(header) : {};
|
|
130
|
+
L.cookies = c;
|
|
131
|
+
}
|
|
132
|
+
return c;
|
|
133
|
+
}
|
|
134
|
+
set cookies(value) {
|
|
135
|
+
(this._lazy ?? (this._lazy = {})).cookies = value;
|
|
136
|
+
}
|
|
137
|
+
get context() {
|
|
138
|
+
const L = this._lazy ?? (this._lazy = {});
|
|
139
|
+
let ctx = L.context;
|
|
140
|
+
if (ctx === undefined) {
|
|
141
|
+
ctx = {};
|
|
142
|
+
L.context = ctx;
|
|
143
|
+
}
|
|
144
|
+
return ctx;
|
|
145
|
+
}
|
|
146
|
+
set context(value) {
|
|
147
|
+
(this._lazy ?? (this._lazy = {})).context = value;
|
|
148
|
+
}
|
|
149
|
+
get requestId() {
|
|
150
|
+
const L = this._lazy ?? (this._lazy = {});
|
|
151
|
+
let id = L.requestId;
|
|
152
|
+
if (id === undefined) {
|
|
153
|
+
const server = this._server;
|
|
154
|
+
id = server && server.requestTrackingEnabled ? randomUUID() : '';
|
|
155
|
+
L.requestId = id;
|
|
156
|
+
}
|
|
157
|
+
return id;
|
|
158
|
+
}
|
|
159
|
+
set requestId(value) {
|
|
160
|
+
(this._lazy ?? (this._lazy = {})).requestId = value;
|
|
161
|
+
}
|
|
162
|
+
get ip() {
|
|
163
|
+
const L = this._lazy ?? (this._lazy = {});
|
|
164
|
+
let v = L.ip;
|
|
165
|
+
if (v === undefined) {
|
|
166
|
+
v = (this.socket && this.socket.remoteAddress) || '';
|
|
167
|
+
L.ip = v;
|
|
168
|
+
}
|
|
169
|
+
return v;
|
|
170
|
+
}
|
|
171
|
+
set ip(value) {
|
|
172
|
+
(this._lazy ?? (this._lazy = {})).ip = value;
|
|
173
|
+
}
|
|
174
|
+
get originalUrl() {
|
|
175
|
+
const v = this._lazy?.originalUrl;
|
|
176
|
+
return v !== undefined ? v : this.url || '';
|
|
177
|
+
}
|
|
178
|
+
set originalUrl(value) {
|
|
179
|
+
(this._lazy ?? (this._lazy = {})).originalUrl = value;
|
|
180
|
+
}
|
|
181
|
+
get hostname() {
|
|
182
|
+
const L = this._lazy ?? (this._lazy = {});
|
|
183
|
+
let v = L.hostname;
|
|
184
|
+
if (v === undefined) {
|
|
185
|
+
const host = (this.headers.host || '');
|
|
186
|
+
v = host ? host.split(':')[0] : '';
|
|
187
|
+
L.hostname = v;
|
|
188
|
+
}
|
|
189
|
+
return v;
|
|
190
|
+
}
|
|
191
|
+
set hostname(value) {
|
|
192
|
+
(this._lazy ?? (this._lazy = {})).hostname = value;
|
|
193
|
+
}
|
|
194
|
+
get protocol() {
|
|
195
|
+
const L = this._lazy ?? (this._lazy = {});
|
|
196
|
+
let v = L.protocol;
|
|
197
|
+
if (v === undefined) {
|
|
198
|
+
const forwardedProto = this.headers['x-forwarded-proto'];
|
|
199
|
+
v = forwardedProto
|
|
200
|
+
? forwardedProto.split(',')[0].trim()
|
|
201
|
+
: this.socket?.encrypted
|
|
202
|
+
? 'https'
|
|
203
|
+
: 'http';
|
|
204
|
+
L.protocol = v;
|
|
205
|
+
}
|
|
206
|
+
return v;
|
|
207
|
+
}
|
|
208
|
+
set protocol(value) {
|
|
209
|
+
(this._lazy ?? (this._lazy = {})).protocol = value;
|
|
210
|
+
}
|
|
211
|
+
get secure() {
|
|
212
|
+
const v = this._lazy?.secure;
|
|
213
|
+
return v !== undefined ? v : this.protocol === 'https';
|
|
214
|
+
}
|
|
215
|
+
set secure(value) {
|
|
216
|
+
(this._lazy ?? (this._lazy = {})).secure = value;
|
|
217
|
+
}
|
|
218
|
+
get xhr() {
|
|
219
|
+
const L = this._lazy ?? (this._lazy = {});
|
|
220
|
+
let v = L.xhr;
|
|
221
|
+
if (v === undefined) {
|
|
222
|
+
const xrw = this.headers['x-requested-with'];
|
|
223
|
+
v = !!xrw && xrw.toLowerCase() === 'xmlhttprequest';
|
|
224
|
+
L.xhr = v;
|
|
225
|
+
}
|
|
226
|
+
return v;
|
|
227
|
+
}
|
|
228
|
+
set xhr(value) {
|
|
229
|
+
(this._lazy ?? (this._lazy = {})).xhr = value;
|
|
230
|
+
}
|
|
231
|
+
get ips() {
|
|
232
|
+
const L = this._lazy ?? (this._lazy = {});
|
|
233
|
+
let v = L.ips;
|
|
234
|
+
if (v === undefined) {
|
|
235
|
+
const forwardedFor = this.headers['x-forwarded-for'];
|
|
236
|
+
v = forwardedFor
|
|
237
|
+
? forwardedFor
|
|
238
|
+
.split(',')
|
|
239
|
+
.map(s => s.trim())
|
|
240
|
+
.filter(Boolean)
|
|
241
|
+
: [];
|
|
242
|
+
L.ips = v;
|
|
243
|
+
}
|
|
244
|
+
return v;
|
|
245
|
+
}
|
|
246
|
+
set ips(value) {
|
|
247
|
+
(this._lazy ?? (this._lazy = {})).ips = value;
|
|
248
|
+
}
|
|
249
|
+
get subdomains() {
|
|
250
|
+
const L = this._lazy ?? (this._lazy = {});
|
|
251
|
+
let v = L.subdomains;
|
|
252
|
+
if (v === undefined) {
|
|
253
|
+
const hostnameParts = this.hostname.split('.');
|
|
254
|
+
// Matches Express: subdomains are parts before the last two (domain + tld),
|
|
255
|
+
// reversed (closest subdomain first).
|
|
256
|
+
v = hostnameParts.length > 2 ? hostnameParts.slice(0, -2).reverse() : [];
|
|
257
|
+
L.subdomains = v;
|
|
258
|
+
}
|
|
259
|
+
return v;
|
|
260
|
+
}
|
|
261
|
+
set subdomains(value) {
|
|
262
|
+
(this._lazy ?? (this._lazy = {})).subdomains = value;
|
|
263
|
+
}
|
|
264
|
+
// Express-compatible request helpers (shared prototype methods, not closures)
|
|
265
|
+
get(name) {
|
|
266
|
+
const lower = name.toLowerCase();
|
|
267
|
+
if (lower === 'referer' || lower === 'referrer') {
|
|
268
|
+
return (this.headers.referer || this.headers.referrer);
|
|
269
|
+
}
|
|
270
|
+
const v = this.headers[lower];
|
|
271
|
+
return Array.isArray(v) ? v[0] : v;
|
|
272
|
+
}
|
|
273
|
+
header(name) {
|
|
274
|
+
return this.get(name);
|
|
275
|
+
}
|
|
276
|
+
is(type) {
|
|
277
|
+
const ct = (this.headers['content-type'] || '');
|
|
278
|
+
if (!ct)
|
|
279
|
+
return false;
|
|
280
|
+
const mime = ct.split(';')[0].trim().toLowerCase();
|
|
281
|
+
const t = type.toLowerCase();
|
|
282
|
+
if (t.indexOf('/') === -1) {
|
|
283
|
+
// e.g. 'json' → match */json or +json suffix
|
|
284
|
+
return mime.endsWith(`/${t}`) || mime.endsWith(`+${t}`);
|
|
285
|
+
}
|
|
286
|
+
if (t.endsWith('/*')) {
|
|
287
|
+
return mime.startsWith(t.slice(0, -1));
|
|
288
|
+
}
|
|
289
|
+
return mime === t;
|
|
290
|
+
}
|
|
291
|
+
accepts(types) {
|
|
292
|
+
const accept = (this.headers.accept || '*/*');
|
|
293
|
+
if (!types)
|
|
294
|
+
return accept;
|
|
295
|
+
const wanted = Array.isArray(types) ? types : [types];
|
|
296
|
+
if (accept === '*/*' || accept === '')
|
|
297
|
+
return wanted[0] || false;
|
|
298
|
+
const acceptTypes = accept.split(',').map(s => s.split(';')[0].trim().toLowerCase());
|
|
299
|
+
for (const w of wanted) {
|
|
300
|
+
const wl = w.toLowerCase();
|
|
301
|
+
const wMime = wl.indexOf('/') === -1 ? `application/${wl}` : wl;
|
|
302
|
+
for (const at of acceptTypes) {
|
|
303
|
+
if (at === '*/*')
|
|
304
|
+
return w;
|
|
305
|
+
if (at === wMime)
|
|
306
|
+
return w;
|
|
307
|
+
if (at.endsWith('/*') && wMime.startsWith(at.slice(0, -1)))
|
|
308
|
+
return w;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
return false;
|
|
312
|
+
}
|
|
313
|
+
acceptsLanguages(langs) {
|
|
314
|
+
const acceptLang = (this.headers['accept-language'] || '');
|
|
315
|
+
if (!langs)
|
|
316
|
+
return acceptLang || false;
|
|
317
|
+
const wanted = Array.isArray(langs) ? langs : [langs];
|
|
318
|
+
if (!acceptLang)
|
|
319
|
+
return wanted[0] || false;
|
|
320
|
+
// Parse with q values; sort by q desc to match Express's "best match wins"
|
|
321
|
+
const accepted = acceptLang
|
|
322
|
+
.split(',')
|
|
323
|
+
.map(s => {
|
|
324
|
+
const parts = s.trim().split(';');
|
|
325
|
+
const tag = parts[0].toLowerCase();
|
|
326
|
+
const qPart = parts.find(p => p.trim().startsWith('q='));
|
|
327
|
+
const q = qPart ? parseFloat(qPart.trim().slice(2)) : 1;
|
|
328
|
+
return { tag, q: isNaN(q) ? 0 : q };
|
|
329
|
+
})
|
|
330
|
+
.filter(a => a.q > 0)
|
|
331
|
+
.sort((a, b) => b.q - a.q);
|
|
332
|
+
for (const { tag } of accepted) {
|
|
333
|
+
for (const w of wanted) {
|
|
334
|
+
const wl = w.toLowerCase();
|
|
335
|
+
if (tag === '*' || tag === wl || tag.split('-')[0] === wl.split('-')[0])
|
|
336
|
+
return w;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
return false;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
export class MoroServerResponse extends ServerResponse {
|
|
343
|
+
_locals = undefined;
|
|
344
|
+
get locals() {
|
|
345
|
+
let l = this._locals;
|
|
346
|
+
if (l === undefined) {
|
|
347
|
+
l = {};
|
|
348
|
+
this._locals = l;
|
|
349
|
+
}
|
|
350
|
+
return l;
|
|
351
|
+
}
|
|
352
|
+
set locals(value) {
|
|
353
|
+
this._locals = value;
|
|
354
|
+
}
|
|
355
|
+
status(code) {
|
|
356
|
+
this.statusCode = code;
|
|
357
|
+
return this;
|
|
358
|
+
}
|
|
359
|
+
json(data) {
|
|
360
|
+
if (this.headersSent)
|
|
361
|
+
return;
|
|
362
|
+
const jsonString = JSON.stringify(data);
|
|
363
|
+
const server = this._server;
|
|
364
|
+
// Compression - EARLY EXIT if disabled or below threshold
|
|
365
|
+
if (server && server.compressionEnabled && jsonString.length > server.compressionThreshold) {
|
|
366
|
+
const acceptEncoding = this.req?.headers['accept-encoding'];
|
|
367
|
+
if (acceptEncoding && acceptEncoding.includes('gzip')) {
|
|
368
|
+
const buffer = Buffer.from(jsonString, 'utf8');
|
|
369
|
+
gzip(buffer)
|
|
370
|
+
.then(compressed => {
|
|
371
|
+
if (this.headersSent)
|
|
372
|
+
return;
|
|
373
|
+
this.writeHead(this.statusCode || 200, {
|
|
374
|
+
'Content-Type': JSON_CONTENT_TYPE,
|
|
375
|
+
'Content-Encoding': 'gzip',
|
|
376
|
+
Vary: 'Accept-Encoding',
|
|
377
|
+
'Content-Length': compressed.length,
|
|
378
|
+
});
|
|
379
|
+
this.end(compressed);
|
|
380
|
+
})
|
|
381
|
+
.catch(err => {
|
|
382
|
+
server.logger.error('Response compression failed', 'HttpServer', {
|
|
383
|
+
error: err instanceof Error ? err.message : String(err),
|
|
384
|
+
});
|
|
385
|
+
if (!this.headersSent) {
|
|
386
|
+
this.writeHead(this.statusCode || 200, {
|
|
387
|
+
'Content-Type': JSON_CONTENT_TYPE,
|
|
388
|
+
'Content-Length': Buffer.byteLength(jsonString),
|
|
389
|
+
});
|
|
390
|
+
this.end(jsonString);
|
|
391
|
+
}
|
|
392
|
+
else {
|
|
393
|
+
this.end();
|
|
394
|
+
}
|
|
395
|
+
});
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
else if (acceptEncoding && acceptEncoding.includes('deflate')) {
|
|
399
|
+
const buffer = Buffer.from(jsonString, 'utf8');
|
|
400
|
+
deflate(buffer)
|
|
401
|
+
.then(compressed => {
|
|
402
|
+
if (this.headersSent)
|
|
403
|
+
return;
|
|
404
|
+
this.writeHead(this.statusCode || 200, {
|
|
405
|
+
'Content-Type': JSON_CONTENT_TYPE,
|
|
406
|
+
'Content-Encoding': 'deflate',
|
|
407
|
+
Vary: 'Accept-Encoding',
|
|
408
|
+
'Content-Length': compressed.length,
|
|
409
|
+
});
|
|
410
|
+
this.end(compressed);
|
|
411
|
+
})
|
|
412
|
+
.catch(err => {
|
|
413
|
+
server.logger.error('Response compression failed', 'HttpServer', {
|
|
414
|
+
error: err instanceof Error ? err.message : String(err),
|
|
415
|
+
});
|
|
416
|
+
if (!this.headersSent) {
|
|
417
|
+
this.writeHead(this.statusCode || 200, {
|
|
418
|
+
'Content-Type': JSON_CONTENT_TYPE,
|
|
419
|
+
'Content-Length': Buffer.byteLength(jsonString),
|
|
420
|
+
});
|
|
421
|
+
this.end(jsonString);
|
|
422
|
+
}
|
|
423
|
+
else {
|
|
424
|
+
this.end();
|
|
425
|
+
}
|
|
426
|
+
});
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
// SYNC PATH - no compression: single serialize, single write, no Buffer copy
|
|
431
|
+
this.writeHead(this.statusCode || 200, {
|
|
432
|
+
'Content-Type': JSON_CONTENT_TYPE,
|
|
433
|
+
'Content-Length': Buffer.byteLength(jsonString),
|
|
434
|
+
});
|
|
435
|
+
this.end(jsonString);
|
|
436
|
+
}
|
|
437
|
+
send(data) {
|
|
438
|
+
if (this.headersSent)
|
|
439
|
+
return;
|
|
440
|
+
// Auto-detect content type if not already set
|
|
441
|
+
if (!this.getHeader('Content-Type')) {
|
|
442
|
+
if (typeof data === 'string') {
|
|
443
|
+
// Cheap JSON sniff: first non-whitespace char is '{', '[' or '"'
|
|
444
|
+
// (replaces a full JSON.parse of the body just to pick a content type)
|
|
445
|
+
let i = 0;
|
|
446
|
+
const len = data.length;
|
|
447
|
+
while (i < len) {
|
|
448
|
+
const c = data.charCodeAt(i);
|
|
449
|
+
if (c !== 32 && c !== 9 && c !== 10 && c !== 13)
|
|
450
|
+
break;
|
|
451
|
+
i++;
|
|
452
|
+
}
|
|
453
|
+
const first = i < len ? data.charCodeAt(i) : 0;
|
|
454
|
+
if (first === 123 /* { */ || first === 91 /* [ */ || first === 34 /* " */) {
|
|
455
|
+
this.setHeader('Content-Type', JSON_CONTENT_TYPE);
|
|
456
|
+
}
|
|
457
|
+
else {
|
|
458
|
+
this.setHeader('Content-Type', 'text/plain; charset=utf-8');
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
else {
|
|
462
|
+
this.setHeader('Content-Type', 'application/octet-stream');
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
this.end(data);
|
|
466
|
+
}
|
|
467
|
+
cookie(name, value, options = {}) {
|
|
468
|
+
if (this.headersSent) {
|
|
469
|
+
const isCritical = options.critical ||
|
|
470
|
+
name.includes('session') ||
|
|
471
|
+
name.includes('auth') ||
|
|
472
|
+
name.includes('csrf');
|
|
473
|
+
const message = `Cookie '${name}' could not be set - headers already sent`;
|
|
474
|
+
if (isCritical || options.throwOnLateSet) {
|
|
475
|
+
throw new Error(`${message}. This may cause authentication or security issues.`);
|
|
476
|
+
}
|
|
477
|
+
else {
|
|
478
|
+
this._server?.logger.warn(message, 'CookieWarning', {
|
|
479
|
+
cookieName: name,
|
|
480
|
+
critical: isCritical,
|
|
481
|
+
stackTrace: new Error().stack,
|
|
482
|
+
});
|
|
483
|
+
}
|
|
484
|
+
return this;
|
|
485
|
+
}
|
|
486
|
+
const cookieValue = encodeURIComponent(value);
|
|
487
|
+
let cookieString = `${name}=${cookieValue}`;
|
|
488
|
+
if (options.maxAge)
|
|
489
|
+
cookieString += `; Max-Age=${options.maxAge}`;
|
|
490
|
+
if (options.expires)
|
|
491
|
+
cookieString += `; Expires=${options.expires.toUTCString()}`;
|
|
492
|
+
if (options.httpOnly)
|
|
493
|
+
cookieString += '; HttpOnly';
|
|
494
|
+
if (options.secure)
|
|
495
|
+
cookieString += '; Secure';
|
|
496
|
+
if (options.sameSite)
|
|
497
|
+
cookieString += `; SameSite=${options.sameSite}`;
|
|
498
|
+
if (options.domain)
|
|
499
|
+
cookieString += `; Domain=${options.domain}`;
|
|
500
|
+
if (options.path)
|
|
501
|
+
cookieString += `; Path=${options.path}`;
|
|
502
|
+
const existingCookies = this.getHeader('Set-Cookie') || [];
|
|
503
|
+
// Avoid spread operator - direct array manipulation
|
|
504
|
+
const cookies = Array.isArray(existingCookies) ? existingCookies : [existingCookies];
|
|
505
|
+
cookies.push(cookieString);
|
|
506
|
+
this.setHeader('Set-Cookie', cookies);
|
|
507
|
+
return this;
|
|
508
|
+
}
|
|
509
|
+
clearCookie(name, options = {}) {
|
|
510
|
+
// Avoid spread operator - manually set properties
|
|
511
|
+
const clearOptions = {
|
|
512
|
+
expires: new Date(0),
|
|
513
|
+
maxAge: 0,
|
|
514
|
+
};
|
|
515
|
+
// Copy other options manually
|
|
516
|
+
if (options.path !== undefined)
|
|
517
|
+
clearOptions.path = options.path;
|
|
518
|
+
if (options.domain !== undefined)
|
|
519
|
+
clearOptions.domain = options.domain;
|
|
520
|
+
if (options.httpOnly !== undefined)
|
|
521
|
+
clearOptions.httpOnly = options.httpOnly;
|
|
522
|
+
if (options.secure !== undefined)
|
|
523
|
+
clearOptions.secure = options.secure;
|
|
524
|
+
if (options.sameSite !== undefined)
|
|
525
|
+
clearOptions.sameSite = options.sameSite;
|
|
526
|
+
return this.cookie(name, '', clearOptions);
|
|
527
|
+
}
|
|
528
|
+
redirect(url, status = 302) {
|
|
529
|
+
if (this.headersSent)
|
|
530
|
+
return;
|
|
531
|
+
this.statusCode = status;
|
|
532
|
+
const safeUrl = url.replace(/[\r\n]/g, '');
|
|
533
|
+
this.setHeader('Location', safeUrl);
|
|
534
|
+
this.end();
|
|
535
|
+
}
|
|
536
|
+
async sendFile(filePath) {
|
|
537
|
+
if (this.headersSent)
|
|
538
|
+
return;
|
|
539
|
+
try {
|
|
540
|
+
const fs = await import('fs/promises');
|
|
541
|
+
const path = await import('path');
|
|
542
|
+
const extension = path.extname(filePath);
|
|
543
|
+
const mime = await this._server.getMimeType(extension);
|
|
544
|
+
const stats = await fs.stat(filePath);
|
|
545
|
+
const data = await fs.readFile(filePath);
|
|
546
|
+
// Add charset for text-based files
|
|
547
|
+
const contentType = this._server.addCharsetIfNeeded(mime);
|
|
548
|
+
this.setHeader('Content-Type', contentType);
|
|
549
|
+
this.setHeader('Content-Length', stats.size);
|
|
550
|
+
// Add security headers for file downloads
|
|
551
|
+
this.setHeader('X-Content-Type-Options', 'nosniff');
|
|
552
|
+
// Add caching headers
|
|
553
|
+
this.setHeader('Last-Modified', stats.mtime.toUTCString());
|
|
554
|
+
this.setHeader('Cache-Control', 'public, max-age=31536000'); // 1 year for static files
|
|
555
|
+
this.end(data);
|
|
556
|
+
}
|
|
557
|
+
catch {
|
|
558
|
+
this.status(404).json({ success: false, error: 'File not found' });
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
// Standardized response helpers
|
|
562
|
+
success(data, message) {
|
|
563
|
+
const response = {
|
|
564
|
+
success: true,
|
|
565
|
+
data,
|
|
566
|
+
};
|
|
567
|
+
if (message !== undefined) {
|
|
568
|
+
response.message = message;
|
|
569
|
+
}
|
|
570
|
+
this.json(response);
|
|
571
|
+
}
|
|
572
|
+
error(error, code, message) {
|
|
573
|
+
const response = {
|
|
574
|
+
success: false,
|
|
575
|
+
error,
|
|
576
|
+
};
|
|
577
|
+
if (code !== undefined) {
|
|
578
|
+
response.code = code;
|
|
579
|
+
}
|
|
580
|
+
if (message !== undefined) {
|
|
581
|
+
response.message = message;
|
|
582
|
+
}
|
|
583
|
+
this.json(response);
|
|
584
|
+
}
|
|
585
|
+
// Common HTTP error helpers (automatically set status code)
|
|
586
|
+
unauthorized(message = 'Authentication required') {
|
|
587
|
+
this.statusCode = 401;
|
|
588
|
+
this.json({
|
|
589
|
+
success: false,
|
|
590
|
+
error: 'Unauthorized',
|
|
591
|
+
code: 'UNAUTHORIZED',
|
|
592
|
+
message,
|
|
593
|
+
});
|
|
594
|
+
}
|
|
595
|
+
forbidden(message = 'Insufficient permissions') {
|
|
596
|
+
this.statusCode = 403;
|
|
597
|
+
this.json({
|
|
598
|
+
success: false,
|
|
599
|
+
error: 'Forbidden',
|
|
600
|
+
code: 'FORBIDDEN',
|
|
601
|
+
message,
|
|
602
|
+
});
|
|
603
|
+
}
|
|
604
|
+
notFound(resource = 'Resource') {
|
|
605
|
+
this.statusCode = 404;
|
|
606
|
+
this.json({
|
|
607
|
+
success: false,
|
|
608
|
+
error: 'Not Found',
|
|
609
|
+
code: 'NOT_FOUND',
|
|
610
|
+
message: `${resource} not found`,
|
|
611
|
+
});
|
|
612
|
+
}
|
|
613
|
+
badRequest(message = 'Invalid request') {
|
|
614
|
+
this.statusCode = 400;
|
|
615
|
+
this.json({
|
|
616
|
+
success: false,
|
|
617
|
+
error: 'Bad Request',
|
|
618
|
+
code: 'BAD_REQUEST',
|
|
619
|
+
message,
|
|
620
|
+
});
|
|
621
|
+
}
|
|
622
|
+
conflict(message) {
|
|
623
|
+
this.statusCode = 409;
|
|
624
|
+
this.json({
|
|
625
|
+
success: false,
|
|
626
|
+
error: 'Conflict',
|
|
627
|
+
code: 'CONFLICT',
|
|
628
|
+
message,
|
|
629
|
+
});
|
|
630
|
+
}
|
|
631
|
+
internalError(message = 'Internal server error') {
|
|
632
|
+
this.statusCode = 500;
|
|
633
|
+
this.json({
|
|
634
|
+
success: false,
|
|
635
|
+
error: 'Internal Server Error',
|
|
636
|
+
code: 'INTERNAL_ERROR',
|
|
637
|
+
message,
|
|
638
|
+
});
|
|
639
|
+
}
|
|
640
|
+
validationError(errors) {
|
|
641
|
+
this.statusCode = 422;
|
|
642
|
+
this.json({
|
|
643
|
+
success: false,
|
|
644
|
+
error: 'Validation Failed',
|
|
645
|
+
code: 'VALIDATION_ERROR',
|
|
646
|
+
errors,
|
|
647
|
+
});
|
|
648
|
+
}
|
|
649
|
+
rateLimited(retryAfter) {
|
|
650
|
+
this.statusCode = 429;
|
|
651
|
+
if (retryAfter) {
|
|
652
|
+
this.setHeader('Retry-After', retryAfter.toString());
|
|
653
|
+
}
|
|
654
|
+
this.json({
|
|
655
|
+
success: false,
|
|
656
|
+
error: 'Rate Limit Exceeded',
|
|
657
|
+
code: 'RATE_LIMITED',
|
|
658
|
+
message: retryAfter
|
|
659
|
+
? `Too many requests. Retry after ${retryAfter} seconds.`
|
|
660
|
+
: 'Too many requests',
|
|
661
|
+
retryAfter,
|
|
662
|
+
});
|
|
663
|
+
}
|
|
664
|
+
// Common success patterns
|
|
665
|
+
created(data, location) {
|
|
666
|
+
this.statusCode = 201;
|
|
667
|
+
if (location) {
|
|
668
|
+
this.setHeader('Location', location);
|
|
669
|
+
}
|
|
670
|
+
this.json({
|
|
671
|
+
success: true,
|
|
672
|
+
data,
|
|
673
|
+
});
|
|
674
|
+
}
|
|
675
|
+
noContent() {
|
|
676
|
+
this.statusCode = 204;
|
|
677
|
+
this.end();
|
|
678
|
+
}
|
|
679
|
+
paginated(data, pagination) {
|
|
680
|
+
const totalPages = Math.ceil(pagination.total / pagination.limit);
|
|
681
|
+
this.json({
|
|
682
|
+
success: true,
|
|
683
|
+
data,
|
|
684
|
+
pagination: {
|
|
685
|
+
page: pagination.page,
|
|
686
|
+
limit: pagination.limit,
|
|
687
|
+
total: pagination.total,
|
|
688
|
+
totalPages,
|
|
689
|
+
hasNext: pagination.page < totalPages,
|
|
690
|
+
hasPrev: pagination.page > 1,
|
|
691
|
+
},
|
|
692
|
+
});
|
|
693
|
+
}
|
|
694
|
+
// Header management utilities
|
|
695
|
+
hasHeader(name) {
|
|
696
|
+
return this.getHeader(name) !== undefined;
|
|
697
|
+
}
|
|
698
|
+
setBulkHeaders(headers) {
|
|
699
|
+
if (this.headersSent) {
|
|
700
|
+
// Only enumerate keys for warning if headers were already sent
|
|
701
|
+
const attemptedHeaderKeys = [];
|
|
702
|
+
for (const key in headers) {
|
|
703
|
+
attemptedHeaderKeys.push(key);
|
|
704
|
+
}
|
|
705
|
+
this._server?.logger.warn('Cannot set headers - headers already sent', 'HeaderWarning', {
|
|
706
|
+
attemptedHeaders: attemptedHeaderKeys,
|
|
707
|
+
});
|
|
708
|
+
return this;
|
|
709
|
+
}
|
|
710
|
+
for (const key in headers) {
|
|
711
|
+
this.setHeader(key, headers[key]);
|
|
712
|
+
}
|
|
713
|
+
return this;
|
|
714
|
+
}
|
|
715
|
+
appendHeader(name, value) {
|
|
716
|
+
if (this.headersSent) {
|
|
717
|
+
this._server?.logger.warn(`Cannot append to header '${name}' - headers already sent`, 'HeaderWarning');
|
|
718
|
+
return this;
|
|
719
|
+
}
|
|
720
|
+
const existing = this.getHeader(name);
|
|
721
|
+
if (existing) {
|
|
722
|
+
const values = Array.isArray(existing) ? existing : [existing.toString()];
|
|
723
|
+
const newValues = Array.isArray(value) ? value : [value];
|
|
724
|
+
this.setHeader(name, [...values, ...newValues]);
|
|
725
|
+
}
|
|
726
|
+
else {
|
|
727
|
+
this.setHeader(name, value);
|
|
728
|
+
}
|
|
729
|
+
return this;
|
|
730
|
+
}
|
|
731
|
+
// Express-compatible response helpers
|
|
732
|
+
set(field, value) {
|
|
733
|
+
if (this.headersSent)
|
|
734
|
+
return this;
|
|
735
|
+
if (typeof field === 'string') {
|
|
736
|
+
if (value !== undefined) {
|
|
737
|
+
this.setHeader(field, value);
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
else {
|
|
741
|
+
for (const key in field) {
|
|
742
|
+
this.setHeader(key, field[key]);
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
return this;
|
|
746
|
+
}
|
|
747
|
+
get(field) {
|
|
748
|
+
return this.getHeader(field);
|
|
749
|
+
}
|
|
750
|
+
append(field, value) {
|
|
751
|
+
return this.appendHeader(field, value);
|
|
752
|
+
}
|
|
753
|
+
type(contentType) {
|
|
754
|
+
if (this.headersSent)
|
|
755
|
+
return this;
|
|
756
|
+
// If no "/" treat as shorthand (e.g. "json" → "application/json")
|
|
757
|
+
let ct = contentType;
|
|
758
|
+
if (ct.indexOf('/') === -1) {
|
|
759
|
+
ct = TYPE_SHORTHANDS[ct.toLowerCase()] || `application/${ct}`;
|
|
760
|
+
}
|
|
761
|
+
this.setHeader('Content-Type', ct);
|
|
762
|
+
return this;
|
|
763
|
+
}
|
|
764
|
+
sendStatus(code) {
|
|
765
|
+
if (this.headersSent)
|
|
766
|
+
return;
|
|
767
|
+
this.statusCode = code;
|
|
768
|
+
const body = STATUS_MESSAGES[code] || String(code);
|
|
769
|
+
this.setHeader('Content-Type', 'text/plain; charset=utf-8');
|
|
770
|
+
this.end(body);
|
|
771
|
+
}
|
|
772
|
+
location(url) {
|
|
773
|
+
if (this.headersSent)
|
|
774
|
+
return this;
|
|
775
|
+
const safe = url.replace(/[\r\n]/g, '');
|
|
776
|
+
this.setHeader('Location', safe);
|
|
777
|
+
return this;
|
|
778
|
+
}
|
|
779
|
+
vary(field) {
|
|
780
|
+
if (this.headersSent)
|
|
781
|
+
return this;
|
|
782
|
+
const existing = this.getHeader('Vary');
|
|
783
|
+
const existingList = existing
|
|
784
|
+
? String(existing)
|
|
785
|
+
.split(',')
|
|
786
|
+
.map(s => s.trim())
|
|
787
|
+
.filter(Boolean)
|
|
788
|
+
: [];
|
|
789
|
+
const incoming = Array.isArray(field) ? field : [field];
|
|
790
|
+
for (const f of incoming) {
|
|
791
|
+
if (!existingList.some(e => e.toLowerCase() === f.toLowerCase())) {
|
|
792
|
+
existingList.push(f);
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
this.setHeader('Vary', existingList.join(', '));
|
|
796
|
+
return this;
|
|
797
|
+
}
|
|
798
|
+
attachment(filename) {
|
|
799
|
+
if (this.headersSent)
|
|
800
|
+
return this;
|
|
801
|
+
if (filename) {
|
|
802
|
+
const safeName = filename.replace(/[\r\n"]/g, '');
|
|
803
|
+
this.setHeader('Content-Disposition', `attachment; filename="${safeName}"`);
|
|
804
|
+
// Set Content-Type from extension if not already set
|
|
805
|
+
if (!this.getHeader('Content-Type')) {
|
|
806
|
+
// fire-and-forget mime lookup; not awaited to keep signature chainable
|
|
807
|
+
this._server.getMimeType(filename.substring(filename.lastIndexOf('.'))).then(mime => {
|
|
808
|
+
if (!this.getHeader('Content-Type') && !this.headersSent) {
|
|
809
|
+
this.setHeader('Content-Type', mime);
|
|
810
|
+
}
|
|
811
|
+
});
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
else {
|
|
815
|
+
this.setHeader('Content-Disposition', 'attachment');
|
|
816
|
+
}
|
|
817
|
+
return this;
|
|
818
|
+
}
|
|
819
|
+
async download(filePath, filename) {
|
|
820
|
+
this.attachment(filename || filePath.split('/').pop());
|
|
821
|
+
return this.sendFile(filePath);
|
|
822
|
+
}
|
|
823
|
+
format(handlers) {
|
|
824
|
+
const types = Object.keys(handlers).filter(k => k !== 'default');
|
|
825
|
+
const matched = this.req.accepts(types);
|
|
826
|
+
const chosen = matched ? handlers[matched] : handlers.default;
|
|
827
|
+
if (!chosen) {
|
|
828
|
+
this.statusCode = 406;
|
|
829
|
+
this.setHeader('Content-Type', 'text/plain; charset=utf-8');
|
|
830
|
+
this.end('Not Acceptable');
|
|
831
|
+
return;
|
|
832
|
+
}
|
|
833
|
+
this.vary('Accept');
|
|
834
|
+
if (matched && typeof matched === 'string') {
|
|
835
|
+
this.type(matched);
|
|
836
|
+
}
|
|
837
|
+
chosen();
|
|
838
|
+
}
|
|
839
|
+
links(links) {
|
|
840
|
+
if (this.headersSent)
|
|
841
|
+
return this;
|
|
842
|
+
const parts = [];
|
|
843
|
+
for (const rel in links) {
|
|
844
|
+
parts.push(`<${links[rel]}>; rel="${rel}"`);
|
|
845
|
+
}
|
|
846
|
+
if (parts.length > 0) {
|
|
847
|
+
const existing = this.getHeader('Link');
|
|
848
|
+
const combined = existing ? `${existing}, ${parts.join(', ')}` : parts.join(', ');
|
|
849
|
+
this.setHeader('Link', combined);
|
|
850
|
+
}
|
|
851
|
+
return this;
|
|
852
|
+
}
|
|
853
|
+
// Response state utilities
|
|
854
|
+
canSetHeaders() {
|
|
855
|
+
return !this.headersSent;
|
|
856
|
+
}
|
|
857
|
+
getResponseState() {
|
|
858
|
+
return {
|
|
859
|
+
headersSent: this.headersSent,
|
|
860
|
+
statusCode: this.statusCode,
|
|
861
|
+
headers: this.getHeaders ? this.getHeaders() : {},
|
|
862
|
+
finished: this.finished || false,
|
|
863
|
+
writable: this.writable,
|
|
864
|
+
};
|
|
865
|
+
}
|
|
866
|
+
}
|
|
10
867
|
export class MoroHttpServer {
|
|
11
868
|
server;
|
|
12
869
|
routes = [];
|
|
13
870
|
globalMiddleware = [];
|
|
871
|
+
/** @internal read by MoroServerResponse prototype methods */
|
|
14
872
|
compressionEnabled = true;
|
|
873
|
+
/** @internal read by MoroServerResponse prototype methods */
|
|
15
874
|
compressionThreshold = 1024;
|
|
875
|
+
/** @internal read by MoroIncomingMessage prototype methods */
|
|
16
876
|
requestTrackingEnabled = true; // Generate request IDs
|
|
877
|
+
/** @internal read by MoroServerResponse prototype methods */
|
|
17
878
|
logger = createFrameworkLogger('HttpServer');
|
|
18
879
|
hookManager;
|
|
19
880
|
requestCounter = 0;
|
|
20
881
|
errorHandler;
|
|
21
882
|
requestDecorations = {};
|
|
22
883
|
responseDecorations = {};
|
|
884
|
+
// Per-server request/response subclasses; their prototype carries the
|
|
885
|
+
// `_server` back-reference plus user decorations (applied once, not per request)
|
|
886
|
+
RequestClass;
|
|
887
|
+
ResponseClass;
|
|
888
|
+
// Direct router dispatch slot - runs after global middleware without the
|
|
889
|
+
// per-middleware promise machinery (set from Moro.listen via setRouterHandler)
|
|
890
|
+
routerHandler;
|
|
23
891
|
// Body size limits - defaults overridden by config in constructor
|
|
24
892
|
maxBodySize = 10 * 1024 * 1024; // Default 10MB (overridden by server.bodySizeLimit config)
|
|
25
893
|
maxUploadSize = 100 * 1024 * 1024; // Default 100MB for file uploads (future: configurable)
|
|
@@ -43,7 +911,19 @@ export class MoroHttpServer {
|
|
|
43
911
|
rateLimited: Buffer.from('{"success":false,"error":"Rate limit exceeded"}'),
|
|
44
912
|
};
|
|
45
913
|
constructor(options) {
|
|
46
|
-
|
|
914
|
+
// Per-instance subclasses so prototype methods can reach this server's
|
|
915
|
+
// config (logger, compression) with zero per-request allocation.
|
|
916
|
+
// eslint-disable-next-line @typescript-eslint/no-this-alias
|
|
917
|
+
const boundServer = this;
|
|
918
|
+
class BoundRequest extends MoroIncomingMessage {
|
|
919
|
+
}
|
|
920
|
+
class BoundResponse extends MoroServerResponse {
|
|
921
|
+
}
|
|
922
|
+
BoundRequest.prototype._server = boundServer;
|
|
923
|
+
BoundResponse.prototype._server = boundServer;
|
|
924
|
+
this.RequestClass = BoundRequest;
|
|
925
|
+
this.ResponseClass = BoundResponse;
|
|
926
|
+
this.server = createServer({ IncomingMessage: BoundRequest, ServerResponse: BoundResponse }, this.handleRequest.bind(this));
|
|
47
927
|
// Configure body size limits from options
|
|
48
928
|
if (options?.maxBodySize) {
|
|
49
929
|
this.maxBodySize = options.maxBodySize;
|
|
@@ -54,7 +934,17 @@ export class MoroHttpServer {
|
|
|
54
934
|
// Optimize server for high performance (conservative settings for compatibility)
|
|
55
935
|
this.server.keepAliveTimeout = 5000; // 5 seconds
|
|
56
936
|
this.server.headersTimeout = 6000; // 6 seconds
|
|
57
|
-
|
|
937
|
+
// Socket inactivity timeout stays at Node's default (0 = disabled). A
|
|
938
|
+
// non-zero value arms/refreshes a timer on every request, which shows up
|
|
939
|
+
// in CPU profiles (setStreamTimeout + timer churn) under load; slowloris
|
|
940
|
+
// protection is already covered by headersTimeout + keepAliveTimeout, and
|
|
941
|
+
// this matches Node's own default connectionTimeout: 0.
|
|
942
|
+
this.server.timeout = 0;
|
|
943
|
+
}
|
|
944
|
+
// Direct router dispatch - called after global middleware, before the legacy
|
|
945
|
+
// route table. Returning true (or a promise of true) means the request was handled.
|
|
946
|
+
setRouterHandler(fn) {
|
|
947
|
+
this.routerHandler = fn;
|
|
58
948
|
}
|
|
59
949
|
// Configure server for maximum performance (can disable all overhead)
|
|
60
950
|
configurePerformance(config = {}) {
|
|
@@ -78,12 +968,30 @@ export class MoroHttpServer {
|
|
|
78
968
|
setErrorHandler(fn) {
|
|
79
969
|
this.errorHandler = fn;
|
|
80
970
|
}
|
|
81
|
-
// Apply
|
|
971
|
+
// Apply request/response decoration maps (called from Moro.decorateRequest/Reply).
|
|
972
|
+
// Decorations are installed on the per-server prototypes once, so requests pay
|
|
973
|
+
// nothing for them; per-request writes to the same key simply shadow the prototype.
|
|
82
974
|
setRequestDecorations(decorations) {
|
|
83
975
|
this.requestDecorations = decorations;
|
|
976
|
+
for (const key in decorations) {
|
|
977
|
+
Object.defineProperty(this.RequestClass.prototype, key, {
|
|
978
|
+
value: decorations[key],
|
|
979
|
+
writable: true,
|
|
980
|
+
enumerable: true,
|
|
981
|
+
configurable: true,
|
|
982
|
+
});
|
|
983
|
+
}
|
|
84
984
|
}
|
|
85
985
|
setResponseDecorations(decorations) {
|
|
86
986
|
this.responseDecorations = decorations;
|
|
987
|
+
for (const key in decorations) {
|
|
988
|
+
Object.defineProperty(this.ResponseClass.prototype, key, {
|
|
989
|
+
value: decorations[key],
|
|
990
|
+
writable: true,
|
|
991
|
+
enumerable: true,
|
|
992
|
+
configurable: true,
|
|
993
|
+
});
|
|
994
|
+
}
|
|
87
995
|
}
|
|
88
996
|
// Middleware management
|
|
89
997
|
use(middleware) {
|
|
@@ -157,82 +1065,184 @@ export class MoroHttpServer {
|
|
|
157
1065
|
paramNames: compiled.paramNames,
|
|
158
1066
|
};
|
|
159
1067
|
}
|
|
160
|
-
async
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
1068
|
+
// Request entry point. Deliberately NOT an async function: the common case
|
|
1069
|
+
// (no body, no hooks, no global middleware, sync route handler) completes
|
|
1070
|
+
// fully synchronously with zero promise allocations and zero microtask
|
|
1071
|
+
// hops. Anything that genuinely needs to await routes through
|
|
1072
|
+
// handleRequestSlow.
|
|
1073
|
+
handleRequest(req, res) {
|
|
1074
|
+
// req/res arrive as the per-server subclasses (MoroIncomingMessage /
|
|
1075
|
+
// MoroServerResponse) - all helpers are already on their prototypes.
|
|
1076
|
+
const httpReq = req;
|
|
1077
|
+
const httpRes = res;
|
|
1078
|
+
// URL split - query string parsing is lazy (MoroIncomingMessage#query getter)
|
|
1079
|
+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
1080
|
+
const urlString = req.url;
|
|
1081
|
+
const queryIndex = urlString.indexOf('?');
|
|
1082
|
+
if (queryIndex === -1) {
|
|
1083
|
+
httpReq.path = urlString;
|
|
1084
|
+
}
|
|
1085
|
+
else {
|
|
1086
|
+
httpReq.path = urlString.substring(0, queryIndex);
|
|
1087
|
+
httpReq._queryString = urlString.substring(queryIndex + 1);
|
|
1088
|
+
}
|
|
1089
|
+
// Intern method string for fast reference equality comparison (50-100% faster)
|
|
1090
|
+
switch (req.method) {
|
|
1091
|
+
case 'POST':
|
|
1092
|
+
httpReq.method = MoroHttpServer.METHOD_POST;
|
|
1093
|
+
break;
|
|
1094
|
+
case 'PUT':
|
|
1095
|
+
httpReq.method = MoroHttpServer.METHOD_PUT;
|
|
1096
|
+
break;
|
|
1097
|
+
case 'PATCH':
|
|
1098
|
+
httpReq.method = MoroHttpServer.METHOD_PATCH;
|
|
1099
|
+
break;
|
|
1100
|
+
case 'GET':
|
|
1101
|
+
httpReq.method = MoroHttpServer.METHOD_GET;
|
|
1102
|
+
break;
|
|
1103
|
+
case 'DELETE':
|
|
1104
|
+
httpReq.method = MoroHttpServer.METHOD_DELETE;
|
|
1105
|
+
break;
|
|
1106
|
+
case 'HEAD':
|
|
1107
|
+
httpReq.method = MoroHttpServer.METHOD_HEAD;
|
|
1108
|
+
break;
|
|
1109
|
+
case 'OPTIONS':
|
|
1110
|
+
httpReq.method = MoroHttpServer.METHOD_OPTIONS;
|
|
1111
|
+
break;
|
|
1112
|
+
}
|
|
1113
|
+
const needsBody = httpReq.method === MoroHttpServer.METHOD_POST ||
|
|
1114
|
+
httpReq.method === MoroHttpServer.METHOD_PUT ||
|
|
1115
|
+
httpReq.method === MoroHttpServer.METHOD_PATCH;
|
|
1116
|
+
const hookManager = this.hookManager;
|
|
1117
|
+
const hasHooks = !!(hookManager &&
|
|
1118
|
+
(hookManager.hasHooks === undefined || hookManager.hasHooks('request')));
|
|
1119
|
+
if (needsBody || hasHooks || this.globalMiddleware.length > 0) {
|
|
1120
|
+
// Slow path: real awaits ahead (body parse / hooks / middleware).
|
|
1121
|
+
// Errors are fully handled inside - the returned promise never rejects.
|
|
1122
|
+
void this.handleRequestSlow(httpReq, httpRes, req, needsBody, hasHooks);
|
|
1123
|
+
return;
|
|
1124
|
+
}
|
|
1125
|
+
// FAST PATH - no promises unless the route handler itself is async
|
|
165
1126
|
try {
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
1127
|
+
const routerHandler = this.routerHandler;
|
|
1128
|
+
if (routerHandler) {
|
|
1129
|
+
const handled = routerHandler(httpReq, httpRes);
|
|
1130
|
+
if (handled) {
|
|
1131
|
+
if (typeof handled.then === 'function') {
|
|
1132
|
+
handled.then(wasHandled => {
|
|
1133
|
+
if (!wasHandled && !httpRes.headersSent) {
|
|
1134
|
+
try {
|
|
1135
|
+
this.dispatchLegacyRoute(httpReq, httpRes, req);
|
|
1136
|
+
}
|
|
1137
|
+
catch (error) {
|
|
1138
|
+
void this.onRequestError(error, httpReq, httpRes, req);
|
|
1139
|
+
}
|
|
1140
|
+
}
|
|
1141
|
+
}, error => void this.onRequestError(error, httpReq, httpRes, req));
|
|
1142
|
+
}
|
|
1143
|
+
return;
|
|
1144
|
+
}
|
|
1145
|
+
if (httpRes.headersSent)
|
|
1146
|
+
return;
|
|
179
1147
|
}
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
1148
|
+
this.dispatchLegacyRoute(httpReq, httpRes, req);
|
|
1149
|
+
}
|
|
1150
|
+
catch (error) {
|
|
1151
|
+
void this.onRequestError(error, httpReq, httpRes, req);
|
|
1152
|
+
}
|
|
1153
|
+
}
|
|
1154
|
+
// Full async pipeline for requests that need it: bodied methods, hook
|
|
1155
|
+
// consumers, and apps with global middleware.
|
|
1156
|
+
async handleRequestSlow(httpReq, httpRes, req, needsBody, hasHooks) {
|
|
1157
|
+
try {
|
|
1158
|
+
if (needsBody) {
|
|
184
1159
|
httpReq.body = await this.parseBody(req);
|
|
185
1160
|
}
|
|
186
|
-
|
|
187
|
-
if (this.hookManager) {
|
|
1161
|
+
if (hasHooks) {
|
|
188
1162
|
await this.hookManager.execute('request', {
|
|
189
1163
|
request: httpReq,
|
|
190
1164
|
response: httpRes,
|
|
191
1165
|
});
|
|
192
1166
|
}
|
|
193
|
-
//
|
|
194
|
-
|
|
195
|
-
if (
|
|
196
|
-
|
|
1167
|
+
// executeMiddleware returns undefined when the whole chain completed
|
|
1168
|
+
// synchronously, so a sync chain costs zero promise allocations.
|
|
1169
|
+
if (this.globalMiddleware.length > 0) {
|
|
1170
|
+
const mwResult = this.executeMiddleware(this.globalMiddleware, httpReq, httpRes);
|
|
1171
|
+
if (mwResult)
|
|
1172
|
+
await mwResult;
|
|
197
1173
|
}
|
|
198
1174
|
// If middleware handled the request, don't continue
|
|
199
1175
|
if (httpRes.headersSent) {
|
|
200
1176
|
return;
|
|
201
1177
|
}
|
|
202
|
-
//
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
const matches = httpReq.path.match(route.pattern);
|
|
215
|
-
if (matches) {
|
|
216
|
-
// Use pooled object for parameters
|
|
217
|
-
httpReq.params = this.acquireParamObject();
|
|
218
|
-
const paramNames = route.paramNames;
|
|
219
|
-
const paramNamesLen = paramNames.length;
|
|
220
|
-
for (let i = 0; i < paramNamesLen; i++) {
|
|
221
|
-
httpReq.params[paramNames[i]] = matches[i + 1];
|
|
1178
|
+
// Unified router direct dispatch - no middleware-chain promise wrapper
|
|
1179
|
+
const routerHandler = this.routerHandler;
|
|
1180
|
+
if (routerHandler) {
|
|
1181
|
+
const handled = routerHandler(httpReq, httpRes);
|
|
1182
|
+
if (handled) {
|
|
1183
|
+
if (typeof handled.then === 'function') {
|
|
1184
|
+
if (await handled)
|
|
1185
|
+
return;
|
|
1186
|
+
}
|
|
1187
|
+
else {
|
|
1188
|
+
return;
|
|
1189
|
+
}
|
|
222
1190
|
}
|
|
1191
|
+
if (httpRes.headersSent)
|
|
1192
|
+
return;
|
|
223
1193
|
}
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
1194
|
+
this.dispatchLegacyRoute(httpReq, httpRes, req);
|
|
1195
|
+
}
|
|
1196
|
+
catch (error) {
|
|
1197
|
+
await this.onRequestError(error, httpReq, httpRes, req);
|
|
1198
|
+
}
|
|
1199
|
+
}
|
|
1200
|
+
// Legacy direct-route table dispatch + 404 fallback. Synchronous; async
|
|
1201
|
+
// route middleware/handlers are continued via promise callbacks wired to
|
|
1202
|
+
// onRequestError so the sync fast path stays promise-free.
|
|
1203
|
+
dispatchLegacyRoute(httpReq, httpRes, req) {
|
|
1204
|
+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
1205
|
+
const route = this.findRoute(req.method, httpReq.path);
|
|
1206
|
+
if (!route) {
|
|
1207
|
+
// 404 response with pre-compiled buffer
|
|
1208
|
+
httpRes.statusCode = 404;
|
|
1209
|
+
httpRes.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
1210
|
+
httpRes.setHeader('Content-Length', MoroHttpServer.RESPONSE_TEMPLATES.notFound.length);
|
|
1211
|
+
httpRes.end(MoroHttpServer.RESPONSE_TEMPLATES.notFound);
|
|
1212
|
+
return;
|
|
1213
|
+
}
|
|
1214
|
+
// Extract path parameters
|
|
1215
|
+
const matches = httpReq.path.match(route.pattern);
|
|
1216
|
+
if (matches) {
|
|
1217
|
+
const params = {};
|
|
1218
|
+
const paramNames = route.paramNames;
|
|
1219
|
+
const paramNamesLen = paramNames.length;
|
|
1220
|
+
for (let i = 0; i < paramNamesLen; i++) {
|
|
1221
|
+
params[paramNames[i]] = matches[i + 1];
|
|
228
1222
|
}
|
|
1223
|
+
httpReq.params = params;
|
|
1224
|
+
}
|
|
1225
|
+
const runHandler = () => {
|
|
229
1226
|
// Execute handler - Don't await sync handlers
|
|
230
1227
|
const handlerResult = route.handler(httpReq, httpRes);
|
|
231
1228
|
if (handlerResult && typeof handlerResult.then === 'function') {
|
|
232
|
-
|
|
1229
|
+
handlerResult.catch(error => void this.onRequestError(error, httpReq, httpRes, req));
|
|
1230
|
+
}
|
|
1231
|
+
};
|
|
1232
|
+
// Execute middleware chain - EARLY EXIT if no route middleware
|
|
1233
|
+
if (route.middleware.length > 0) {
|
|
1234
|
+
const mwResult = this.executeMiddleware(route.middleware, httpReq, httpRes);
|
|
1235
|
+
if (mwResult) {
|
|
1236
|
+
mwResult.then(runHandler, error => void this.onRequestError(error, httpReq, httpRes, req));
|
|
1237
|
+
return;
|
|
233
1238
|
}
|
|
234
1239
|
}
|
|
235
|
-
|
|
1240
|
+
runHandler();
|
|
1241
|
+
}
|
|
1242
|
+
// Shared error handling for all request paths (sync throw, slow-path catch,
|
|
1243
|
+
// and promise rejections from async handlers/middleware). Never throws.
|
|
1244
|
+
async onRequestError(error, httpReq, httpRes, req) {
|
|
1245
|
+
try {
|
|
236
1246
|
// Debug: Log the actual error and where it came from
|
|
237
1247
|
this.logger.debug('Request error details', 'RequestHandler', {
|
|
238
1248
|
errorType: typeof error,
|
|
@@ -241,6 +1251,16 @@ export class MoroHttpServer {
|
|
|
241
1251
|
requestPath: req.url,
|
|
242
1252
|
requestMethod: req.method,
|
|
243
1253
|
});
|
|
1254
|
+
// Payload-too-large from parseBody: respond 413 (matches the old bodySize
|
|
1255
|
+
// middleware semantics, now enforced in-server)
|
|
1256
|
+
if (error?.statusCode === 413 && !httpRes.headersSent) {
|
|
1257
|
+
httpRes.statusCode = 413;
|
|
1258
|
+
httpRes.json({
|
|
1259
|
+
success: false,
|
|
1260
|
+
error: 'Request entity too large',
|
|
1261
|
+
});
|
|
1262
|
+
return;
|
|
1263
|
+
}
|
|
244
1264
|
this.logger.error('Request error', 'RequestHandler', {
|
|
245
1265
|
error: error instanceof Error ? error.message : String(error),
|
|
246
1266
|
requestId: httpReq.requestId,
|
|
@@ -298,66 +1318,13 @@ export class MoroHttpServer {
|
|
|
298
1318
|
}
|
|
299
1319
|
}
|
|
300
1320
|
}
|
|
301
|
-
|
|
302
|
-
//
|
|
303
|
-
//
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
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
|
-
}
|
|
1321
|
+
catch (fatalError) {
|
|
1322
|
+
// Error handling itself failed (e.g. the connection died mid-response) -
|
|
1323
|
+
// never let this escape as an unhandled rejection
|
|
1324
|
+
this.logger.error('Failed while handling request error', 'RequestHandler', {
|
|
1325
|
+
error: fatalError instanceof Error ? fatalError.message : String(fatalError),
|
|
1326
|
+
});
|
|
327
1327
|
}
|
|
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
1328
|
}
|
|
362
1329
|
// Force cleanup of all pooled objects
|
|
363
1330
|
forceCleanupPools() {
|
|
@@ -429,697 +1396,7 @@ export class MoroHttpServer {
|
|
|
429
1396
|
}
|
|
430
1397
|
return normalized;
|
|
431
1398
|
}
|
|
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
|
-
}
|
|
1399
|
+
/** @internal called by MoroServerResponse prototype methods */
|
|
1123
1400
|
async getMimeType(ext) {
|
|
1124
1401
|
const mimeTypes = {
|
|
1125
1402
|
'.html': 'text/html',
|
|
@@ -1138,6 +1415,7 @@ export class MoroHttpServer {
|
|
|
1138
1415
|
};
|
|
1139
1416
|
return mimeTypes[ext.toLowerCase()] || 'application/octet-stream';
|
|
1140
1417
|
}
|
|
1418
|
+
/** @internal called by MoroServerResponse prototype methods */
|
|
1141
1419
|
addCharsetIfNeeded(mimeType) {
|
|
1142
1420
|
// Add charset for text-based content types - optimized with early exit
|
|
1143
1421
|
// Check most common cases first
|
|
@@ -1161,6 +1439,14 @@ export class MoroHttpServer {
|
|
|
1161
1439
|
const maxSize = isMultipart
|
|
1162
1440
|
? this.maxUploadSize // Configurable file upload limit (default 100MB)
|
|
1163
1441
|
: this.maxBodySize; // Configurable body size limit (default 10MB, from server.bodySizeLimit config)
|
|
1442
|
+
// Early rejection from the declared Content-Length - nothing is read off the wire
|
|
1443
|
+
if (contentLength > maxSize) {
|
|
1444
|
+
const error = new Error('Request entity too large');
|
|
1445
|
+
error.statusCode = 413;
|
|
1446
|
+
error.limit = maxSize;
|
|
1447
|
+
error.received = contentLength;
|
|
1448
|
+
return Promise.reject(error);
|
|
1449
|
+
}
|
|
1164
1450
|
// For very large payloads, return a streaming interface instead of buffering
|
|
1165
1451
|
if (contentLength > maxSize / 2) {
|
|
1166
1452
|
// Stream for payloads > 50MB (uploads) or > 5MB (JSON/form)
|
|
@@ -1176,14 +1462,19 @@ export class MoroHttpServer {
|
|
|
1176
1462
|
const errorMessage = isMultipart
|
|
1177
1463
|
? `File upload too large. Maximum ${Math.floor(maxSize / (1024 * 1024))}MB allowed.`
|
|
1178
1464
|
: 'Request body too large';
|
|
1179
|
-
|
|
1465
|
+
const error = new Error(errorMessage);
|
|
1466
|
+
error.statusCode = 413;
|
|
1467
|
+
error.limit = maxSize;
|
|
1468
|
+
reject(error);
|
|
1180
1469
|
return;
|
|
1181
1470
|
}
|
|
1182
1471
|
chunks.push(chunk);
|
|
1183
1472
|
});
|
|
1184
1473
|
req.on('end', () => {
|
|
1185
1474
|
try {
|
|
1186
|
-
|
|
1475
|
+
// Single-chunk fast path (most JSON bodies arrive in one chunk);
|
|
1476
|
+
// otherwise concat with the known total length to avoid a re-scan
|
|
1477
|
+
const body = chunks.length === 1 ? chunks[0] : Buffer.concat(chunks, totalLength);
|
|
1187
1478
|
if (contentType.includes('application/json')) {
|
|
1188
1479
|
resolve(JSON.parse(body.toString()));
|
|
1189
1480
|
}
|
|
@@ -1410,30 +1701,14 @@ export class MoroHttpServer {
|
|
|
1410
1701
|
}
|
|
1411
1702
|
return result;
|
|
1412
1703
|
}
|
|
1413
|
-
// Legacy
|
|
1704
|
+
// Legacy methods for backward compatibility - the hot path now parses query
|
|
1705
|
+
// strings lazily via the MoroIncomingMessage#query getter (no pooling: a fresh
|
|
1706
|
+
// object literal is cheaper than pool bookkeeping)
|
|
1414
1707
|
parseQueryString(queryString) {
|
|
1415
|
-
return
|
|
1708
|
+
return parseQueryString(queryString);
|
|
1416
1709
|
}
|
|
1417
|
-
// Optimized query string parser with object pooling
|
|
1418
1710
|
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;
|
|
1711
|
+
return parseQueryString(queryString);
|
|
1437
1712
|
}
|
|
1438
1713
|
// Advanced route optimization: cache + static routes + segment grouping
|
|
1439
1714
|
routeCache = new Map();
|
|
@@ -1498,13 +1773,23 @@ export class MoroHttpServer {
|
|
|
1498
1773
|
// Middleware execution with Express-compatible error propagation.
|
|
1499
1774
|
// Supports: 3-arg (req, res, next), 4-arg (err, req, res, next) error middlewares,
|
|
1500
1775
|
// and next(err) to skip forward to the next error middleware.
|
|
1501
|
-
|
|
1776
|
+
//
|
|
1777
|
+
// Sync-aware dispatch: middleware that calls next() synchronously (or throws
|
|
1778
|
+
// synchronously) advances the chain in a plain loop with ZERO promise
|
|
1779
|
+
// allocations. A promise is only created when a middleware actually completes
|
|
1780
|
+
// asynchronously. Returns undefined when the whole chain ran synchronously.
|
|
1781
|
+
executeMiddleware(middleware, req, res) {
|
|
1782
|
+
return this.dispatchMiddleware(middleware, req, res, 0, undefined);
|
|
1783
|
+
}
|
|
1784
|
+
dispatchMiddleware(middleware, req, res, startIndex, initialError) {
|
|
1502
1785
|
const len = middleware.length;
|
|
1503
|
-
let activeError =
|
|
1504
|
-
|
|
1786
|
+
let activeError = initialError;
|
|
1787
|
+
let i = startIndex;
|
|
1788
|
+
while (i < len) {
|
|
1505
1789
|
if (res.headersSent)
|
|
1506
1790
|
return;
|
|
1507
1791
|
const mw = middleware[i];
|
|
1792
|
+
i++;
|
|
1508
1793
|
const isErrorHandler = mw.length >= 4;
|
|
1509
1794
|
// Non-error middleware is skipped while an error is active; error middleware
|
|
1510
1795
|
// is skipped while no error is active. Matches Express semantics.
|
|
@@ -1512,36 +1797,60 @@ export class MoroHttpServer {
|
|
|
1512
1797
|
continue;
|
|
1513
1798
|
if (activeError === undefined && isErrorHandler)
|
|
1514
1799
|
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
|
-
}
|
|
1800
|
+
let settled = false;
|
|
1801
|
+
let settledError = undefined;
|
|
1802
|
+
let asyncResolve;
|
|
1803
|
+
const next = (err) => {
|
|
1804
|
+
if (settled)
|
|
1805
|
+
return;
|
|
1806
|
+
settled = true;
|
|
1807
|
+
settledError = err;
|
|
1808
|
+
if (asyncResolve)
|
|
1809
|
+
asyncResolve(err);
|
|
1810
|
+
};
|
|
1811
|
+
let result;
|
|
1812
|
+
try {
|
|
1813
|
+
result = isErrorHandler ? mw(activeError, req, res, next) : mw(req, res, next);
|
|
1814
|
+
}
|
|
1815
|
+
catch (err) {
|
|
1816
|
+
if (!settled) {
|
|
1817
|
+
settled = true;
|
|
1818
|
+
settledError = err;
|
|
1539
1819
|
}
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1820
|
+
}
|
|
1821
|
+
const isThenable = result && typeof result.then === 'function';
|
|
1822
|
+
if (settled) {
|
|
1823
|
+
// Completed synchronously (next() called sync, or threw sync).
|
|
1824
|
+
// Matches the old behavior: a still-pending returned promise no longer
|
|
1825
|
+
// gates the chain once next() has been called; swallow late rejections.
|
|
1826
|
+
if (isThenable) {
|
|
1827
|
+
result.then(undefined, () => { });
|
|
1543
1828
|
}
|
|
1544
|
-
|
|
1829
|
+
activeError = settledError;
|
|
1830
|
+
continue;
|
|
1831
|
+
}
|
|
1832
|
+
if (!isThenable) {
|
|
1833
|
+
// Sync-looking middleware that neither called next() nor returned a
|
|
1834
|
+
// promise: it will call next() later (e.g. from an event callback).
|
|
1835
|
+
// Fall back to a promise for the remainder of the chain.
|
|
1836
|
+
const resumeIndex = i;
|
|
1837
|
+
return new Promise(resolve => {
|
|
1838
|
+
asyncResolve = resolve;
|
|
1839
|
+
}).then(err => this.dispatchMiddleware(middleware, req, res, resumeIndex, err));
|
|
1840
|
+
}
|
|
1841
|
+
// Async middleware: completion is next() OR promise settle, whichever
|
|
1842
|
+
// happens first (same semantics as the previous implementation).
|
|
1843
|
+
const resumeIndex = i;
|
|
1844
|
+
return new Promise(resolve => {
|
|
1845
|
+
asyncResolve = resolve;
|
|
1846
|
+
result.then(() => {
|
|
1847
|
+
if (!settled)
|
|
1848
|
+
next();
|
|
1849
|
+
}, (err) => {
|
|
1850
|
+
if (!settled)
|
|
1851
|
+
next(err);
|
|
1852
|
+
});
|
|
1853
|
+
}).then(err => this.dispatchMiddleware(middleware, req, res, resumeIndex, err));
|
|
1545
1854
|
}
|
|
1546
1855
|
// If an error is still unhandled after the chain, re-throw so the top-level
|
|
1547
1856
|
// catch in handleRequest can invoke the registered errorHandler / default 500.
|