azurajs 3.0.1 → 3.0.2
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/config/index.js +128 -6
- package/dist/config/index.js.map +1 -1
- package/dist/config/index.mjs +130 -1
- package/dist/config/index.mjs.map +1 -1
- package/dist/core/index.js +1100 -11
- package/dist/core/index.js.map +1 -1
- package/dist/core/index.mjs +1102 -3
- package/dist/core/index.mjs.map +1 -1
- package/dist/decorators/index.js +117 -87
- package/dist/decorators/index.js.map +1 -1
- package/dist/decorators/index.mjs +98 -1
- package/dist/decorators/index.mjs.map +1 -1
- package/dist/index.js +2592 -236
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +2537 -9
- package/dist/index.mjs.map +1 -1
- package/dist/middleware/index.js +16 -7
- package/dist/middleware/index.js.map +1 -1
- package/dist/middleware/index.mjs +17 -1
- package/dist/middleware/index.mjs.map +1 -1
- package/dist/plugins/index.js +1056 -73
- package/dist/plugins/index.js.map +1 -1
- package/dist/plugins/index.mjs +1042 -1
- package/dist/plugins/index.mjs.map +1 -1
- package/dist/types/index.js +49 -12
- package/dist/types/index.js.map +1 -1
- package/dist/types/index.mjs +49 -2
- package/dist/types/index.mjs.map +1 -1
- package/dist/utils/index.js +551 -50
- package/dist/utils/index.js.map +1 -1
- package/dist/utils/index.mjs +541 -3
- package/dist/utils/index.mjs.map +1 -1
- package/package.json +35 -17
- package/{dist/chunk-DR254CWJ.mjs → src/config/ConfigModule.ts} +169 -132
- package/src/config/index.ts +1 -0
- package/src/core/index.ts +2 -0
- package/src/core/router.ts +284 -0
- package/{dist/chunk-EYAHUNC7.mjs → src/core/server.ts} +590 -699
- package/src/decorators/Route.ts +110 -0
- package/src/decorators/index.ts +23 -0
- package/src/index.ts +12 -0
- package/src/middleware/LoggingMiddleware.ts +20 -0
- package/src/middleware/index.ts +1 -0
- package/src/plugins/CORSPlugin.ts +56 -0
- package/src/plugins/CircuitBreakerPlugin.ts +84 -0
- package/src/plugins/CompressionPlugin.ts +80 -0
- package/src/plugins/ETagPlugin.ts +31 -0
- package/src/plugins/HealthCheckPlugin.ts +57 -0
- package/src/plugins/HelmetPlugin.ts +89 -0
- package/src/plugins/JWTPlugin.ts +132 -0
- package/src/plugins/MultipartPlugin.ts +168 -0
- package/src/plugins/ProxyPlugin.ts +89 -0
- package/src/plugins/RateLimitPlugin.ts +96 -0
- package/src/plugins/RequestIdPlugin.ts +21 -0
- package/src/plugins/SSEPlugin.ts +114 -0
- package/src/plugins/SessionPlugin.ts +98 -0
- package/src/plugins/StaticPlugin.ts +152 -0
- package/src/plugins/TimeoutPlugin.ts +33 -0
- package/src/plugins/index.ts +18 -0
- package/src/types/common.type.ts +82 -0
- package/src/types/config.type.ts +57 -0
- package/{dist/chunk-OWUGAI5V.mjs → src/types/http/status.ts} +49 -51
- package/src/types/index.ts +55 -0
- package/src/types/plugins/plugin.type.ts +170 -0
- package/src/types/reflect.d.ts +14 -0
- package/src/types/routes.type.ts +70 -0
- package/src/utils/HttpError.ts +62 -0
- package/src/utils/IpResolver.ts +30 -0
- package/src/utils/Logger.ts +144 -0
- package/src/utils/Parser.ts +182 -0
- package/src/utils/cookies/CookieManager.ts +48 -0
- package/src/utils/index.ts +9 -0
- package/{dist/chunk-UWIFSGSQ.mjs → src/utils/validators/DTOValidator.ts} +145 -141
- package/src/utils/validators/SchemaValidator.ts +45 -0
- package/dist/chunk-3UFAWS2V.js +0 -392
- package/dist/chunk-3UFAWS2V.js.map +0 -1
- package/dist/chunk-4LSFAAZW.js +0 -4
- package/dist/chunk-4LSFAAZW.js.map +0 -1
- package/dist/chunk-7NSRIVZM.js +0 -54
- package/dist/chunk-7NSRIVZM.js.map +0 -1
- package/dist/chunk-AOG6NYAM.js +0 -144
- package/dist/chunk-AOG6NYAM.js.map +0 -1
- package/dist/chunk-DR254CWJ.mjs.map +0 -1
- package/dist/chunk-EYAHUNC7.mjs.map +0 -1
- package/dist/chunk-HHDQPIJN.mjs +0 -19
- package/dist/chunk-HHDQPIJN.mjs.map +0 -1
- package/dist/chunk-HHZNAGGI.js +0 -702
- package/dist/chunk-HHZNAGGI.js.map +0 -1
- package/dist/chunk-KJM5XCAY.js +0 -21
- package/dist/chunk-KJM5XCAY.js.map +0 -1
- package/dist/chunk-NLSZKAPA.mjs +0 -1044
- package/dist/chunk-NLSZKAPA.mjs.map +0 -1
- package/dist/chunk-OWUGAI5V.mjs.map +0 -1
- package/dist/chunk-POPNQEOK.js +0 -1063
- package/dist/chunk-POPNQEOK.js.map +0 -1
- package/dist/chunk-QPRW4YU4.js +0 -134
- package/dist/chunk-QPRW4YU4.js.map +0 -1
- package/dist/chunk-REJDZUZ5.mjs +0 -382
- package/dist/chunk-REJDZUZ5.mjs.map +0 -1
- package/dist/chunk-TC6N6TJZ.mjs +0 -100
- package/dist/chunk-TC6N6TJZ.mjs.map +0 -1
- package/dist/chunk-TEUXKMXP.js +0 -122
- package/dist/chunk-TEUXKMXP.js.map +0 -1
- package/dist/chunk-UWIFSGSQ.mjs.map +0 -1
- package/dist/chunk-YPBKY4KY.mjs +0 -3
- package/dist/chunk-YPBKY4KY.mjs.map +0 -1
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { createHmac } from "node:crypto";
|
|
2
|
+
import type { JWTOptions, PluginHandler } from "../types/plugins/plugin.type.js";
|
|
3
|
+
import type { AzuraRequest } from "../types/common.type.js";
|
|
4
|
+
|
|
5
|
+
function base64urlEncode(data: string | Buffer): string {
|
|
6
|
+
const buf = typeof data === "string" ? Buffer.from(data) : data;
|
|
7
|
+
return buf.toString("base64url");
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function base64urlDecode(str: string): string {
|
|
11
|
+
return Buffer.from(str, "base64url").toString("utf-8");
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const ALGO_MAP: Record<string, string> = {
|
|
15
|
+
HS256: "sha256",
|
|
16
|
+
HS384: "sha384",
|
|
17
|
+
HS512: "sha512",
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export interface JWTPayload {
|
|
21
|
+
[key: string]: any;
|
|
22
|
+
iat?: number;
|
|
23
|
+
exp?: number;
|
|
24
|
+
iss?: string;
|
|
25
|
+
aud?: string;
|
|
26
|
+
sub?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function signJWT(payload: JWTPayload, secret: string, options: { algorithm?: string; expiresIn?: string | number } = {}): string {
|
|
30
|
+
const algorithm = options.algorithm ?? "HS256";
|
|
31
|
+
const header = base64urlEncode(JSON.stringify({ alg: algorithm, typ: "JWT" }));
|
|
32
|
+
|
|
33
|
+
const now = Math.floor(Date.now() / 1000);
|
|
34
|
+
const body: JWTPayload = { ...payload, iat: now };
|
|
35
|
+
|
|
36
|
+
if (options.expiresIn) {
|
|
37
|
+
body.exp = now + parseExpiration(options.expiresIn);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const encodedPayload = base64urlEncode(JSON.stringify(body));
|
|
41
|
+
const signature = createHmac(ALGO_MAP[algorithm] ?? "sha256", secret)
|
|
42
|
+
.update(`${header}.${encodedPayload}`)
|
|
43
|
+
.digest("base64url");
|
|
44
|
+
|
|
45
|
+
return `${header}.${encodedPayload}.${signature}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function verifyJWT(token: string, secret: string, options: { algorithm?: string } = {}): JWTPayload {
|
|
49
|
+
const parts = token.split(".");
|
|
50
|
+
if (parts.length !== 3) throw new Error("Invalid token format");
|
|
51
|
+
|
|
52
|
+
const [header, payload, signature] = parts;
|
|
53
|
+
const algorithm = options.algorithm ?? "HS256";
|
|
54
|
+
|
|
55
|
+
const expectedSig = createHmac(ALGO_MAP[algorithm] ?? "sha256", secret)
|
|
56
|
+
.update(`${header}.${payload}`)
|
|
57
|
+
.digest("base64url");
|
|
58
|
+
|
|
59
|
+
if (signature !== expectedSig) throw new Error("Invalid token signature");
|
|
60
|
+
|
|
61
|
+
const decoded = JSON.parse(base64urlDecode(payload)) as JWTPayload;
|
|
62
|
+
|
|
63
|
+
if (decoded.exp && decoded.exp < Math.floor(Date.now() / 1000)) {
|
|
64
|
+
throw new Error("Token expired");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return decoded;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function parseExpiration(exp: string | number): number {
|
|
71
|
+
if (typeof exp === "number") return exp;
|
|
72
|
+
const match = exp.match(/^(\d+)(s|m|h|d)$/);
|
|
73
|
+
if (!match) return 3600;
|
|
74
|
+
const val = parseInt(match[1], 10);
|
|
75
|
+
switch (match[2]) {
|
|
76
|
+
case "s": return val;
|
|
77
|
+
case "m": return val * 60;
|
|
78
|
+
case "h": return val * 3600;
|
|
79
|
+
case "d": return val * 86400;
|
|
80
|
+
default: return 3600;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function extractToken(req: AzuraRequest): string | null {
|
|
85
|
+
const auth = req.headers.authorization;
|
|
86
|
+
if (auth?.startsWith("Bearer ")) return auth.slice(7);
|
|
87
|
+
return (req.query.token as string) ?? req.cookies?.token ?? null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function JWTPlugin(options: JWTOptions): PluginHandler {
|
|
91
|
+
const {
|
|
92
|
+
secret,
|
|
93
|
+
algorithm = "HS256",
|
|
94
|
+
paths = [],
|
|
95
|
+
exclude = [],
|
|
96
|
+
getToken,
|
|
97
|
+
} = options;
|
|
98
|
+
|
|
99
|
+
return (ctx, next) => {
|
|
100
|
+
const { req, res } = ctx;
|
|
101
|
+
const path = req.pathname;
|
|
102
|
+
|
|
103
|
+
if (exclude.some((p) => path.startsWith(p))) {
|
|
104
|
+
next();
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (paths.length > 0 && !paths.some((p) => path.startsWith(p))) {
|
|
109
|
+
next();
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const token = getToken ? getToken(req) : extractToken(req);
|
|
114
|
+
|
|
115
|
+
if (!token) {
|
|
116
|
+
res.statusCode = 401;
|
|
117
|
+
res.setHeader("Content-Type", "application/json");
|
|
118
|
+
res.end(JSON.stringify({ error: { statusCode: 401, message: "No token provided" } }));
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
try {
|
|
123
|
+
const payload = verifyJWT(token, secret, { algorithm });
|
|
124
|
+
req.user = payload;
|
|
125
|
+
next();
|
|
126
|
+
} catch (err: any) {
|
|
127
|
+
res.statusCode = 401;
|
|
128
|
+
res.setHeader("Content-Type", "application/json");
|
|
129
|
+
res.end(JSON.stringify({ error: { statusCode: 401, message: err.message ?? "Invalid token" } }));
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { writeFileSync, mkdirSync, existsSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import type { MultipartOptions, PluginHandler } from "../types/plugins/plugin.type.js";
|
|
5
|
+
|
|
6
|
+
export interface UploadedFile {
|
|
7
|
+
fieldname: string;
|
|
8
|
+
filename: string;
|
|
9
|
+
mimetype: string;
|
|
10
|
+
size: number;
|
|
11
|
+
buffer: Buffer;
|
|
12
|
+
path?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function MultipartPlugin(options: MultipartOptions = {}): PluginHandler {
|
|
16
|
+
const {
|
|
17
|
+
maxFileSize = 10 * 1024 * 1024,
|
|
18
|
+
maxFiles = 10,
|
|
19
|
+
maxFieldSize = 1024 * 1024,
|
|
20
|
+
maxFields = 50,
|
|
21
|
+
allowedMimeTypes,
|
|
22
|
+
uploadDir,
|
|
23
|
+
} = options;
|
|
24
|
+
|
|
25
|
+
if (uploadDir && !existsSync(uploadDir)) {
|
|
26
|
+
mkdirSync(uploadDir, { recursive: true });
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return async (ctx, next) => {
|
|
30
|
+
const { req } = ctx;
|
|
31
|
+
const contentType = req.headers["content-type"] ?? "";
|
|
32
|
+
|
|
33
|
+
if (!contentType.startsWith("multipart/form-data")) {
|
|
34
|
+
next();
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const boundaryMatch = contentType.match(/boundary=([^\s;]+)/);
|
|
39
|
+
if (!boundaryMatch) {
|
|
40
|
+
next();
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const boundary = boundaryMatch[1];
|
|
45
|
+
|
|
46
|
+
try {
|
|
47
|
+
const { fields, files } = await parseMultipart(req as any, boundary, {
|
|
48
|
+
maxFileSize,
|
|
49
|
+
maxFiles,
|
|
50
|
+
maxFieldSize,
|
|
51
|
+
maxFields,
|
|
52
|
+
allowedMimeTypes,
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
if (uploadDir) {
|
|
56
|
+
for (const file of files) {
|
|
57
|
+
const ext = file.filename.includes(".")
|
|
58
|
+
? file.filename.slice(file.filename.lastIndexOf("."))
|
|
59
|
+
: "";
|
|
60
|
+
const savedName = `${randomBytes(16).toString("hex")}${ext}`;
|
|
61
|
+
const savePath = join(uploadDir, savedName);
|
|
62
|
+
writeFileSync(savePath, file.buffer);
|
|
63
|
+
file.path = savePath;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
(req as any).files = files;
|
|
68
|
+
(req as any).body = { ...((req as any).body ?? {}), ...fields };
|
|
69
|
+
|
|
70
|
+
next();
|
|
71
|
+
} catch (err: any) {
|
|
72
|
+
const { res } = ctx;
|
|
73
|
+
res.statusCode = 400;
|
|
74
|
+
res.setHeader("Content-Type", "application/json");
|
|
75
|
+
res.end(JSON.stringify({ error: { statusCode: 400, message: err.message } }));
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
interface ParseOptions {
|
|
81
|
+
maxFileSize: number;
|
|
82
|
+
maxFiles: number;
|
|
83
|
+
maxFieldSize: number;
|
|
84
|
+
maxFields: number;
|
|
85
|
+
allowedMimeTypes?: string[];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function parseMultipart(
|
|
89
|
+
req: NodeJS.ReadableStream,
|
|
90
|
+
boundary: string,
|
|
91
|
+
opts: ParseOptions,
|
|
92
|
+
): Promise<{ fields: Record<string, string>; files: UploadedFile[] }> {
|
|
93
|
+
const raw = await collectBody(req);
|
|
94
|
+
const delimiter = Buffer.from(`--${boundary}`);
|
|
95
|
+
const endDelimiter = Buffer.from(`--${boundary}--`);
|
|
96
|
+
|
|
97
|
+
const fields: Record<string, string> = {};
|
|
98
|
+
const files: UploadedFile[] = [];
|
|
99
|
+
let fieldCount = 0;
|
|
100
|
+
|
|
101
|
+
let start = bufferIndexOf(raw, delimiter, 0);
|
|
102
|
+
if (start === -1) return { fields, files };
|
|
103
|
+
start += delimiter.length + 2; // skip delimiter + CRLF
|
|
104
|
+
|
|
105
|
+
while (start < raw.length) {
|
|
106
|
+
const end = bufferIndexOf(raw, delimiter, start);
|
|
107
|
+
if (end === -1) break;
|
|
108
|
+
|
|
109
|
+
const part = raw.subarray(start, end - 2); // -2 for trailing CRLF before delimiter
|
|
110
|
+
const headerEnd = bufferIndexOf(part, Buffer.from("\r\n\r\n"), 0);
|
|
111
|
+
if (headerEnd === -1) { start = end + delimiter.length + 2; continue; }
|
|
112
|
+
|
|
113
|
+
const headerStr = part.subarray(0, headerEnd).toString("utf-8");
|
|
114
|
+
const body = part.subarray(headerEnd + 4);
|
|
115
|
+
|
|
116
|
+
const nameMatch = headerStr.match(/name="([^"]+)"/);
|
|
117
|
+
const filenameMatch = headerStr.match(/filename="([^"]*)"/);
|
|
118
|
+
const typeMatch = headerStr.match(/Content-Type:\s*(.+)/i);
|
|
119
|
+
|
|
120
|
+
if (filenameMatch && nameMatch) {
|
|
121
|
+
if (files.length >= opts.maxFiles) throw new Error("Too many files");
|
|
122
|
+
if (body.length > opts.maxFileSize) throw new Error(`File ${filenameMatch[1]} exceeds max size`);
|
|
123
|
+
|
|
124
|
+
const mimetype = typeMatch?.[1]?.trim() ?? "application/octet-stream";
|
|
125
|
+
if (opts.allowedMimeTypes && !opts.allowedMimeTypes.includes(mimetype)) {
|
|
126
|
+
throw new Error(`File type ${mimetype} not allowed`);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
files.push({
|
|
130
|
+
fieldname: nameMatch[1],
|
|
131
|
+
filename: filenameMatch[1],
|
|
132
|
+
mimetype,
|
|
133
|
+
size: body.length,
|
|
134
|
+
buffer: Buffer.from(body),
|
|
135
|
+
});
|
|
136
|
+
} else if (nameMatch) {
|
|
137
|
+
if (++fieldCount > opts.maxFields) throw new Error("Too many fields");
|
|
138
|
+
if (body.length > opts.maxFieldSize) throw new Error(`Field ${nameMatch[1]} exceeds max size`);
|
|
139
|
+
fields[nameMatch[1]] = body.toString("utf-8");
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
start = end + delimiter.length;
|
|
143
|
+
if (bufferIndexOf(raw, endDelimiter, end) === end) break;
|
|
144
|
+
start += 2;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return { fields, files };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function collectBody(stream: NodeJS.ReadableStream): Promise<Buffer> {
|
|
151
|
+
return new Promise((resolve, reject) => {
|
|
152
|
+
const chunks: Buffer[] = [];
|
|
153
|
+
stream.on("data", (chunk: Buffer) => chunks.push(chunk));
|
|
154
|
+
stream.on("end", () => resolve(Buffer.concat(chunks)));
|
|
155
|
+
stream.on("error", reject);
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function bufferIndexOf(buf: Buffer, search: Buffer, offset: number): number {
|
|
160
|
+
for (let i = offset; i <= buf.length - search.length; i++) {
|
|
161
|
+
let found = true;
|
|
162
|
+
for (let j = 0; j < search.length; j++) {
|
|
163
|
+
if (buf[i + j] !== search[j]) { found = false; break; }
|
|
164
|
+
}
|
|
165
|
+
if (found) return i;
|
|
166
|
+
}
|
|
167
|
+
return -1;
|
|
168
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { request as httpRequest } from "node:http";
|
|
2
|
+
import { request as httpsRequest } from "node:https";
|
|
3
|
+
import { URL } from "node:url";
|
|
4
|
+
import type { ProxyOptions, PluginHandler } from "../types/plugins/plugin.type.js";
|
|
5
|
+
|
|
6
|
+
export function ProxyPlugin(options: ProxyOptions): PluginHandler {
|
|
7
|
+
const {
|
|
8
|
+
target,
|
|
9
|
+
pathRewrite = {},
|
|
10
|
+
changeOrigin = true,
|
|
11
|
+
timeout = 30_000,
|
|
12
|
+
headers: extraHeaders = {},
|
|
13
|
+
onProxyReq,
|
|
14
|
+
onProxyRes,
|
|
15
|
+
} = options;
|
|
16
|
+
|
|
17
|
+
const targetUrl = new URL(target);
|
|
18
|
+
const isHttps = targetUrl.protocol === "https:";
|
|
19
|
+
const requestFn = isHttps ? httpsRequest : httpRequest;
|
|
20
|
+
|
|
21
|
+
return (ctx, next) => {
|
|
22
|
+
const { req, res } = ctx;
|
|
23
|
+
|
|
24
|
+
let targetPath = req.pathname;
|
|
25
|
+
for (const [from, to] of Object.entries(pathRewrite)) {
|
|
26
|
+
targetPath = targetPath.replace(new RegExp(from), to);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const search = req.url?.includes("?") ? req.url.slice(req.url.indexOf("?")) : "";
|
|
30
|
+
const fullPath = targetPath + search;
|
|
31
|
+
|
|
32
|
+
const proxyHeaders: Record<string, string> = {};
|
|
33
|
+
for (const [key, val] of Object.entries(req.headers)) {
|
|
34
|
+
if (val) proxyHeaders[key] = Array.isArray(val) ? val.join(", ") : val;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (changeOrigin) {
|
|
38
|
+
proxyHeaders.host = targetUrl.host;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
Object.assign(proxyHeaders, extraHeaders);
|
|
42
|
+
|
|
43
|
+
const proxyReq = requestFn(
|
|
44
|
+
{
|
|
45
|
+
hostname: targetUrl.hostname,
|
|
46
|
+
port: targetUrl.port || (isHttps ? 443 : 80),
|
|
47
|
+
path: fullPath,
|
|
48
|
+
method: req.method,
|
|
49
|
+
headers: proxyHeaders,
|
|
50
|
+
timeout,
|
|
51
|
+
},
|
|
52
|
+
(proxyRes) => {
|
|
53
|
+
onProxyRes?.(proxyRes, res as any);
|
|
54
|
+
|
|
55
|
+
res.statusCode = proxyRes.statusCode ?? 502;
|
|
56
|
+
for (const [key, val] of Object.entries(proxyRes.headers)) {
|
|
57
|
+
if (val) res.setHeader(key, val);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
proxyRes.pipe(res);
|
|
61
|
+
},
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
proxyReq.on("error", (err) => {
|
|
65
|
+
if (!res.headersSent) {
|
|
66
|
+
res.statusCode = 502;
|
|
67
|
+
res.setHeader("Content-Type", "application/json");
|
|
68
|
+
res.end(JSON.stringify({ error: { statusCode: 502, message: "Bad Gateway", details: err.message } }));
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
proxyReq.on("timeout", () => {
|
|
73
|
+
proxyReq.destroy();
|
|
74
|
+
if (!res.headersSent) {
|
|
75
|
+
res.statusCode = 504;
|
|
76
|
+
res.setHeader("Content-Type", "application/json");
|
|
77
|
+
res.end(JSON.stringify({ error: { statusCode: 504, message: "Gateway Timeout" } }));
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
onProxyReq?.(proxyReq, req as any);
|
|
82
|
+
|
|
83
|
+
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
84
|
+
req.pipe(proxyReq);
|
|
85
|
+
} else {
|
|
86
|
+
proxyReq.end();
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import type { RateLimitOptions, PluginHandler } from "../types/plugins/plugin.type.js";
|
|
2
|
+
import type { AzuraRequest } from "../types/common.type.js";
|
|
3
|
+
|
|
4
|
+
interface SlidingWindowEntry {
|
|
5
|
+
count: number;
|
|
6
|
+
resetTime: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
class MemoryStore {
|
|
10
|
+
private store = new Map<string, SlidingWindowEntry>();
|
|
11
|
+
private cleanupInterval: ReturnType<typeof setInterval>;
|
|
12
|
+
|
|
13
|
+
constructor(windowMs: number) {
|
|
14
|
+
this.cleanupInterval = setInterval(() => {
|
|
15
|
+
const now = Date.now();
|
|
16
|
+
for (const [key, entry] of this.store) {
|
|
17
|
+
if (now >= entry.resetTime) this.store.delete(key);
|
|
18
|
+
}
|
|
19
|
+
}, windowMs);
|
|
20
|
+
if (this.cleanupInterval.unref) this.cleanupInterval.unref();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
increment(key: string, windowMs: number): { totalHits: number; resetTime: Date } {
|
|
24
|
+
const now = Date.now();
|
|
25
|
+
const entry = this.store.get(key);
|
|
26
|
+
|
|
27
|
+
if (!entry || now >= entry.resetTime) {
|
|
28
|
+
const resetTime = now + windowMs;
|
|
29
|
+
this.store.set(key, { count: 1, resetTime });
|
|
30
|
+
return { totalHits: 1, resetTime: new Date(resetTime) };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
entry.count++;
|
|
34
|
+
return { totalHits: entry.count, resetTime: new Date(entry.resetTime) };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
decrement(key: string): void {
|
|
38
|
+
const entry = this.store.get(key);
|
|
39
|
+
if (entry && entry.count > 0) entry.count--;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
resetKey(key: string): void {
|
|
43
|
+
this.store.delete(key);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
destroy(): void {
|
|
47
|
+
clearInterval(this.cleanupInterval);
|
|
48
|
+
this.store.clear();
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function RateLimitPlugin(options: RateLimitOptions = {}): PluginHandler {
|
|
53
|
+
const {
|
|
54
|
+
windowMs = 60_000,
|
|
55
|
+
max = 100,
|
|
56
|
+
message = { error: { statusCode: 429, message: "Too many requests, please try again later" } },
|
|
57
|
+
statusCode = 429,
|
|
58
|
+
keyGenerator = (req: AzuraRequest) => req.ip,
|
|
59
|
+
skipSuccessfulRequests = false,
|
|
60
|
+
skipFailedRequests = false,
|
|
61
|
+
} = options;
|
|
62
|
+
|
|
63
|
+
const memStore = new MemoryStore(windowMs);
|
|
64
|
+
|
|
65
|
+
return (ctx, next) => {
|
|
66
|
+
const { req, res } = ctx;
|
|
67
|
+
const key = keyGenerator(req);
|
|
68
|
+
const { totalHits, resetTime } = memStore.increment(key, windowMs);
|
|
69
|
+
|
|
70
|
+
res.setHeader("X-RateLimit-Limit", String(max));
|
|
71
|
+
res.setHeader("X-RateLimit-Remaining", String(Math.max(0, max - totalHits)));
|
|
72
|
+
res.setHeader("X-RateLimit-Reset", String(Math.ceil(resetTime.getTime() / 1000)));
|
|
73
|
+
|
|
74
|
+
if (totalHits > max) {
|
|
75
|
+
res.setHeader("Retry-After", String(Math.ceil(windowMs / 1000)));
|
|
76
|
+
res.statusCode = statusCode;
|
|
77
|
+
const body = typeof message === "string" ? message : JSON.stringify(message);
|
|
78
|
+
res.setHeader("Content-Type", typeof message === "string" ? "text/plain" : "application/json");
|
|
79
|
+
res.end(body);
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const origEnd = res.end;
|
|
84
|
+
res.end = function (...args: any[]) {
|
|
85
|
+
if (skipSuccessfulRequests && res.statusCode < 400) {
|
|
86
|
+
memStore.decrement(key);
|
|
87
|
+
}
|
|
88
|
+
if (skipFailedRequests && res.statusCode >= 400) {
|
|
89
|
+
memStore.decrement(key);
|
|
90
|
+
}
|
|
91
|
+
return (origEnd as any).apply(this, args);
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
next();
|
|
95
|
+
};
|
|
96
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import type { RequestIdOptions, PluginHandler } from "../types/plugins/plugin.type.js";
|
|
3
|
+
|
|
4
|
+
export function RequestIdPlugin(options: RequestIdOptions = {}): PluginHandler {
|
|
5
|
+
const {
|
|
6
|
+
header = "X-Request-Id",
|
|
7
|
+
generator = randomUUID,
|
|
8
|
+
} = options;
|
|
9
|
+
|
|
10
|
+
return (ctx, next) => {
|
|
11
|
+
const { req, res } = ctx;
|
|
12
|
+
|
|
13
|
+
const existingId = req.headers[header.toLowerCase()] as string | undefined;
|
|
14
|
+
const requestId = existingId ?? generator();
|
|
15
|
+
|
|
16
|
+
req.requestId = requestId;
|
|
17
|
+
res.setHeader(header, requestId);
|
|
18
|
+
|
|
19
|
+
next();
|
|
20
|
+
};
|
|
21
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import type { SSEOptions, PluginHandler } from "../types/plugins/plugin.type.js";
|
|
2
|
+
import type { AzuraResponse } from "../types/common.type.js";
|
|
3
|
+
|
|
4
|
+
export interface SSEClient {
|
|
5
|
+
id: string;
|
|
6
|
+
res: AzuraResponse;
|
|
7
|
+
send(event: string, data: any, id?: string): void;
|
|
8
|
+
close(): void;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export class SSEManager {
|
|
12
|
+
private clients = new Map<string, SSEClient>();
|
|
13
|
+
private clientCounter = 0;
|
|
14
|
+
|
|
15
|
+
addClient(res: AzuraResponse): SSEClient {
|
|
16
|
+
const id = `sse-${++this.clientCounter}-${Date.now()}`;
|
|
17
|
+
|
|
18
|
+
res.setHeader("Content-Type", "text/event-stream");
|
|
19
|
+
res.setHeader("Cache-Control", "no-cache");
|
|
20
|
+
res.setHeader("Connection", "keep-alive");
|
|
21
|
+
res.setHeader("X-Accel-Buffering", "no");
|
|
22
|
+
res.statusCode = 200;
|
|
23
|
+
res.flushHeaders();
|
|
24
|
+
|
|
25
|
+
const client: SSEClient = {
|
|
26
|
+
id,
|
|
27
|
+
res,
|
|
28
|
+
send(event: string, data: any, eventId?: string) {
|
|
29
|
+
const serialized = typeof data === "string" ? data : JSON.stringify(data);
|
|
30
|
+
let message = "";
|
|
31
|
+
if (eventId) message += `id: ${eventId}\n`;
|
|
32
|
+
message += `event: ${event}\n`;
|
|
33
|
+
message += `data: ${serialized}\n\n`;
|
|
34
|
+
res.write(message);
|
|
35
|
+
},
|
|
36
|
+
close() {
|
|
37
|
+
res.end();
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
this.clients.set(id, client);
|
|
42
|
+
|
|
43
|
+
res.on("close", () => {
|
|
44
|
+
this.clients.delete(id);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
return client;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
broadcast(event: string, data: any, id?: string): void {
|
|
51
|
+
for (const client of this.clients.values()) {
|
|
52
|
+
client.send(event, data, id);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
getClient(id: string): SSEClient | undefined {
|
|
57
|
+
return this.clients.get(id);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
getClientCount(): number {
|
|
61
|
+
return this.clients.size;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
closeAll(): void {
|
|
65
|
+
for (const client of this.clients.values()) {
|
|
66
|
+
client.close();
|
|
67
|
+
}
|
|
68
|
+
this.clients.clear();
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function SSEPlugin(options: SSEOptions): PluginHandler {
|
|
73
|
+
const {
|
|
74
|
+
path,
|
|
75
|
+
heartbeatInterval = 30_000,
|
|
76
|
+
retry = 3000,
|
|
77
|
+
maxClients = 1000,
|
|
78
|
+
} = options;
|
|
79
|
+
|
|
80
|
+
const manager = new SSEManager();
|
|
81
|
+
|
|
82
|
+
let heartbeat: ReturnType<typeof setInterval> | null = null;
|
|
83
|
+
if (heartbeatInterval > 0) {
|
|
84
|
+
heartbeat = setInterval(() => {
|
|
85
|
+
manager.broadcast("heartbeat", { time: Date.now() });
|
|
86
|
+
}, heartbeatInterval);
|
|
87
|
+
if (heartbeat.unref) heartbeat.unref();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const plugin: PluginHandler & { manager: SSEManager } = Object.assign(
|
|
91
|
+
(ctx: any, next: any) => {
|
|
92
|
+
const { req, res } = ctx;
|
|
93
|
+
|
|
94
|
+
if (req.pathname !== path || req.method !== "GET") {
|
|
95
|
+
next();
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (manager.getClientCount() >= maxClients) {
|
|
100
|
+
res.statusCode = 503;
|
|
101
|
+
res.setHeader("Content-Type", "application/json");
|
|
102
|
+
res.end(JSON.stringify({ error: { statusCode: 503, message: "Too many SSE connections" } }));
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const client = manager.addClient(res as any);
|
|
107
|
+
res.write(`retry: ${retry}\n\n`);
|
|
108
|
+
client.send("connected", { clientId: client.id });
|
|
109
|
+
},
|
|
110
|
+
{ manager },
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
return plugin;
|
|
114
|
+
}
|