sockress 0.2.5 → 0.2.6

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 DELETED
@@ -1,1141 +0,0 @@
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
- import multer, { Options as MulterOptions } from 'multer';
10
- import type { Multer } from 'multer';
11
- import path from 'path';
12
- import fs from 'fs';
13
- import { promises as fsp } from 'fs';
14
-
15
- export interface SockressAddress extends AddressInfo {
16
- hostname: string;
17
- url: string;
18
- }
19
-
20
- type ListenCallback = (error: Error | null, address?: SockressAddress) => void;
21
-
22
- type HTTPMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'HEAD' | 'OPTIONS';
23
-
24
- export type NextFunction = (err?: unknown) => void;
25
- export type SockressHandler = (req: SockressRequest, res: SockressResponse, next: NextFunction) => unknown;
26
- export type SockressErrorHandler = (err: unknown, req: SockressRequest, res: SockressResponse, next: NextFunction) => unknown;
27
-
28
- export interface SockressOptions {
29
- cors?: Partial<CorsOptions>;
30
- socket?: Partial<SocketOptions>;
31
- bodyLimit?: number;
32
- }
33
-
34
- interface SocketOptions {
35
- path: string;
36
- heartbeatInterval: number;
37
- idleTimeout: number;
38
- }
39
-
40
- interface CorsOptions {
41
- origin: string | string[];
42
- credentials: boolean;
43
- methods: HTTPMethod[];
44
- allowedHeaders: string[];
45
- exposedHeaders: string[];
46
- maxAge: number;
47
- }
48
-
49
- interface NormalizedOptions {
50
- cors: CorsOptions;
51
- socket: SocketOptions;
52
- bodyLimit: number;
53
- }
54
-
55
- export interface SockressUploaderOptions {
56
- dest?: string;
57
- limits?: MulterOptions['limits'];
58
- preserveFilename?: boolean;
59
- }
60
-
61
- export interface SockressUploader {
62
- single(field: string): SockressHandler;
63
- array(field: string, maxCount?: number): SockressHandler;
64
- fields(
65
- fields: Array<{
66
- name: string;
67
- maxCount?: number;
68
- }>
69
- ): SockressHandler;
70
- any(): SockressHandler;
71
- }
72
-
73
- export interface StaticOptions {
74
- index?: string;
75
- maxAge?: number;
76
- stripPrefix?: string;
77
- }
78
-
79
- interface MiddlewareLayer {
80
- path: string;
81
- handler: SockressHandler | SockressErrorHandler;
82
- isErrorHandler: boolean;
83
- }
84
-
85
- interface RouteLayer {
86
- method: HTTPMethod | 'ALL';
87
- matcher: PathMatcher;
88
- handlers: Array<SockressHandler | SockressErrorHandler>;
89
- }
90
-
91
- interface PipelineLayer {
92
- handler: SockressHandler | SockressErrorHandler;
93
- isErrorHandler: boolean;
94
- }
95
-
96
- interface PathMatcher {
97
- raw: string;
98
- match: (path: string) => PathMatchResult | null;
99
- }
100
-
101
- interface PathMatchResult {
102
- params: Record<string, string>;
103
- }
104
-
105
- type RequestMode =
106
- | { kind: 'http'; req: IncomingMessage; res: ServerResponse }
107
- | { kind: 'socket'; socket: WebSocket; requestId: string };
108
-
109
- interface IncomingSocketMessage {
110
- type: 'request';
111
- id?: string;
112
- method?: string;
113
- path?: string;
114
- headers?: Record<string, string | string[]>;
115
- query?: Record<string, string | string[]>;
116
- body?: unknown;
117
- }
118
-
119
- interface OutgoingSocketMessage {
120
- type: 'response' | 'error';
121
- id?: string;
122
- status?: number;
123
- headers?: Record<string, string>;
124
- body?: unknown;
125
- message?: string;
126
- code?: string;
127
- cookies?: string[];
128
- }
129
-
130
- export interface SockressUploadedFile {
131
- fieldName: string;
132
- name: string;
133
- type: string;
134
- size: number;
135
- buffer: Buffer;
136
- lastModified?: number;
137
- path?: string;
138
- }
139
-
140
- export interface SockressRequest {
141
- readonly id: string;
142
- readonly method: HTTPMethod;
143
- path: string;
144
- query: Record<string, string | string[]>;
145
- params: Record<string, string>;
146
- headers: Record<string, string | string[] | undefined>;
147
- body: unknown;
148
- cookies: Record<string, string>;
149
- file?: SockressUploadedFile;
150
- files?: Record<string, SockressUploadedFile[]>;
151
- readonly type: 'http' | 'socket';
152
- readonly ip: string | undefined;
153
- readonly protocol: 'http' | 'https' | 'ws' | 'wss';
154
- readonly secure: boolean;
155
- context: Record<string, unknown>;
156
- raw?: IncomingMessage;
157
- get(field: string): string | undefined;
158
- }
159
-
160
- export class SockressRequestImpl implements SockressRequest {
161
- public params: Record<string, string> = {};
162
- public context: Record<string, unknown> = {};
163
-
164
- constructor(
165
- public readonly id: string,
166
- public readonly method: HTTPMethod,
167
- public path: string,
168
- public query: Record<string, string | string[]>,
169
- public headers: Record<string, string | string[] | undefined>,
170
- public body: unknown,
171
- public cookies: Record<string, string>,
172
- public files: Record<string, SockressUploadedFile[]> | undefined,
173
- public file: SockressUploadedFile | undefined,
174
- public readonly type: 'http' | 'socket',
175
- public readonly ip: string | undefined,
176
- public readonly protocol: 'http' | 'https' | 'ws' | 'wss',
177
- public readonly secure: boolean,
178
- public raw?: IncomingMessage
179
- ) {}
180
-
181
- get(field: string): string | undefined {
182
- const key = field.toLowerCase();
183
- const value = this.headers[key];
184
- if (Array.isArray(value)) {
185
- return value[0];
186
- }
187
- if (typeof value === 'number') {
188
- return String(value);
189
- }
190
- return value as string | undefined;
191
- }
192
- }
193
-
194
- export class SockressResponse {
195
- private statusCode = 200;
196
- private sent = false;
197
- private headers: Record<string, string> = {};
198
- private cookies: string[] = [];
199
- public readonly raw?: ServerResponse;
200
-
201
- constructor(
202
- private readonly mode: RequestMode,
203
- private readonly cors: CorsOptions,
204
- private readonly allowedOrigin: string
205
- ) {
206
- if (mode.kind === 'http') {
207
- this.raw = mode.res;
208
- }
209
- }
210
-
211
- status(code: number): this {
212
- this.statusCode = code;
213
- return this;
214
- }
215
-
216
- set(field: string, value: string): this {
217
- this.headers[field.toLowerCase()] = value;
218
- return this;
219
- }
220
-
221
- append(field: string, value: string): this {
222
- const current = this.headers[field.toLowerCase()];
223
- if (current) {
224
- this.headers[field.toLowerCase()] = `${current}, ${value}`;
225
- } else {
226
- this.headers[field.toLowerCase()] = value;
227
- }
228
- return this;
229
- }
230
-
231
- cookie(name: string, value: string, options: CookieSerializeOptions = {}): this {
232
- this.cookies.push(serializeCookie(name, value, options));
233
- return this;
234
- }
235
-
236
- clearCookie(name: string, options: CookieSerializeOptions = {}): this {
237
- return this.cookie(name, '', { ...options, maxAge: 0 });
238
- }
239
-
240
- json(payload: unknown): this {
241
- this.set('content-type', 'application/json; charset=utf-8');
242
- return this.send(payload);
243
- }
244
-
245
- send(payload?: unknown): this {
246
- if (this.sent) {
247
- return this;
248
- }
249
- this.sent = true;
250
- if (!this.headers['content-type'] && typeof payload === 'string') {
251
- this.set('content-type', 'text/plain; charset=utf-8');
252
- }
253
- const headersWithCors = this.buildHeaders();
254
- if (this.mode.kind === 'http') {
255
- const res = this.mode.res;
256
- res.statusCode = this.statusCode;
257
- Object.entries(headersWithCors).forEach(([key, value]) => {
258
- res.setHeader(key, value);
259
- });
260
- if (this.cookies.length) {
261
- res.setHeader('Set-Cookie', this.cookies);
262
- }
263
- if (Buffer.isBuffer(payload)) {
264
- res.end(payload);
265
- } else if (typeof payload === 'string') {
266
- res.end(payload);
267
- } else if (payload === undefined || payload === null) {
268
- res.end();
269
- } else {
270
- const buffer = Buffer.from(JSON.stringify(payload));
271
- if (!this.headers['content-type']) {
272
- res.setHeader('content-type', 'application/json; charset=utf-8');
273
- }
274
- res.end(buffer);
275
- }
276
- return this;
277
- }
278
-
279
- const message: OutgoingSocketMessage = {
280
- type: 'response',
281
- id: this.mode.requestId,
282
- status: this.statusCode,
283
- headers: headersWithCors,
284
- body: payload,
285
- cookies: this.cookies.length ? [...this.cookies] : undefined
286
- };
287
- this.mode.socket.send(JSON.stringify(message));
288
- return this;
289
- }
290
-
291
- end(): this {
292
- return this.send();
293
- }
294
-
295
- isSent(): boolean {
296
- return this.sent;
297
- }
298
-
299
- private buildHeaders(): Record<string, string> {
300
- const headers = { ...this.headers };
301
- headers['access-control-allow-origin'] = this.allowedOrigin;
302
- headers['access-control-allow-credentials'] = String(this.cors.credentials);
303
- headers['access-control-allow-methods'] = this.cors.methods.join(', ');
304
- headers['access-control-allow-headers'] = this.cors.allowedHeaders.join(', ');
305
- headers['access-control-expose-headers'] = this.cors.exposedHeaders.join(', ');
306
- headers['access-control-max-age'] = String(this.cors.maxAge);
307
- return headers;
308
- }
309
- }
310
-
311
- export class SockressApp {
312
- private middlewares: MiddlewareLayer[] = [];
313
- private routes: RouteLayer[] = [];
314
- private server?: http.Server;
315
- private wss?: WebSocketServer;
316
- private heartbeatInterval?: NodeJS.Timeout;
317
- private shutdownRegistered = false;
318
- private shuttingDown = false;
319
-
320
- constructor(private readonly config: NormalizedOptions) {}
321
-
322
- static create(options?: SockressOptions): SockressApp {
323
- return new SockressApp(normalizeOptions(options));
324
- }
325
-
326
- use(path: string, ...handlers: Array<SockressHandler | SockressErrorHandler>): this;
327
- use(...handlers: Array<SockressHandler | SockressErrorHandler>): this;
328
- use(
329
- pathOrHandler: string | SockressHandler | SockressErrorHandler,
330
- ...rest: Array<SockressHandler | SockressErrorHandler>
331
- ): this {
332
- let path = '/';
333
- let stack: Array<SockressHandler | SockressErrorHandler> = [];
334
- if (typeof pathOrHandler === 'string') {
335
- path = pathOrHandler;
336
- stack = rest;
337
- } else {
338
- stack = [pathOrHandler, ...rest];
339
- }
340
- if (!stack.length) {
341
- throw new Error('use() requires at least one handler');
342
- }
343
- for (const handler of stack) {
344
- if (!handler) continue;
345
- this.middlewares.push({
346
- path,
347
- handler,
348
- isErrorHandler: handler.length === 4
349
- });
350
- }
351
- return this;
352
- }
353
-
354
- useStatic(route: string, directory: string, options?: StaticOptions): this {
355
- const handler = serveStatic(directory, { ...options, stripPrefix: options?.stripPrefix ?? route });
356
- return this.use(route, handler);
357
- }
358
-
359
- private register(method: HTTPMethod | 'ALL', path: string, handlers: Array<SockressHandler | SockressErrorHandler>): this {
360
- if (!handlers.length) {
361
- throw new Error(`Route ${method} ${path} requires at least one handler`);
362
- }
363
- this.routes.push({
364
- method,
365
- matcher: buildMatcher(path),
366
- handlers
367
- });
368
- return this;
369
- }
370
-
371
- get(path: string, ...handlers: Array<SockressHandler | SockressErrorHandler>): this {
372
- return this.register('GET', path, handlers);
373
- }
374
-
375
- post(path: string, ...handlers: Array<SockressHandler | SockressErrorHandler>): this {
376
- return this.register('POST', path, handlers);
377
- }
378
-
379
- put(path: string, ...handlers: Array<SockressHandler | SockressErrorHandler>): this {
380
- return this.register('PUT', path, handlers);
381
- }
382
-
383
- patch(path: string, ...handlers: Array<SockressHandler | SockressErrorHandler>): this {
384
- return this.register('PATCH', path, handlers);
385
- }
386
-
387
- delete(path: string, ...handlers: Array<SockressHandler | SockressErrorHandler>): this {
388
- return this.register('DELETE', path, handlers);
389
- }
390
-
391
- head(path: string, ...handlers: Array<SockressHandler | SockressErrorHandler>): this {
392
- return this.register('HEAD', path, handlers);
393
- }
394
-
395
- options(path: string, ...handlers: Array<SockressHandler | SockressErrorHandler>): this {
396
- return this.register('OPTIONS', path, handlers);
397
- }
398
-
399
- all(path: string, ...handlers: Array<SockressHandler | SockressErrorHandler>): this {
400
- return this.register('ALL', path, handlers);
401
- }
402
-
403
- listen(port: number, callback?: ListenCallback): http.Server;
404
- listen(port: number, host: string, callback?: ListenCallback): http.Server;
405
- listen(port: number, hostOrCallback?: string | ListenCallback, maybeCallback?: ListenCallback): http.Server {
406
- let host: string | undefined;
407
- let callback: ListenCallback | undefined;
408
- if (typeof hostOrCallback === 'function') {
409
- callback = hostOrCallback;
410
- } else {
411
- host = hostOrCallback;
412
- callback = maybeCallback;
413
- }
414
-
415
- if (this.server) {
416
- throw new Error('Sockress server is already running');
417
- }
418
- const httpServer = http.createServer((req, res) => this.handleHttp(req, res));
419
- const wss = new WebSocketServer({ noServer: true });
420
- httpServer.on('upgrade', (req, socket, head) => {
421
- const { pathname } = new URL(req.url ?? '/', `http://${req.headers.host || 'localhost'}`);
422
- if (pathname !== this.config.socket.path) {
423
- socket.destroy();
424
- return;
425
- }
426
- if (!isOriginAllowed(req.headers.origin, this.config.cors.origin)) {
427
- socket.destroy();
428
- return;
429
- }
430
- wss.handleUpgrade(req, socket, head, (ws) => {
431
- wss.emit('connection', ws, req);
432
- });
433
- });
434
- wss.on('connection', (socket, req) => this.handleSocket(socket, req));
435
- this.server = httpServer;
436
- this.wss = wss;
437
- const listener = httpServer.listen(port, host, () => {
438
- const addressInfo = httpServer.address();
439
- if (!addressInfo || typeof addressInfo === 'string') {
440
- callback?.(null, undefined);
441
- return;
442
- }
443
- callback?.(null, enhanceAddressInfo(addressInfo, host));
444
- });
445
- httpServer.on('error', (err) => callback?.(err));
446
- this.startHeartbeat();
447
- this.registerShutdownHooks();
448
- return listener;
449
- }
450
-
451
- async close(): Promise<void> {
452
- await Promise.all([
453
- this.server
454
- ? new Promise<void>((resolve, reject) => this.server!.close((err) => (err ? reject(err) : resolve())))
455
- : Promise.resolve(),
456
- this.wss
457
- ? new Promise<void>((resolve, reject) => this.wss!.close((err) => (err ? reject(err) : resolve())))
458
- : Promise.resolve()
459
- ]);
460
- if (this.heartbeatInterval) {
461
- clearInterval(this.heartbeatInterval);
462
- }
463
- }
464
-
465
- private startHeartbeat(): void {
466
- if (!this.wss) return;
467
- this.heartbeatInterval = setInterval(() => {
468
- this.wss?.clients.forEach((socket: WebSocket & { isAlive?: boolean }) => {
469
- if (socket.isAlive === false) {
470
- return socket.terminate();
471
- }
472
- socket.isAlive = false;
473
- socket.ping();
474
- });
475
- }, this.config.socket.heartbeatInterval);
476
- }
477
-
478
- private registerShutdownHooks(): void {
479
- if (this.shutdownRegistered) return;
480
- this.shutdownRegistered = true;
481
- if (typeof process === 'undefined' || !process.on) {
482
- return;
483
- }
484
- const finalize = () => {
485
- if (this.shuttingDown) return;
486
- this.shuttingDown = true;
487
- this.close().catch(() => undefined);
488
- };
489
- process.once('beforeExit', finalize);
490
- process.once('SIGINT', finalize);
491
- process.once('SIGTERM', finalize);
492
- }
493
-
494
- private async handleHttp(req: IncomingMessage, res: ServerResponse): Promise<void> {
495
- try {
496
- const { method = 'GET' } = req;
497
- const url = new URL(req.url ?? '/', `http://${req.headers.host || 'localhost'}`);
498
- const path = url.pathname || '/';
499
- const query = parseQuery(url.searchParams);
500
- const cookies = req.headers.cookie ? parseCookie(req.headers.cookie) : {};
501
- const contentType = (req.headers['content-type'] || '').toLowerCase();
502
- const skipBodyParsing = contentType.startsWith('multipart/form-data');
503
- let parsedBody: unknown;
504
- if (!skipBodyParsing) {
505
- const body = await readBody(req, this.config.bodyLimit);
506
- parsedBody = parseBody(body, req.headers['content-type']);
507
- }
508
- const normalizedPayload = normalizeBodyPayload(parsedBody);
509
- const primaryFile = pickPrimaryFile(normalizedPayload.files);
510
- const secure = isSocketEncrypted(req.socket as Socket);
511
- const sockressReq = new SockressRequestImpl(
512
- nanoid(),
513
- method.toUpperCase() as HTTPMethod,
514
- path,
515
- query,
516
- req.headers,
517
- normalizedPayload.body,
518
- cookies,
519
- normalizedPayload.files,
520
- primaryFile,
521
- 'http',
522
- getIp(req),
523
- secure ? 'https' : 'http',
524
- secure,
525
- req
526
- );
527
- const origin = pickOrigin(req.headers.origin as string | undefined, this.config.cors.origin);
528
- const sockressRes = new SockressResponse({ kind: 'http', req, res }, this.config.cors, origin);
529
- if (sockressReq.method === 'OPTIONS') {
530
- sockressRes.status(204).end();
531
- return;
532
- }
533
- await this.runPipeline(sockressReq, sockressRes);
534
- } catch (error) {
535
- res.statusCode = 500;
536
- res.setHeader('content-type', 'application/json; charset=utf-8');
537
- res.end(JSON.stringify({ error: 'Internal Server Error', details: error instanceof Error ? error.message : error }));
538
- }
539
- }
540
-
541
- private handleSocket(socket: WebSocket & { isAlive?: boolean }, req: IncomingMessage): void {
542
- socket.isAlive = true;
543
- socket.on('pong', () => {
544
- socket.isAlive = true;
545
- });
546
- socket.on('message', async (raw) => {
547
- try {
548
- const payload = JSON.parse(raw.toString()) as IncomingSocketMessage;
549
- if (payload.type !== 'request') {
550
- return socket.send(JSON.stringify({ type: 'error', message: 'Unsupported message type' }));
551
- }
552
- const path = payload.path || '/';
553
- const method = (payload.method || 'GET').toUpperCase() as HTTPMethod;
554
- const query = payload.query ?? {};
555
- const headers = normalizeHeaders(payload.headers ?? {});
556
- const cookieHeader = headers.cookie;
557
- const cookieString = Array.isArray(cookieHeader) ? cookieHeader[0] : cookieHeader;
558
- const cookies = typeof cookieString === 'string' ? parseCookie(cookieString) : {};
559
- const secure = isSocketEncrypted(req.socket as Socket);
560
- const normalizedPayload = normalizeBodyPayload(payload.body);
561
- const primaryFile = pickPrimaryFile(normalizedPayload.files);
562
- const sockressReq = new SockressRequestImpl(
563
- payload.id ?? nanoid(),
564
- method,
565
- path,
566
- query,
567
- headers,
568
- normalizedPayload.body,
569
- cookies,
570
- normalizedPayload.files,
571
- primaryFile,
572
- 'socket',
573
- getIp(req),
574
- secure ? 'wss' : 'ws',
575
- secure
576
- );
577
- const origin = pickOrigin(req.headers.origin as string | undefined, this.config.cors.origin);
578
- const sockressRes = new SockressResponse({ kind: 'socket', socket, requestId: sockressReq.id }, this.config.cors, origin);
579
- await this.runPipeline(sockressReq, sockressRes);
580
- } catch (error) {
581
- const outgoing: OutgoingSocketMessage = {
582
- type: 'error',
583
- message: error instanceof Error ? error.message : 'Unexpected socket payload'
584
- };
585
- socket.send(JSON.stringify(outgoing));
586
- }
587
- });
588
- }
589
-
590
- private async runPipeline(req: SockressRequest, res: SockressResponse): Promise<void> {
591
- const stack = this.composeStack(req, req.method);
592
- let idx = 0;
593
- const next: NextFunction = async (err?: unknown) => {
594
- const layer = stack[idx++];
595
- if (!layer) {
596
- if (err) {
597
- this.renderError(err, req, res);
598
- } else if (!res.isSent()) {
599
- res.status(404).json({ error: 'Not Found' });
600
- }
601
- return;
602
- }
603
- const handler = layer.handler;
604
- const isErrorHandler = layer.isErrorHandler;
605
- try {
606
- if (err) {
607
- if (isErrorHandler) {
608
- await (handler as SockressErrorHandler)(err, req, res, next);
609
- } else {
610
- await next(err);
611
- }
612
- return;
613
- }
614
- if (isErrorHandler) {
615
- await next();
616
- return;
617
- }
618
- await (handler as SockressHandler)(req, res, next);
619
- } catch (error) {
620
- await next(error);
621
- }
622
- };
623
- await next();
624
- }
625
-
626
- private composeStack(req: SockressRequest, method: HTTPMethod): PipelineLayer[] {
627
- const { path } = req;
628
- const stack: PipelineLayer[] = [];
629
- for (const layer of this.middlewares) {
630
- if (matchesPrefix(layer.path, path)) {
631
- stack.push({
632
- handler: layer.handler,
633
- isErrorHandler: layer.isErrorHandler
634
- });
635
- }
636
- }
637
- for (const route of this.routes) {
638
- if (route.method !== method && route.method !== 'ALL') {
639
- continue;
640
- }
641
- const match = route.matcher.match(path);
642
- if (!match) continue;
643
- for (const handler of route.handlers) {
644
- const isErrorHandler = handler.length === 4;
645
- if (isErrorHandler) {
646
- const wrapped: SockressErrorHandler = (err, request, res, next) => {
647
- request.params = { ...match.params };
648
- return (handler as SockressErrorHandler)(err, request, res, next);
649
- };
650
- stack.push({ handler: wrapped, isErrorHandler: true });
651
- } else {
652
- const wrapped: SockressHandler = (request, res, next) => {
653
- request.params = { ...match.params };
654
- return (handler as SockressHandler)(request, res, next);
655
- };
656
- stack.push({ handler: wrapped, isErrorHandler: false });
657
- }
658
- }
659
- }
660
- return stack;
661
- }
662
-
663
- private renderError(err: unknown, req: SockressRequest, res: SockressResponse): void {
664
- if (res.isSent()) {
665
- return;
666
- }
667
- res.status(500).json({
668
- error: 'Internal Server Error',
669
- details: err instanceof Error ? err.message : err
670
- });
671
- }
672
- }
673
-
674
- export function sockress(options?: SockressOptions): SockressApp {
675
- return SockressApp.create(options);
676
- }
677
-
678
- export const createSockress = sockress;
679
-
680
- function normalizeOptions(options?: SockressOptions): NormalizedOptions {
681
- const cors: CorsOptions = {
682
- origin: options?.cors?.origin ?? '*',
683
- credentials: options?.cors?.credentials ?? true,
684
- methods: options?.cors?.methods ?? ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
685
- allowedHeaders: options?.cors?.allowedHeaders ?? ['Content-Type', 'Authorization', 'X-Requested-With'],
686
- exposedHeaders: options?.cors?.exposedHeaders ?? [],
687
- maxAge: options?.cors?.maxAge ?? 600
688
- };
689
- const socket: SocketOptions = {
690
- path: options?.socket?.path ?? '/sockress',
691
- heartbeatInterval: options?.socket?.heartbeatInterval ?? 30_000,
692
- idleTimeout: options?.socket?.idleTimeout ?? 120_000
693
- };
694
- const bodyLimit = options?.bodyLimit ?? 1_000_000;
695
- return { cors, socket, bodyLimit };
696
- }
697
-
698
- function buildMatcher(path: string): PathMatcher {
699
- if (path === '*' || path === '/*') {
700
- return {
701
- raw: path,
702
- match: (incoming: string) => ({ params: { wild: incoming.replace(/^\//, '') } })
703
- };
704
- }
705
- const keys: string[] = [];
706
- const pattern = path
707
- .split('/')
708
- .map((segment) => {
709
- if (!segment) return '';
710
- if (segment.startsWith(':')) {
711
- const key = segment.replace(/^:/, '').replace(/\?$/, '');
712
- keys.push(key);
713
- return segment.endsWith('?') ? '(?:\\/([^/]+))?' : '\\/([^/]+)';
714
- }
715
- if (segment === '*') {
716
- keys.push('wild');
717
- return '\\/(.*)';
718
- }
719
- return `\\/${segment.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`;
720
- })
721
- .join('');
722
- const regex = new RegExp(`^${pattern || '\\/'}\\/?$`);
723
- return {
724
- raw: path,
725
- match: (incoming: string) => {
726
- const exec = regex.exec(incoming === '' ? '/' : incoming);
727
- if (!exec) {
728
- return null;
729
- }
730
- const params: Record<string, string> = {};
731
- keys.forEach((key, index) => {
732
- const value = exec[index + 1];
733
- if (value !== undefined) {
734
- params[key] = decodeURIComponent(value);
735
- }
736
- });
737
- return { params };
738
- }
739
- };
740
- }
741
-
742
- async function readBody(req: IncomingMessage, limit: number): Promise<Buffer> {
743
- return new Promise<Buffer>((resolve, reject) => {
744
- const chunks: Buffer[] = [];
745
- let total = 0;
746
- req.on('data', (chunk: Buffer) => {
747
- total += chunk.length;
748
- if (total > limit) {
749
- reject(new Error('Payload too large'));
750
- req.destroy();
751
- return;
752
- }
753
- chunks.push(chunk);
754
- });
755
- req.on('end', () => resolve(Buffer.concat(chunks)));
756
- req.on('error', (err) => reject(err));
757
- });
758
- }
759
-
760
- function parseBody(buffer: Buffer, contentType?: string): unknown {
761
- if (!buffer.length) return undefined;
762
- const type = contentType?.split(';')[0].trim().toLowerCase();
763
- if (type === 'application/json') {
764
- const text = buffer.toString('utf8');
765
- return text ? JSON.parse(text) : undefined;
766
- }
767
- if (type === 'application/x-www-form-urlencoded') {
768
- const params = new URLSearchParams(buffer.toString('utf8'));
769
- const result: Record<string, string | string[]> = {};
770
- for (const [key, value] of params.entries()) {
771
- if (result[key]) {
772
- const existing = result[key];
773
- result[key] = Array.isArray(existing) ? [...existing, value] : [existing as string, value];
774
- } else {
775
- result[key] = value;
776
- }
777
- }
778
- return result;
779
- }
780
- return buffer;
781
- }
782
-
783
- function parseQuery(searchParams: URLSearchParams): Record<string, string | string[]> {
784
- const result: Record<string, string | string[]> = {};
785
- for (const [key, value] of searchParams.entries()) {
786
- if (result[key]) {
787
- const existing = result[key];
788
- result[key] = Array.isArray(existing) ? [...existing, value] : [existing as string, value];
789
- } else {
790
- result[key] = value;
791
- }
792
- }
793
- return result;
794
- }
795
-
796
- function matchesPrefix(base: string, path: string): boolean {
797
- if (base === '/' || base === '') return true;
798
- if (!base.startsWith('/')) {
799
- base = `/${base}`;
800
- }
801
- return path === base || path.startsWith(`${base}/`);
802
- }
803
-
804
- function getIp(req: IncomingMessage): string | undefined {
805
- const forwarded = req.headers['x-forwarded-for'];
806
- if (Array.isArray(forwarded)) {
807
- return forwarded[0];
808
- }
809
- if (typeof forwarded === 'string') {
810
- return forwarded.split(',')[0].trim();
811
- }
812
- return req.socket.remoteAddress ?? undefined;
813
- }
814
-
815
- function isOriginAllowed(originHeader: string | undefined, allowed: string | string[]): boolean {
816
- if (!originHeader || allowed === '*') return true;
817
- if (Array.isArray(allowed)) {
818
- return allowed.includes(originHeader);
819
- }
820
- return allowed === originHeader;
821
- }
822
-
823
- function pickOrigin(requestOrigin: string | undefined, allowed: string | string[]): string {
824
- if (allowed === '*') return '*';
825
- if (Array.isArray(allowed)) {
826
- if (requestOrigin && allowed.includes(requestOrigin)) {
827
- return requestOrigin;
828
- }
829
- return allowed[0] ?? '*';
830
- }
831
- return allowed;
832
- }
833
-
834
- function isSocketEncrypted(socket: Socket): boolean {
835
- return socket instanceof TLSSocket && Boolean(socket.encrypted);
836
- }
837
-
838
- interface SocketFormDataEnvelope {
839
- fields?: Record<string, string | string[]>;
840
- files?: Record<string, SerializedSocketFile[]>;
841
- }
842
-
843
- interface SerializedSocketFile {
844
- fieldName?: string;
845
- name?: string;
846
- type?: string;
847
- size?: number;
848
- data: string;
849
- lastModified?: number;
850
- }
851
-
852
- interface NormalizedBodyPayload {
853
- body: unknown;
854
- files?: Record<string, SockressUploadedFile[]>;
855
- }
856
-
857
- function normalizeBodyPayload(value: unknown): NormalizedBodyPayload {
858
- if (
859
- value &&
860
- typeof value === 'object' &&
861
- '__formData' in (value as Record<string, unknown>) &&
862
- typeof (value as Record<string, unknown>).__formData === 'object'
863
- ) {
864
- const form = ((value as Record<string, unknown>).__formData || {}) as SocketFormDataEnvelope;
865
- const files = convertSerializedFiles(form.files ?? {});
866
- const fields = form.fields ?? {};
867
- return {
868
- body: fields,
869
- files: Object.keys(files).length ? files : undefined
870
- };
871
- }
872
- return { body: value === undefined ? {} : value };
873
- }
874
-
875
- function convertSerializedFiles(
876
- serialized: Record<string, SerializedSocketFile[]>
877
- ): Record<string, SockressUploadedFile[]> {
878
- const files: Record<string, SockressUploadedFile[]> = {};
879
- for (const [field, entries] of Object.entries(serialized)) {
880
- files[field] = entries
881
- .filter((entry) => typeof entry.data === 'string')
882
- .map((entry) => ({
883
- fieldName: field,
884
- name: entry.name ?? 'file',
885
- type: entry.type ?? 'application/octet-stream',
886
- size: entry.size ?? Buffer.from(entry.data, 'base64').length,
887
- buffer: Buffer.from(entry.data, 'base64'),
888
- lastModified: entry.lastModified
889
- }));
890
- }
891
- return files;
892
- }
893
-
894
- function pickPrimaryFile(files?: Record<string, SockressUploadedFile[]>): SockressUploadedFile | undefined {
895
- if (!files) {
896
- return undefined;
897
- }
898
- const firstKey = Object.keys(files)[0];
899
- if (!firstKey) return undefined;
900
- const list = files[firstKey];
901
- if (!Array.isArray(list) || !list.length) return undefined;
902
- return list[0];
903
- }
904
-
905
- export function createUploader(options?: SockressUploaderOptions): SockressUploader {
906
- const storage = multer.memoryStorage();
907
- const multerInstance = multer({
908
- storage,
909
- limits: options?.limits
910
- });
911
- const resolvedDest = options?.dest ? path.resolve(options.dest) : undefined;
912
- const wrap =
913
- (factory: (...args: any[]) => ReturnType<Multer['single']>) =>
914
- (...args: any[]): SockressHandler => {
915
- const middleware = factory(...args);
916
- return (req, res, next) => {
917
- if (req.type === 'socket') {
918
- if (!resolvedDest || !req.files) {
919
- if (!req.file) {
920
- req.file = pickPrimaryFile(req.files);
921
- }
922
- next();
923
- return;
924
- }
925
- persistFilesToDisk(req.files, resolvedDest, options?.preserveFilename)
926
- .then(() => {
927
- if (!req.file) {
928
- req.file = pickPrimaryFile(req.files);
929
- }
930
- next();
931
- })
932
- .catch(next);
933
- return;
934
- }
935
- if (!req.raw || !res.raw) {
936
- next(new Error('Uploads require an HTTP request'));
937
- return;
938
- }
939
- middleware(req.raw as any, res.raw as any, (err?: any) => {
940
- if (err) {
941
- next(err);
942
- return;
943
- }
944
- const normalized = normalizeMulterOutput(req.raw as any);
945
- req.body = mergeBodies(req.body, normalized.fields);
946
- req.files = normalized.files;
947
- req.file = normalized.file;
948
- if (!resolvedDest || !req.files) {
949
- next();
950
- return;
951
- }
952
- persistFilesToDisk(req.files, resolvedDest, options?.preserveFilename)
953
- .then(() => next())
954
- .catch(next);
955
- });
956
- };
957
- };
958
- return {
959
- single: (field) => wrap(multerInstance.single.bind(multerInstance))(field),
960
- array: (field, maxCount) => wrap(multerInstance.array.bind(multerInstance))(field, maxCount),
961
- fields: (defs) => wrap(multerInstance.fields.bind(multerInstance))(defs),
962
- any: () => wrap(multerInstance.any.bind(multerInstance))()
963
- };
964
- }
965
-
966
- export function serveStatic(root: string, options?: StaticOptions): SockressHandler {
967
- const resolvedRoot = path.resolve(root);
968
- const stripPrefix = options?.stripPrefix ? ensureLeadingSlash(options.stripPrefix) : '';
969
- const indexFile = options?.index ?? 'index.html';
970
- const maxAge = options?.maxAge ?? 0;
971
- return async (req, res, next) => {
972
- if (req.method !== 'GET' && req.method !== 'HEAD') {
973
- next();
974
- return;
975
- }
976
- let relativePath = req.path || '/';
977
- if (stripPrefix && relativePath.startsWith(stripPrefix)) {
978
- relativePath = relativePath.slice(stripPrefix.length) || '/';
979
- }
980
- const sanitized = sanitizeRelativePath(relativePath);
981
- let target = path.join(resolvedRoot, sanitized);
982
- try {
983
- let stats = await fsp.stat(target);
984
- if (stats.isDirectory()) {
985
- target = path.join(target, indexFile);
986
- stats = await fsp.stat(target);
987
- }
988
- const buffer = await fsp.readFile(target);
989
- res.set('cache-control', `public, max-age=${Math.floor(maxAge / 1000)}`);
990
- res.set('content-length', stats.size.toString());
991
- res.set('content-type', mimeFromExtension(path.extname(target)));
992
- if (req.method === 'HEAD') {
993
- res.end();
994
- return;
995
- }
996
- res.send(buffer);
997
- } catch {
998
- next();
999
- }
1000
- };
1001
- }
1002
-
1003
- function mergeBodies(body: unknown, nextBody: Record<string, unknown>): Record<string, unknown> {
1004
- const current = typeof body === 'object' && body !== null ? (body as Record<string, unknown>) : {};
1005
- return { ...current, ...nextBody };
1006
- }
1007
-
1008
- function normalizeMulterOutput(req: any): {
1009
- file?: SockressUploadedFile;
1010
- files?: Record<string, SockressUploadedFile[]>;
1011
- fields: Record<string, unknown>;
1012
- } {
1013
- const files: Record<string, SockressUploadedFile[]> = {};
1014
- const pushFile = (file: any) => {
1015
- if (!file) return;
1016
- const normalized: SockressUploadedFile = {
1017
- fieldName: file.fieldname || file.name || 'file',
1018
- name: file.originalname || file.filename || file.fieldname || 'file',
1019
- type: file.mimetype || 'application/octet-stream',
1020
- size: file.size ?? (file.buffer ? file.buffer.length : 0),
1021
- buffer: file.buffer ?? Buffer.alloc(0),
1022
- lastModified: file.lastModified
1023
- };
1024
- if (!files[normalized.fieldName]) {
1025
- files[normalized.fieldName] = [];
1026
- }
1027
- files[normalized.fieldName].push(normalized);
1028
- };
1029
- if (req.file) {
1030
- pushFile(req.file);
1031
- }
1032
- if (Array.isArray(req.files)) {
1033
- req.files.forEach(pushFile);
1034
- } else if (req.files && typeof req.files === 'object') {
1035
- Object.values(req.files).forEach((entry: any) => {
1036
- if (Array.isArray(entry)) {
1037
- entry.forEach(pushFile);
1038
- } else {
1039
- pushFile(entry);
1040
- }
1041
- });
1042
- }
1043
- return {
1044
- file: pickPrimaryFile(files),
1045
- files: Object.keys(files).length ? files : undefined,
1046
- fields: req.body ?? {}
1047
- };
1048
- }
1049
-
1050
- async function persistFilesToDisk(
1051
- files: Record<string, SockressUploadedFile[]>,
1052
- dest: string,
1053
- preserveFilename?: boolean
1054
- ): Promise<void> {
1055
- if (!Object.keys(files).length) return;
1056
- await fsp.mkdir(dest, { recursive: true });
1057
- for (const list of Object.values(files)) {
1058
- for (const file of list) {
1059
- const filename = preserveFilename ? sanitizeFilename(file.name) : `${Date.now()}-${nanoid(8)}${path.extname(file.name || '')}`;
1060
- const target = path.join(dest, filename);
1061
- await fsp.writeFile(target, file.buffer);
1062
- file.path = target;
1063
- }
1064
- }
1065
- }
1066
-
1067
- function sanitizeFilename(name: string): string {
1068
- return name.replace(/[^a-zA-Z0-9.\-_]/g, '_');
1069
- }
1070
-
1071
- function sanitizeRelativePath(requestPath: string): string {
1072
- const normalized = path.normalize(requestPath);
1073
- if (normalized.startsWith('..')) {
1074
- return normalized.replace(/^(\.\.(\/|\\|$))+/, '');
1075
- }
1076
- return normalized;
1077
- }
1078
-
1079
- function ensureLeadingSlash(value: string): string {
1080
- if (!value.startsWith('/')) {
1081
- return `/${value}`;
1082
- }
1083
- return value;
1084
- }
1085
-
1086
- function mimeFromExtension(ext: string): string {
1087
- switch (ext.toLowerCase()) {
1088
- case '.html':
1089
- case '.htm':
1090
- return 'text/html; charset=utf-8';
1091
- case '.css':
1092
- return 'text/css; charset=utf-8';
1093
- case '.js':
1094
- case '.mjs':
1095
- return 'application/javascript; charset=utf-8';
1096
- case '.json':
1097
- return 'application/json; charset=utf-8';
1098
- case '.png':
1099
- return 'image/png';
1100
- case '.jpg':
1101
- case '.jpeg':
1102
- return 'image/jpeg';
1103
- case '.gif':
1104
- return 'image/gif';
1105
- case '.svg':
1106
- return 'image/svg+xml';
1107
- case '.ico':
1108
- return 'image/x-icon';
1109
- default:
1110
- return 'application/octet-stream';
1111
- }
1112
- }
1113
-
1114
- function normalizeHeaders(headers: Record<string, string | string[]>): Record<string, string | string[]> {
1115
- const normalized: Record<string, string | string[]> = {};
1116
- for (const [key, value] of Object.entries(headers)) {
1117
- if (value === undefined || value === null) continue;
1118
- const headerKey = key.toLowerCase();
1119
- normalized[headerKey] = Array.isArray(value) ? value.map((entry) => String(entry)) : String(value);
1120
- }
1121
- return normalized;
1122
- }
1123
-
1124
- function enhanceAddressInfo(info: AddressInfo, preferredHost?: string): SockressAddress {
1125
- const hostname = normalizeHostname(preferredHost ?? info.address);
1126
- return {
1127
- ...info,
1128
- hostname,
1129
- url: `http://${hostname}:${info.port}`
1130
- };
1131
- }
1132
-
1133
- function normalizeHostname(host?: string): string {
1134
- if (!host) return 'localhost';
1135
- const lowered = host.toLowerCase();
1136
- if (lowered === '::' || lowered === '::1' || lowered === '0.0.0.0') {
1137
- return 'localhost';
1138
- }
1139
- return host;
1140
- }
1141
-