sockress 0.1.0

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/src/index.ts ADDED
@@ -0,0 +1,725 @@
1
+ import http, { IncomingMessage, ServerResponse } from 'http';
2
+ import { AddressInfo } from 'net';
3
+ import type { Socket } from 'net';
4
+ import { TLSSocket } from 'tls';
5
+ import { WebSocketServer, WebSocket } from 'ws';
6
+ import { parse as parseCookie, serialize as serializeCookie } from 'cookie';
7
+ import type { CookieSerializeOptions } from 'cookie';
8
+ import { nanoid } from 'nanoid';
9
+
10
+ type HTTPMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'HEAD' | 'OPTIONS';
11
+
12
+ export type NextFunction = (err?: unknown) => void;
13
+ export type SockressHandler = (req: SockressRequest, res: SockressResponse, next: NextFunction) => unknown;
14
+ export type SockressErrorHandler = (err: unknown, req: SockressRequest, res: SockressResponse, next: NextFunction) => unknown;
15
+
16
+ export interface SockressOptions {
17
+ cors?: Partial<CorsOptions>;
18
+ socket?: Partial<SocketOptions>;
19
+ bodyLimit?: number;
20
+ }
21
+
22
+ interface SocketOptions {
23
+ path: string;
24
+ heartbeatInterval: number;
25
+ idleTimeout: number;
26
+ }
27
+
28
+ interface CorsOptions {
29
+ origin: string | string[];
30
+ credentials: boolean;
31
+ methods: HTTPMethod[];
32
+ allowedHeaders: string[];
33
+ exposedHeaders: string[];
34
+ maxAge: number;
35
+ }
36
+
37
+ interface NormalizedOptions {
38
+ cors: CorsOptions;
39
+ socket: SocketOptions;
40
+ bodyLimit: number;
41
+ }
42
+
43
+ interface MiddlewareLayer {
44
+ path: string;
45
+ handler: SockressHandler | SockressErrorHandler;
46
+ isErrorHandler: boolean;
47
+ }
48
+
49
+ interface RouteLayer {
50
+ method: HTTPMethod | 'ALL';
51
+ matcher: PathMatcher;
52
+ handlers: Array<SockressHandler | SockressErrorHandler>;
53
+ }
54
+
55
+ interface PipelineLayer {
56
+ handler: SockressHandler | SockressErrorHandler;
57
+ isErrorHandler: boolean;
58
+ }
59
+
60
+ interface PathMatcher {
61
+ raw: string;
62
+ match: (path: string) => PathMatchResult | null;
63
+ }
64
+
65
+ interface PathMatchResult {
66
+ params: Record<string, string>;
67
+ }
68
+
69
+ type RequestMode =
70
+ | { kind: 'http'; req: IncomingMessage; res: ServerResponse }
71
+ | { kind: 'socket'; socket: WebSocket; requestId: string };
72
+
73
+ interface IncomingSocketMessage {
74
+ type: 'request';
75
+ id?: string;
76
+ method?: string;
77
+ path?: string;
78
+ headers?: Record<string, string>;
79
+ query?: Record<string, string | string[]>;
80
+ body?: unknown;
81
+ }
82
+
83
+ interface OutgoingSocketMessage {
84
+ type: 'response' | 'error';
85
+ id?: string;
86
+ status?: number;
87
+ headers?: Record<string, string>;
88
+ body?: unknown;
89
+ message?: string;
90
+ code?: string;
91
+ cookies?: string[];
92
+ }
93
+
94
+ export interface SockressRequest {
95
+ readonly id: string;
96
+ readonly method: HTTPMethod;
97
+ path: string;
98
+ query: Record<string, string | string[]>;
99
+ params: Record<string, string>;
100
+ headers: Record<string, string | string[] | undefined>;
101
+ body: unknown;
102
+ cookies: Record<string, string>;
103
+ readonly type: 'http' | 'socket';
104
+ readonly ip: string | undefined;
105
+ readonly protocol: 'http' | 'https' | 'ws' | 'wss';
106
+ readonly secure: boolean;
107
+ context: Record<string, unknown>;
108
+ raw?: IncomingMessage;
109
+ get(field: string): string | undefined;
110
+ }
111
+
112
+ export class SockressRequestImpl implements SockressRequest {
113
+ public params: Record<string, string> = {};
114
+ public context: Record<string, unknown> = {};
115
+
116
+ constructor(
117
+ public readonly id: string,
118
+ public readonly method: HTTPMethod,
119
+ public path: string,
120
+ public query: Record<string, string | string[]>,
121
+ public headers: Record<string, string | string[] | undefined>,
122
+ public body: unknown,
123
+ public cookies: Record<string, string>,
124
+ public readonly type: 'http' | 'socket',
125
+ public readonly ip: string | undefined,
126
+ public readonly protocol: 'http' | 'https' | 'ws' | 'wss',
127
+ public readonly secure: boolean,
128
+ public raw?: IncomingMessage
129
+ ) {}
130
+
131
+ get(field: string): string | undefined {
132
+ const key = field.toLowerCase();
133
+ const value = this.headers[key];
134
+ if (Array.isArray(value)) {
135
+ return value[0];
136
+ }
137
+ if (typeof value === 'number') {
138
+ return String(value);
139
+ }
140
+ return value as string | undefined;
141
+ }
142
+ }
143
+
144
+ export class SockressResponse {
145
+ private statusCode = 200;
146
+ private sent = false;
147
+ private headers: Record<string, string> = {};
148
+ private cookies: string[] = [];
149
+
150
+ constructor(
151
+ private readonly mode: RequestMode,
152
+ private readonly cors: CorsOptions,
153
+ private readonly allowedOrigin: string
154
+ ) {}
155
+
156
+ status(code: number): this {
157
+ this.statusCode = code;
158
+ return this;
159
+ }
160
+
161
+ set(field: string, value: string): this {
162
+ this.headers[field.toLowerCase()] = value;
163
+ return this;
164
+ }
165
+
166
+ append(field: string, value: string): this {
167
+ const current = this.headers[field.toLowerCase()];
168
+ if (current) {
169
+ this.headers[field.toLowerCase()] = `${current}, ${value}`;
170
+ } else {
171
+ this.headers[field.toLowerCase()] = value;
172
+ }
173
+ return this;
174
+ }
175
+
176
+ cookie(name: string, value: string, options: CookieSerializeOptions = {}): this {
177
+ this.cookies.push(serializeCookie(name, value, options));
178
+ return this;
179
+ }
180
+
181
+ clearCookie(name: string, options: CookieSerializeOptions = {}): this {
182
+ return this.cookie(name, '', { ...options, maxAge: 0 });
183
+ }
184
+
185
+ json(payload: unknown): this {
186
+ this.set('content-type', 'application/json; charset=utf-8');
187
+ return this.send(JSON.stringify(payload));
188
+ }
189
+
190
+ send(payload?: unknown): this {
191
+ if (this.sent) {
192
+ return this;
193
+ }
194
+ this.sent = true;
195
+ if (!this.headers['content-type'] && typeof payload === 'string') {
196
+ this.set('content-type', 'text/plain; charset=utf-8');
197
+ }
198
+ const headersWithCors = this.buildHeaders();
199
+ if (this.mode.kind === 'http') {
200
+ const res = this.mode.res;
201
+ res.statusCode = this.statusCode;
202
+ Object.entries(headersWithCors).forEach(([key, value]) => {
203
+ res.setHeader(key, value);
204
+ });
205
+ if (this.cookies.length) {
206
+ res.setHeader('Set-Cookie', this.cookies);
207
+ }
208
+ if (Buffer.isBuffer(payload)) {
209
+ res.end(payload);
210
+ } else if (typeof payload === 'string') {
211
+ res.end(payload);
212
+ } else if (payload === undefined || payload === null) {
213
+ res.end();
214
+ } else {
215
+ const buffer = Buffer.from(JSON.stringify(payload));
216
+ if (!this.headers['content-type']) {
217
+ res.setHeader('content-type', 'application/json; charset=utf-8');
218
+ }
219
+ res.end(buffer);
220
+ }
221
+ return this;
222
+ }
223
+
224
+ const message: OutgoingSocketMessage = {
225
+ type: 'response',
226
+ id: this.mode.requestId,
227
+ status: this.statusCode,
228
+ headers: headersWithCors,
229
+ body: payload,
230
+ cookies: this.cookies.length ? [...this.cookies] : undefined
231
+ };
232
+ this.mode.socket.send(JSON.stringify(message));
233
+ return this;
234
+ }
235
+
236
+ end(): this {
237
+ return this.send();
238
+ }
239
+
240
+ isSent(): boolean {
241
+ return this.sent;
242
+ }
243
+
244
+ private buildHeaders(): Record<string, string> {
245
+ const headers = { ...this.headers };
246
+ headers['access-control-allow-origin'] = this.allowedOrigin;
247
+ headers['access-control-allow-credentials'] = String(this.cors.credentials);
248
+ headers['access-control-allow-methods'] = this.cors.methods.join(', ');
249
+ headers['access-control-allow-headers'] = this.cors.allowedHeaders.join(', ');
250
+ headers['access-control-expose-headers'] = this.cors.exposedHeaders.join(', ');
251
+ headers['access-control-max-age'] = String(this.cors.maxAge);
252
+ return headers;
253
+ }
254
+ }
255
+
256
+ export class SockressApp {
257
+ private middlewares: MiddlewareLayer[] = [];
258
+ private routes: RouteLayer[] = [];
259
+ private server?: http.Server;
260
+ private wss?: WebSocketServer;
261
+ private heartbeatInterval?: NodeJS.Timeout;
262
+
263
+ constructor(private readonly config: NormalizedOptions) {}
264
+
265
+ static create(options?: SockressOptions): SockressApp {
266
+ return new SockressApp(normalizeOptions(options));
267
+ }
268
+
269
+ use(path: string, ...handlers: Array<SockressHandler | SockressErrorHandler>): this;
270
+ use(...handlers: Array<SockressHandler | SockressErrorHandler>): this;
271
+ use(
272
+ pathOrHandler: string | SockressHandler | SockressErrorHandler,
273
+ ...rest: Array<SockressHandler | SockressErrorHandler>
274
+ ): this {
275
+ let path = '/';
276
+ let stack: Array<SockressHandler | SockressErrorHandler> = [];
277
+ if (typeof pathOrHandler === 'string') {
278
+ path = pathOrHandler;
279
+ stack = rest;
280
+ } else {
281
+ stack = [pathOrHandler, ...rest];
282
+ }
283
+ if (!stack.length) {
284
+ throw new Error('use() requires at least one handler');
285
+ }
286
+ for (const handler of stack) {
287
+ if (!handler) continue;
288
+ this.middlewares.push({
289
+ path,
290
+ handler,
291
+ isErrorHandler: handler.length === 4
292
+ });
293
+ }
294
+ return this;
295
+ }
296
+
297
+ private register(method: HTTPMethod | 'ALL', path: string, handlers: Array<SockressHandler | SockressErrorHandler>): this {
298
+ if (!handlers.length) {
299
+ throw new Error(`Route ${method} ${path} requires at least one handler`);
300
+ }
301
+ this.routes.push({
302
+ method,
303
+ matcher: buildMatcher(path),
304
+ handlers
305
+ });
306
+ return this;
307
+ }
308
+
309
+ get(path: string, ...handlers: Array<SockressHandler | SockressErrorHandler>): this {
310
+ return this.register('GET', path, handlers);
311
+ }
312
+
313
+ post(path: string, ...handlers: Array<SockressHandler | SockressErrorHandler>): this {
314
+ return this.register('POST', path, handlers);
315
+ }
316
+
317
+ put(path: string, ...handlers: Array<SockressHandler | SockressErrorHandler>): this {
318
+ return this.register('PUT', path, handlers);
319
+ }
320
+
321
+ patch(path: string, ...handlers: Array<SockressHandler | SockressErrorHandler>): this {
322
+ return this.register('PATCH', path, handlers);
323
+ }
324
+
325
+ delete(path: string, ...handlers: Array<SockressHandler | SockressErrorHandler>): this {
326
+ return this.register('DELETE', path, handlers);
327
+ }
328
+
329
+ head(path: string, ...handlers: Array<SockressHandler | SockressErrorHandler>): this {
330
+ return this.register('HEAD', path, handlers);
331
+ }
332
+
333
+ options(path: string, ...handlers: Array<SockressHandler | SockressErrorHandler>): this {
334
+ return this.register('OPTIONS', path, handlers);
335
+ }
336
+
337
+ all(path: string, ...handlers: Array<SockressHandler | SockressErrorHandler>): this {
338
+ return this.register('ALL', path, handlers);
339
+ }
340
+
341
+ listen(port: number, host?: string, callback?: (address: AddressInfo) => void): http.Server {
342
+ if (this.server) {
343
+ throw new Error('Sockress server is already running');
344
+ }
345
+ const httpServer = http.createServer((req, res) => this.handleHttp(req, res));
346
+ const wss = new WebSocketServer({ noServer: true });
347
+ httpServer.on('upgrade', (req, socket, head) => {
348
+ const { pathname } = new URL(req.url ?? '/', `http://${req.headers.host || 'localhost'}`);
349
+ if (pathname !== this.config.socket.path) {
350
+ socket.destroy();
351
+ return;
352
+ }
353
+ if (!isOriginAllowed(req.headers.origin, this.config.cors.origin)) {
354
+ socket.destroy();
355
+ return;
356
+ }
357
+ wss.handleUpgrade(req, socket, head, (ws) => {
358
+ wss.emit('connection', ws, req);
359
+ });
360
+ });
361
+ wss.on('connection', (socket, req) => this.handleSocket(socket, req));
362
+ this.server = httpServer;
363
+ this.wss = wss;
364
+ const listener = httpServer.listen(port, host, () => {
365
+ const address = httpServer.address() as AddressInfo;
366
+ if (callback) callback(address);
367
+ });
368
+ this.startHeartbeat();
369
+ return listener;
370
+ }
371
+
372
+ async close(): Promise<void> {
373
+ await Promise.all([
374
+ this.server
375
+ ? new Promise<void>((resolve, reject) => this.server!.close((err) => (err ? reject(err) : resolve())))
376
+ : Promise.resolve(),
377
+ this.wss
378
+ ? new Promise<void>((resolve, reject) => this.wss!.close((err) => (err ? reject(err) : resolve())))
379
+ : Promise.resolve()
380
+ ]);
381
+ if (this.heartbeatInterval) {
382
+ clearInterval(this.heartbeatInterval);
383
+ }
384
+ }
385
+
386
+ private startHeartbeat(): void {
387
+ if (!this.wss) return;
388
+ this.heartbeatInterval = setInterval(() => {
389
+ this.wss?.clients.forEach((socket: WebSocket & { isAlive?: boolean }) => {
390
+ if (socket.isAlive === false) {
391
+ return socket.terminate();
392
+ }
393
+ socket.isAlive = false;
394
+ socket.ping();
395
+ });
396
+ }, this.config.socket.heartbeatInterval);
397
+ }
398
+
399
+ private async handleHttp(req: IncomingMessage, res: ServerResponse): Promise<void> {
400
+ try {
401
+ const { method = 'GET' } = req;
402
+ const url = new URL(req.url ?? '/', `http://${req.headers.host || 'localhost'}`);
403
+ const path = url.pathname || '/';
404
+ const query = parseQuery(url.searchParams);
405
+ const cookies = req.headers.cookie ? parseCookie(req.headers.cookie) : {};
406
+ const body = await readBody(req, this.config.bodyLimit);
407
+ const parsedBody = parseBody(body, req.headers['content-type']);
408
+ const secure = isSocketEncrypted(req.socket as Socket);
409
+ const sockressReq = new SockressRequestImpl(
410
+ nanoid(),
411
+ method.toUpperCase() as HTTPMethod,
412
+ path,
413
+ query,
414
+ req.headers,
415
+ parsedBody,
416
+ cookies,
417
+ 'http',
418
+ getIp(req),
419
+ secure ? 'https' : 'http',
420
+ secure,
421
+ req
422
+ );
423
+ const origin = pickOrigin(req.headers.origin as string | undefined, this.config.cors.origin);
424
+ const sockressRes = new SockressResponse({ kind: 'http', req, res }, this.config.cors, origin);
425
+ if (sockressReq.method === 'OPTIONS') {
426
+ sockressRes.status(204).end();
427
+ return;
428
+ }
429
+ await this.runPipeline(sockressReq, sockressRes);
430
+ } catch (error) {
431
+ res.statusCode = 500;
432
+ res.setHeader('content-type', 'application/json; charset=utf-8');
433
+ res.end(JSON.stringify({ error: 'Internal Server Error', details: error instanceof Error ? error.message : error }));
434
+ }
435
+ }
436
+
437
+ private handleSocket(socket: WebSocket & { isAlive?: boolean }, req: IncomingMessage): void {
438
+ socket.isAlive = true;
439
+ socket.on('pong', () => {
440
+ socket.isAlive = true;
441
+ });
442
+ socket.on('message', async (raw) => {
443
+ try {
444
+ const payload = JSON.parse(raw.toString()) as IncomingSocketMessage;
445
+ if (payload.type !== 'request') {
446
+ return socket.send(JSON.stringify({ type: 'error', message: 'Unsupported message type' }));
447
+ }
448
+ const path = payload.path || '/';
449
+ const method = (payload.method || 'GET').toUpperCase() as HTTPMethod;
450
+ const query = payload.query ?? {};
451
+ const headers = payload.headers ?? {};
452
+ const cookies = headers.cookie ? parseCookie(headers.cookie) : {};
453
+ const secure = isSocketEncrypted(req.socket as Socket);
454
+ const sockressReq = new SockressRequestImpl(
455
+ payload.id ?? nanoid(),
456
+ method,
457
+ path,
458
+ query,
459
+ headers,
460
+ payload.body,
461
+ cookies,
462
+ 'socket',
463
+ getIp(req),
464
+ secure ? 'wss' : 'ws',
465
+ secure
466
+ );
467
+ const origin = pickOrigin(req.headers.origin as string | undefined, this.config.cors.origin);
468
+ const sockressRes = new SockressResponse({ kind: 'socket', socket, requestId: sockressReq.id }, this.config.cors, origin);
469
+ await this.runPipeline(sockressReq, sockressRes);
470
+ } catch (error) {
471
+ const outgoing: OutgoingSocketMessage = {
472
+ type: 'error',
473
+ message: error instanceof Error ? error.message : 'Unexpected socket payload'
474
+ };
475
+ socket.send(JSON.stringify(outgoing));
476
+ }
477
+ });
478
+ }
479
+
480
+ private async runPipeline(req: SockressRequest, res: SockressResponse): Promise<void> {
481
+ const stack = this.composeStack(req, req.method);
482
+ let idx = 0;
483
+ const next: NextFunction = async (err?: unknown) => {
484
+ const layer = stack[idx++];
485
+ if (!layer) {
486
+ if (err) {
487
+ this.renderError(err, req, res);
488
+ } else if (!res.isSent()) {
489
+ res.status(404).json({ error: 'Not Found' });
490
+ }
491
+ return;
492
+ }
493
+ const handler = layer.handler;
494
+ const isErrorHandler = layer.isErrorHandler;
495
+ try {
496
+ if (err) {
497
+ if (isErrorHandler) {
498
+ await (handler as SockressErrorHandler)(err, req, res, next);
499
+ } else {
500
+ await next(err);
501
+ }
502
+ return;
503
+ }
504
+ if (isErrorHandler) {
505
+ await next();
506
+ return;
507
+ }
508
+ await (handler as SockressHandler)(req, res, next);
509
+ } catch (error) {
510
+ await next(error);
511
+ }
512
+ };
513
+ await next();
514
+ }
515
+
516
+ private composeStack(req: SockressRequest, method: HTTPMethod): PipelineLayer[] {
517
+ const { path } = req;
518
+ const stack: PipelineLayer[] = [];
519
+ for (const layer of this.middlewares) {
520
+ if (matchesPrefix(layer.path, path)) {
521
+ stack.push({
522
+ handler: layer.handler,
523
+ isErrorHandler: layer.isErrorHandler
524
+ });
525
+ }
526
+ }
527
+ for (const route of this.routes) {
528
+ if (route.method !== method && route.method !== 'ALL') {
529
+ continue;
530
+ }
531
+ const match = route.matcher.match(path);
532
+ if (!match) continue;
533
+ for (const handler of route.handlers) {
534
+ const isErrorHandler = handler.length === 4;
535
+ if (isErrorHandler) {
536
+ const wrapped: SockressErrorHandler = (err, request, res, next) => {
537
+ request.params = { ...match.params };
538
+ return (handler as SockressErrorHandler)(err, request, res, next);
539
+ };
540
+ stack.push({ handler: wrapped, isErrorHandler: true });
541
+ } else {
542
+ const wrapped: SockressHandler = (request, res, next) => {
543
+ request.params = { ...match.params };
544
+ return (handler as SockressHandler)(request, res, next);
545
+ };
546
+ stack.push({ handler: wrapped, isErrorHandler: false });
547
+ }
548
+ }
549
+ }
550
+ return stack;
551
+ }
552
+
553
+ private renderError(err: unknown, req: SockressRequest, res: SockressResponse): void {
554
+ if (res.isSent()) {
555
+ return;
556
+ }
557
+ res.status(500).json({
558
+ error: 'Internal Server Error',
559
+ details: err instanceof Error ? err.message : err
560
+ });
561
+ }
562
+ }
563
+
564
+ export function createSockress(options?: SockressOptions): SockressApp {
565
+ return SockressApp.create(options);
566
+ }
567
+
568
+ function normalizeOptions(options?: SockressOptions): NormalizedOptions {
569
+ const cors: CorsOptions = {
570
+ origin: options?.cors?.origin ?? '*',
571
+ credentials: options?.cors?.credentials ?? true,
572
+ methods: options?.cors?.methods ?? ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
573
+ allowedHeaders: options?.cors?.allowedHeaders ?? ['Content-Type', 'Authorization', 'X-Requested-With'],
574
+ exposedHeaders: options?.cors?.exposedHeaders ?? [],
575
+ maxAge: options?.cors?.maxAge ?? 600
576
+ };
577
+ const socket: SocketOptions = {
578
+ path: options?.socket?.path ?? '/sockress',
579
+ heartbeatInterval: options?.socket?.heartbeatInterval ?? 30_000,
580
+ idleTimeout: options?.socket?.idleTimeout ?? 120_000
581
+ };
582
+ const bodyLimit = options?.bodyLimit ?? 1_000_000;
583
+ return { cors, socket, bodyLimit };
584
+ }
585
+
586
+ function buildMatcher(path: string): PathMatcher {
587
+ if (path === '*' || path === '/*') {
588
+ return {
589
+ raw: path,
590
+ match: (incoming: string) => ({ params: { wild: incoming.replace(/^\//, '') } })
591
+ };
592
+ }
593
+ const keys: string[] = [];
594
+ const pattern = path
595
+ .split('/')
596
+ .map((segment) => {
597
+ if (!segment) return '';
598
+ if (segment.startsWith(':')) {
599
+ const key = segment.replace(/^:/, '').replace(/\?$/, '');
600
+ keys.push(key);
601
+ return segment.endsWith('?') ? '(?:\\/([^/]+))?' : '\\/([^/]+)';
602
+ }
603
+ if (segment === '*') {
604
+ keys.push('wild');
605
+ return '\\/(.*)';
606
+ }
607
+ return `\\/${segment.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`;
608
+ })
609
+ .join('');
610
+ const regex = new RegExp(`^${pattern || '\\/'}\\/?$`);
611
+ return {
612
+ raw: path,
613
+ match: (incoming: string) => {
614
+ const exec = regex.exec(incoming === '' ? '/' : incoming);
615
+ if (!exec) {
616
+ return null;
617
+ }
618
+ const params: Record<string, string> = {};
619
+ keys.forEach((key, index) => {
620
+ const value = exec[index + 1];
621
+ if (value !== undefined) {
622
+ params[key] = decodeURIComponent(value);
623
+ }
624
+ });
625
+ return { params };
626
+ }
627
+ };
628
+ }
629
+
630
+ async function readBody(req: IncomingMessage, limit: number): Promise<Buffer> {
631
+ return new Promise<Buffer>((resolve, reject) => {
632
+ const chunks: Buffer[] = [];
633
+ let total = 0;
634
+ req.on('data', (chunk: Buffer) => {
635
+ total += chunk.length;
636
+ if (total > limit) {
637
+ reject(new Error('Payload too large'));
638
+ req.destroy();
639
+ return;
640
+ }
641
+ chunks.push(chunk);
642
+ });
643
+ req.on('end', () => resolve(Buffer.concat(chunks)));
644
+ req.on('error', (err) => reject(err));
645
+ });
646
+ }
647
+
648
+ function parseBody(buffer: Buffer, contentType?: string): unknown {
649
+ if (!buffer.length) return undefined;
650
+ const type = contentType?.split(';')[0].trim().toLowerCase();
651
+ if (type === 'application/json') {
652
+ const text = buffer.toString('utf8');
653
+ return text ? JSON.parse(text) : undefined;
654
+ }
655
+ if (type === 'application/x-www-form-urlencoded') {
656
+ const params = new URLSearchParams(buffer.toString('utf8'));
657
+ const result: Record<string, string | string[]> = {};
658
+ for (const [key, value] of params.entries()) {
659
+ if (result[key]) {
660
+ const existing = result[key];
661
+ result[key] = Array.isArray(existing) ? [...existing, value] : [existing as string, value];
662
+ } else {
663
+ result[key] = value;
664
+ }
665
+ }
666
+ return result;
667
+ }
668
+ return buffer;
669
+ }
670
+
671
+ function parseQuery(searchParams: URLSearchParams): Record<string, string | string[]> {
672
+ const result: Record<string, string | string[]> = {};
673
+ for (const [key, value] of searchParams.entries()) {
674
+ if (result[key]) {
675
+ const existing = result[key];
676
+ result[key] = Array.isArray(existing) ? [...existing, value] : [existing as string, value];
677
+ } else {
678
+ result[key] = value;
679
+ }
680
+ }
681
+ return result;
682
+ }
683
+
684
+ function matchesPrefix(base: string, path: string): boolean {
685
+ if (base === '/' || base === '') return true;
686
+ if (!base.startsWith('/')) {
687
+ base = `/${base}`;
688
+ }
689
+ return path === base || path.startsWith(`${base}/`);
690
+ }
691
+
692
+ function getIp(req: IncomingMessage): string | undefined {
693
+ const forwarded = req.headers['x-forwarded-for'];
694
+ if (Array.isArray(forwarded)) {
695
+ return forwarded[0];
696
+ }
697
+ if (typeof forwarded === 'string') {
698
+ return forwarded.split(',')[0].trim();
699
+ }
700
+ return req.socket.remoteAddress ?? undefined;
701
+ }
702
+
703
+ function isOriginAllowed(originHeader: string | undefined, allowed: string | string[]): boolean {
704
+ if (!originHeader || allowed === '*') return true;
705
+ if (Array.isArray(allowed)) {
706
+ return allowed.includes(originHeader);
707
+ }
708
+ return allowed === originHeader;
709
+ }
710
+
711
+ function pickOrigin(requestOrigin: string | undefined, allowed: string | string[]): string {
712
+ if (allowed === '*') return '*';
713
+ if (Array.isArray(allowed)) {
714
+ if (requestOrigin && allowed.includes(requestOrigin)) {
715
+ return requestOrigin;
716
+ }
717
+ return allowed[0] ?? '*';
718
+ }
719
+ return allowed;
720
+ }
721
+
722
+ function isSocketEncrypted(socket: Socket): boolean {
723
+ return socket instanceof TLSSocket && Boolean(socket.encrypted);
724
+ }
725
+