qhttpx 1.8.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/.eslintrc.json +22 -0
- package/.github/workflows/ci.yml +32 -0
- package/.github/workflows/npm-publish.yml +37 -0
- package/.github/workflows/release.yml +21 -0
- package/.prettierrc +7 -0
- package/CHANGELOG.md +145 -0
- package/LICENSE +21 -0
- package/README.md +343 -0
- package/dist/package.json +61 -0
- package/dist/src/benchmarks/compare-frameworks.js +119 -0
- package/dist/src/benchmarks/quantam-users.js +56 -0
- package/dist/src/benchmarks/simple-json.js +58 -0
- package/dist/src/benchmarks/ultra-mode.js +122 -0
- package/dist/src/cli/index.js +200 -0
- package/dist/src/client/index.js +72 -0
- package/dist/src/core/batch.js +97 -0
- package/dist/src/core/body-parser.js +121 -0
- package/dist/src/core/buffer-pool.js +70 -0
- package/dist/src/core/config.js +50 -0
- package/dist/src/core/fusion.js +183 -0
- package/dist/src/core/logger.js +49 -0
- package/dist/src/core/metrics.js +111 -0
- package/dist/src/core/resources.js +25 -0
- package/dist/src/core/scheduler.js +85 -0
- package/dist/src/core/scope.js +68 -0
- package/dist/src/core/serializer.js +44 -0
- package/dist/src/core/server.js +905 -0
- package/dist/src/core/stream.js +71 -0
- package/dist/src/core/tasks.js +87 -0
- package/dist/src/core/types.js +19 -0
- package/dist/src/core/websocket.js +86 -0
- package/dist/src/core/worker-queue.js +73 -0
- package/dist/src/database/adapters/memory.js +90 -0
- package/dist/src/database/adapters/mongo.js +141 -0
- package/dist/src/database/adapters/postgres.js +111 -0
- package/dist/src/database/adapters/sqlite.js +42 -0
- package/dist/src/database/coalescer.js +134 -0
- package/dist/src/database/manager.js +87 -0
- package/dist/src/database/types.js +2 -0
- package/dist/src/index.js +61 -0
- package/dist/src/middleware/compression.js +133 -0
- package/dist/src/middleware/cors.js +66 -0
- package/dist/src/middleware/presets.js +33 -0
- package/dist/src/middleware/rate-limit.js +77 -0
- package/dist/src/middleware/security.js +69 -0
- package/dist/src/middleware/static.js +191 -0
- package/dist/src/openapi/generator.js +149 -0
- package/dist/src/router/radix-router.js +89 -0
- package/dist/src/router/radix-tree.js +81 -0
- package/dist/src/router/router.js +146 -0
- package/dist/src/testing/index.js +84 -0
- package/dist/src/utils/cookies.js +59 -0
- package/dist/src/utils/logger.js +45 -0
- package/dist/src/utils/signals.js +31 -0
- package/dist/src/utils/sse.js +32 -0
- package/dist/src/validation/index.js +19 -0
- package/dist/src/validation/simple.js +102 -0
- package/dist/src/validation/types.js +12 -0
- package/dist/src/validation/zod.js +18 -0
- package/dist/src/views/index.js +17 -0
- package/dist/src/views/types.js +2 -0
- package/dist/tests/adapters.test.js +106 -0
- package/dist/tests/batch.test.js +117 -0
- package/dist/tests/body-parser.test.js +52 -0
- package/dist/tests/compression-sse.test.js +87 -0
- package/dist/tests/cookies.test.js +63 -0
- package/dist/tests/cors.test.js +55 -0
- package/dist/tests/database.test.js +80 -0
- package/dist/tests/dx.test.js +64 -0
- package/dist/tests/ecosystem.test.js +133 -0
- package/dist/tests/features.test.js +47 -0
- package/dist/tests/fusion.test.js +92 -0
- package/dist/tests/http-basic.test.js +124 -0
- package/dist/tests/logger.test.js +33 -0
- package/dist/tests/middleware.test.js +109 -0
- package/dist/tests/observability.test.js +59 -0
- package/dist/tests/openapi.test.js +64 -0
- package/dist/tests/plugin.test.js +65 -0
- package/dist/tests/plugins.test.js +71 -0
- package/dist/tests/rate-limit.test.js +77 -0
- package/dist/tests/resources.test.js +44 -0
- package/dist/tests/scheduler.test.js +46 -0
- package/dist/tests/schema-routes.test.js +77 -0
- package/dist/tests/security.test.js +83 -0
- package/dist/tests/server-db.test.js +72 -0
- package/dist/tests/smoke.test.js +10 -0
- package/dist/tests/sqlite-fusion.test.js +92 -0
- package/dist/tests/static.test.js +102 -0
- package/dist/tests/stream.test.js +44 -0
- package/dist/tests/task-metrics.test.js +53 -0
- package/dist/tests/tasks.test.js +62 -0
- package/dist/tests/testing.test.js +47 -0
- package/dist/tests/validation.test.js +107 -0
- package/dist/tests/websocket.test.js +146 -0
- package/dist/vitest.config.js +9 -0
- package/docs/AEGIS.md +76 -0
- package/docs/BENCHMARKS.md +36 -0
- package/docs/CAPABILITIES.md +70 -0
- package/docs/CLI.md +43 -0
- package/docs/DATABASE.md +142 -0
- package/docs/ECOSYSTEM.md +146 -0
- package/docs/NEXT_STEPS.md +99 -0
- package/docs/OPENAPI.md +99 -0
- package/docs/PLUGINS.md +59 -0
- package/docs/REAL_WORLD_EXAMPLES.md +109 -0
- package/docs/ROADMAP.md +366 -0
- package/docs/VALIDATION.md +136 -0
- package/eslint.config.cjs +26 -0
- package/examples/api-server.ts +254 -0
- package/package.json +61 -0
- package/src/benchmarks/compare-frameworks.ts +149 -0
- package/src/benchmarks/quantam-users.ts +70 -0
- package/src/benchmarks/simple-json.ts +71 -0
- package/src/benchmarks/ultra-mode.ts +159 -0
- package/src/cli/index.ts +214 -0
- package/src/client/index.ts +93 -0
- package/src/core/batch.ts +110 -0
- package/src/core/body-parser.ts +151 -0
- package/src/core/buffer-pool.ts +96 -0
- package/src/core/config.ts +60 -0
- package/src/core/fusion.ts +210 -0
- package/src/core/logger.ts +70 -0
- package/src/core/metrics.ts +166 -0
- package/src/core/resources.ts +38 -0
- package/src/core/scheduler.ts +126 -0
- package/src/core/scope.ts +87 -0
- package/src/core/serializer.ts +41 -0
- package/src/core/server.ts +1113 -0
- package/src/core/stream.ts +111 -0
- package/src/core/tasks.ts +138 -0
- package/src/core/types.ts +178 -0
- package/src/core/websocket.ts +112 -0
- package/src/core/worker-queue.ts +90 -0
- package/src/database/adapters/memory.ts +99 -0
- package/src/database/adapters/mongo.ts +116 -0
- package/src/database/adapters/postgres.ts +86 -0
- package/src/database/adapters/sqlite.ts +44 -0
- package/src/database/coalescer.ts +153 -0
- package/src/database/manager.ts +97 -0
- package/src/database/types.ts +24 -0
- package/src/index.ts +42 -0
- package/src/middleware/compression.ts +147 -0
- package/src/middleware/cors.ts +98 -0
- package/src/middleware/presets.ts +50 -0
- package/src/middleware/rate-limit.ts +106 -0
- package/src/middleware/security.ts +109 -0
- package/src/middleware/static.ts +216 -0
- package/src/openapi/generator.ts +167 -0
- package/src/router/radix-router.ts +119 -0
- package/src/router/radix-tree.ts +106 -0
- package/src/router/router.ts +190 -0
- package/src/testing/index.ts +104 -0
- package/src/utils/cookies.ts +67 -0
- package/src/utils/logger.ts +59 -0
- package/src/utils/signals.ts +45 -0
- package/src/utils/sse.ts +41 -0
- package/src/validation/index.ts +3 -0
- package/src/validation/simple.ts +93 -0
- package/src/validation/types.ts +38 -0
- package/src/validation/zod.ts +14 -0
- package/src/views/index.ts +1 -0
- package/src/views/types.ts +4 -0
- package/tests/adapters.test.ts +120 -0
- package/tests/batch.test.ts +139 -0
- package/tests/body-parser.test.ts +83 -0
- package/tests/compression-sse.test.ts +98 -0
- package/tests/cookies.test.ts +74 -0
- package/tests/cors.test.ts +79 -0
- package/tests/database.test.ts +90 -0
- package/tests/dx.test.ts +78 -0
- package/tests/ecosystem.test.ts +156 -0
- package/tests/features.test.ts +51 -0
- package/tests/fusion.test.ts +121 -0
- package/tests/http-basic.test.ts +161 -0
- package/tests/logger.test.ts +48 -0
- package/tests/middleware.test.ts +137 -0
- package/tests/observability.test.ts +91 -0
- package/tests/openapi.test.ts +74 -0
- package/tests/plugin.test.ts +85 -0
- package/tests/plugins.test.ts +93 -0
- package/tests/rate-limit.test.ts +97 -0
- package/tests/resources.test.ts +64 -0
- package/tests/scheduler.test.ts +71 -0
- package/tests/schema-routes.test.ts +89 -0
- package/tests/security.test.ts +128 -0
- package/tests/server-db.test.ts +72 -0
- package/tests/smoke.test.ts +9 -0
- package/tests/sqlite-fusion.test.ts +106 -0
- package/tests/static.test.ts +111 -0
- package/tests/stream.test.ts +58 -0
- package/tests/task-metrics.test.ts +78 -0
- package/tests/tasks.test.ts +90 -0
- package/tests/testing.test.ts +53 -0
- package/tests/validation.test.ts +126 -0
- package/tests/websocket.test.ts +132 -0
- package/tsconfig.json +16 -0
- package/vitest.config.ts +9 -0
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { QHTTPXMiddleware } from '../core/types';
|
|
2
|
+
import zlib from 'zlib';
|
|
3
|
+
|
|
4
|
+
export type CompressionOptions = {
|
|
5
|
+
threshold?: number; // Minimum size in bytes to compress
|
|
6
|
+
level?: number; // Zlib compression level
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export function createCompressionMiddleware(options: CompressionOptions = {}): QHTTPXMiddleware {
|
|
10
|
+
const threshold = options.threshold ?? 1024;
|
|
11
|
+
const level = options.level ?? zlib.constants.Z_DEFAULT_COMPRESSION;
|
|
12
|
+
|
|
13
|
+
return async (ctx, next) => {
|
|
14
|
+
const req = ctx.req;
|
|
15
|
+
const res = ctx.res;
|
|
16
|
+
const acceptEncoding = req.headers['accept-encoding'] || '';
|
|
17
|
+
|
|
18
|
+
let stream: zlib.Gzip | zlib.BrotliCompress | zlib.Deflate | undefined;
|
|
19
|
+
let encoding = '';
|
|
20
|
+
|
|
21
|
+
if (/\bbr\b/.test(acceptEncoding as string)) {
|
|
22
|
+
stream = zlib.createBrotliCompress({
|
|
23
|
+
params: {
|
|
24
|
+
[zlib.constants.BROTLI_PARAM_QUALITY]: level,
|
|
25
|
+
},
|
|
26
|
+
});
|
|
27
|
+
encoding = 'br';
|
|
28
|
+
} else if (/\bgzip\b/.test(acceptEncoding as string)) {
|
|
29
|
+
stream = zlib.createGzip({ level });
|
|
30
|
+
encoding = 'gzip';
|
|
31
|
+
} else if (/\bdeflate\b/.test(acceptEncoding as string)) {
|
|
32
|
+
stream = zlib.createDeflate({ level });
|
|
33
|
+
encoding = 'deflate';
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (!stream) {
|
|
37
|
+
await next();
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const originalWrite = res.write;
|
|
42
|
+
const originalEnd = res.end;
|
|
43
|
+
// const originalSetHeader = res.setHeader;
|
|
44
|
+
|
|
45
|
+
// We need to defer compression decision until we know the content type/length
|
|
46
|
+
// But since we are streaming, we might just start compressing if headers are sent?
|
|
47
|
+
// Actually, we can hook into write/end.
|
|
48
|
+
|
|
49
|
+
let headersSent = false;
|
|
50
|
+
let compress = false;
|
|
51
|
+
|
|
52
|
+
// Helper to check if we should compress based on content-type
|
|
53
|
+
const shouldCompress = () => {
|
|
54
|
+
const contentType = res.getHeader('content-type');
|
|
55
|
+
if (!contentType) return true; // Assume yes if unknown? Or no? Usually text/json is compressed.
|
|
56
|
+
const type = String(contentType).toLowerCase();
|
|
57
|
+
|
|
58
|
+
if (type.includes('text/event-stream')) {
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return (
|
|
63
|
+
type.includes('text') ||
|
|
64
|
+
type.includes('json') ||
|
|
65
|
+
type.includes('xml') ||
|
|
66
|
+
type.includes('javascript') ||
|
|
67
|
+
type.includes('svg')
|
|
68
|
+
);
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
// Override write
|
|
72
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
73
|
+
res.write = function (chunk: any, ...args: any[]): boolean {
|
|
74
|
+
if (!headersSent) {
|
|
75
|
+
if (shouldCompress()) {
|
|
76
|
+
compress = true;
|
|
77
|
+
// Disable auto-end because we handle the stream asynchronously
|
|
78
|
+
ctx.disableAutoEnd = true;
|
|
79
|
+
res.setHeader('Content-Encoding', encoding);
|
|
80
|
+
res.removeHeader('Content-Length');
|
|
81
|
+
res.setHeader('Vary', 'Accept-Encoding');
|
|
82
|
+
|
|
83
|
+
stream!.on('data', (data) => {
|
|
84
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
85
|
+
(originalWrite as any).call(res, data);
|
|
86
|
+
});
|
|
87
|
+
stream!.on('end', () => {
|
|
88
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
89
|
+
(originalEnd as any).call(res);
|
|
90
|
+
});
|
|
91
|
+
} else {
|
|
92
|
+
compress = false;
|
|
93
|
+
}
|
|
94
|
+
headersSent = true;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (compress && stream) {
|
|
98
|
+
return stream.write(chunk, ...(args as [BufferEncoding, (error: Error | null | undefined) => void]));
|
|
99
|
+
} else {
|
|
100
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
101
|
+
return (originalWrite as any).apply(res, [chunk, ...args]);
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
// Override end
|
|
106
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
107
|
+
res.end = function (chunk?: any, ...args: any[]): any {
|
|
108
|
+
if (!headersSent) {
|
|
109
|
+
const len = chunk ? Buffer.byteLength(chunk) : 0;
|
|
110
|
+
|
|
111
|
+
if (shouldCompress() && len >= threshold) {
|
|
112
|
+
compress = true;
|
|
113
|
+
// Disable auto-end because we handle the stream asynchronously
|
|
114
|
+
ctx.disableAutoEnd = true;
|
|
115
|
+
res.setHeader('Content-Encoding', encoding);
|
|
116
|
+
res.removeHeader('Content-Length');
|
|
117
|
+
res.setHeader('Vary', 'Accept-Encoding');
|
|
118
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
119
|
+
stream!.on('data', (data) => (originalWrite as any).call(res, data));
|
|
120
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
121
|
+
stream!.on('end', () => (originalEnd as any).call(res));
|
|
122
|
+
} else {
|
|
123
|
+
compress = false;
|
|
124
|
+
}
|
|
125
|
+
headersSent = true;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (compress && stream) {
|
|
129
|
+
if (chunk) stream.write(chunk);
|
|
130
|
+
stream.end();
|
|
131
|
+
} else {
|
|
132
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
133
|
+
return (originalEnd as any).apply(res, [chunk, ...args]);
|
|
134
|
+
}
|
|
135
|
+
return res;
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
// Fix for the pipe issue:
|
|
139
|
+
// If we set compress=true, we should pipe stream to res ONCE.
|
|
140
|
+
// If we use { end: false }, we must manually end res.
|
|
141
|
+
// If we use { end: true }, stream.end() will end res.
|
|
142
|
+
|
|
143
|
+
// Let's refine the logic.
|
|
144
|
+
|
|
145
|
+
await next();
|
|
146
|
+
};
|
|
147
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { QHTTPXMiddleware } from '../core/types';
|
|
2
|
+
|
|
3
|
+
export type CorsOrigin =
|
|
4
|
+
| string
|
|
5
|
+
| string[]
|
|
6
|
+
| ((origin: string | undefined) => string | null | undefined);
|
|
7
|
+
|
|
8
|
+
export type CorsOptions = {
|
|
9
|
+
origin?: CorsOrigin;
|
|
10
|
+
methods?: string[];
|
|
11
|
+
allowedHeaders?: string[];
|
|
12
|
+
exposedHeaders?: string[];
|
|
13
|
+
credentials?: boolean;
|
|
14
|
+
maxAgeSeconds?: number;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
function resolveOrigin(origin: CorsOrigin | undefined, requestOrigin: string | undefined): string | null | undefined {
|
|
18
|
+
if (!origin) {
|
|
19
|
+
return '*';
|
|
20
|
+
}
|
|
21
|
+
if (typeof origin === 'string') {
|
|
22
|
+
return origin;
|
|
23
|
+
}
|
|
24
|
+
if (Array.isArray(origin)) {
|
|
25
|
+
if (!requestOrigin) {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
if (origin.includes(requestOrigin)) {
|
|
29
|
+
return requestOrigin;
|
|
30
|
+
}
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
return origin(requestOrigin);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function createCorsMiddleware(options: CorsOptions = {}): QHTTPXMiddleware {
|
|
37
|
+
const methodsHeader =
|
|
38
|
+
options.methods && options.methods.length > 0
|
|
39
|
+
? options.methods.join(', ')
|
|
40
|
+
: 'GET,HEAD,PUT,PATCH,POST,DELETE,OPTIONS';
|
|
41
|
+
|
|
42
|
+
const allowedHeadersHeader =
|
|
43
|
+
options.allowedHeaders && options.allowedHeaders.length > 0
|
|
44
|
+
? options.allowedHeaders.join(', ')
|
|
45
|
+
: undefined;
|
|
46
|
+
|
|
47
|
+
const exposedHeadersHeader =
|
|
48
|
+
options.exposedHeaders && options.exposedHeaders.length > 0
|
|
49
|
+
? options.exposedHeaders.join(', ')
|
|
50
|
+
: undefined;
|
|
51
|
+
|
|
52
|
+
const maxAgeHeader =
|
|
53
|
+
typeof options.maxAgeSeconds === 'number'
|
|
54
|
+
? String(options.maxAgeSeconds)
|
|
55
|
+
: undefined;
|
|
56
|
+
|
|
57
|
+
const allowCredentials = options.credentials ?? false;
|
|
58
|
+
|
|
59
|
+
return async (ctx, next) => {
|
|
60
|
+
const requestOriginHeader = ctx.req.headers.origin;
|
|
61
|
+
const resolvedOrigin = resolveOrigin(options.origin, requestOriginHeader);
|
|
62
|
+
|
|
63
|
+
if (resolvedOrigin) {
|
|
64
|
+
ctx.res.setHeader('access-control-allow-origin', resolvedOrigin);
|
|
65
|
+
if (allowCredentials) {
|
|
66
|
+
ctx.res.setHeader('access-control-allow-credentials', 'true');
|
|
67
|
+
}
|
|
68
|
+
if (exposedHeadersHeader) {
|
|
69
|
+
ctx.res.setHeader('access-control-expose-headers', exposedHeadersHeader);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (ctx.req.method === 'OPTIONS') {
|
|
74
|
+
ctx.res.statusCode = 204;
|
|
75
|
+
ctx.res.setHeader('access-control-allow-methods', methodsHeader);
|
|
76
|
+
|
|
77
|
+
const requestHeaders =
|
|
78
|
+
typeof ctx.req.headers['access-control-request-headers'] === 'string'
|
|
79
|
+
? ctx.req.headers['access-control-request-headers']
|
|
80
|
+
: undefined;
|
|
81
|
+
|
|
82
|
+
const headersValue = allowedHeadersHeader || requestHeaders;
|
|
83
|
+
if (headersValue) {
|
|
84
|
+
ctx.res.setHeader('access-control-allow-headers', headersValue);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (maxAgeHeader) {
|
|
88
|
+
ctx.res.setHeader('access-control-max-age', maxAgeHeader);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
ctx.res.end();
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
await next();
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { QHTTPXMiddleware } from '../core/types';
|
|
2
|
+
import { createSecureDefaults, SecureDefaultsOptions } from './security';
|
|
3
|
+
import { createLoggerMiddleware, LoggerOptions } from '../utils/logger';
|
|
4
|
+
import { createStaticMiddleware, StaticOptions } from './static';
|
|
5
|
+
|
|
6
|
+
export type ApiPresetOptions = {
|
|
7
|
+
security?: SecureDefaultsOptions;
|
|
8
|
+
logging?: LoggerOptions | boolean;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export function createApiPreset(options: ApiPresetOptions = {}): QHTTPXMiddleware[] {
|
|
12
|
+
const middlewares: QHTTPXMiddleware[] = [];
|
|
13
|
+
|
|
14
|
+
// 1. Security (CORS, Headers)
|
|
15
|
+
middlewares.push(...createSecureDefaults(options.security));
|
|
16
|
+
|
|
17
|
+
// 2. Logging
|
|
18
|
+
if (options.logging !== false) {
|
|
19
|
+
const loggerOptions = typeof options.logging === 'object' ? options.logging : {};
|
|
20
|
+
middlewares.push(createLoggerMiddleware(loggerOptions));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return middlewares;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type StaticAppPresetOptions = ApiPresetOptions & {
|
|
27
|
+
static: StaticOptions;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export function createStaticAppPreset(
|
|
31
|
+
options: StaticAppPresetOptions,
|
|
32
|
+
): QHTTPXMiddleware[] {
|
|
33
|
+
const middlewares: QHTTPXMiddleware[] = [];
|
|
34
|
+
|
|
35
|
+
// 1. Security
|
|
36
|
+
middlewares.push(...createSecureDefaults(options.security));
|
|
37
|
+
|
|
38
|
+
// 2. Logging
|
|
39
|
+
if (options.logging !== false) {
|
|
40
|
+
const loggerOptions = typeof options.logging === 'object' ? options.logging : {};
|
|
41
|
+
middlewares.push(createLoggerMiddleware(loggerOptions));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// 3. Static Files
|
|
45
|
+
// We force fallthrough to true so API routes can handle non-static requests
|
|
46
|
+
const staticOptions = { ...options.static, fallthrough: true };
|
|
47
|
+
middlewares.push(createStaticMiddleware(staticOptions));
|
|
48
|
+
|
|
49
|
+
return middlewares;
|
|
50
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import type { QHTTPXContext, QHTTPXMiddleware } from '../core/types';
|
|
2
|
+
|
|
3
|
+
export interface RateLimitStore {
|
|
4
|
+
increment(key: string, windowMs: number): Promise<{ total: number; resetTime: number }>;
|
|
5
|
+
decrement(key: string): Promise<void>;
|
|
6
|
+
reset(key: string): Promise<void>;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export class MemoryStore implements RateLimitStore {
|
|
10
|
+
private hits = new Map<string, { count: number; resetTime: number }>();
|
|
11
|
+
private interval?: NodeJS.Timeout;
|
|
12
|
+
|
|
13
|
+
constructor(clearPeriodMs = 60000) {
|
|
14
|
+
// Cleanup expired entries periodically
|
|
15
|
+
if (clearPeriodMs > 0) {
|
|
16
|
+
this.interval = setInterval(() => this.cleanup(), clearPeriodMs);
|
|
17
|
+
this.interval.unref(); // Don't hold process open
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async increment(key: string, windowMs: number): Promise<{ total: number; resetTime: number }> {
|
|
22
|
+
const now = Date.now();
|
|
23
|
+
let record = this.hits.get(key);
|
|
24
|
+
|
|
25
|
+
if (!record || now > record.resetTime) {
|
|
26
|
+
record = { count: 1, resetTime: now + windowMs };
|
|
27
|
+
this.hits.set(key, record);
|
|
28
|
+
} else {
|
|
29
|
+
record.count++;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return { total: record.count, resetTime: record.resetTime };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async decrement(key: string): Promise<void> {
|
|
36
|
+
const record = this.hits.get(key);
|
|
37
|
+
if (record && record.count > 0) {
|
|
38
|
+
record.count--;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async reset(key: string): Promise<void> {
|
|
43
|
+
this.hits.delete(key);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
private cleanup() {
|
|
47
|
+
const now = Date.now();
|
|
48
|
+
for (const [key, record] of this.hits.entries()) {
|
|
49
|
+
if (now > record.resetTime) {
|
|
50
|
+
this.hits.delete(key);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface RateLimitOptions {
|
|
57
|
+
windowMs?: number; // Time window in milliseconds
|
|
58
|
+
max?: number; // Max requests per window
|
|
59
|
+
message?: string | object; // Response message
|
|
60
|
+
statusCode?: number; // Status code (default 429)
|
|
61
|
+
headers?: boolean; // Send X-RateLimit headers
|
|
62
|
+
keyGenerator?: (ctx: QHTTPXContext) => string; // Custom key generator
|
|
63
|
+
skip?: (ctx: QHTTPXContext) => boolean; // Skip limiter
|
|
64
|
+
store?: RateLimitStore; // Storage backend
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export const rateLimit = (options: RateLimitOptions = {}): QHTTPXMiddleware => {
|
|
68
|
+
const windowMs = options.windowMs ?? 60000; // 1 minute default
|
|
69
|
+
const max = options.max ?? 100; // 100 requests default
|
|
70
|
+
const message = options.message ?? 'Too many requests, please try again later.';
|
|
71
|
+
const statusCode = options.statusCode ?? 429;
|
|
72
|
+
const headers = options.headers ?? true;
|
|
73
|
+
const store = options.store ?? new MemoryStore();
|
|
74
|
+
|
|
75
|
+
const keyGenerator = options.keyGenerator ?? ((ctx: QHTTPXContext) => {
|
|
76
|
+
return ctx.req.socket.remoteAddress || 'unknown';
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
return async (ctx, next) => {
|
|
80
|
+
if (options.skip?.(ctx)) {
|
|
81
|
+
return next();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const key = keyGenerator(ctx);
|
|
85
|
+
const { total, resetTime } = await store.increment(key, windowMs);
|
|
86
|
+
|
|
87
|
+
const remaining = Math.max(0, max - total);
|
|
88
|
+
const resetSeconds = Math.ceil((resetTime - Date.now()) / 1000);
|
|
89
|
+
|
|
90
|
+
if (headers) {
|
|
91
|
+
ctx.res.setHeader('X-RateLimit-Limit', max);
|
|
92
|
+
ctx.res.setHeader('X-RateLimit-Remaining', remaining);
|
|
93
|
+
ctx.res.setHeader('X-RateLimit-Reset', resetSeconds);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (total > max) {
|
|
97
|
+
if (headers) {
|
|
98
|
+
ctx.res.setHeader('Retry-After', resetSeconds);
|
|
99
|
+
}
|
|
100
|
+
ctx.json(typeof message === 'string' ? { error: message } : message, statusCode);
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
await next();
|
|
105
|
+
};
|
|
106
|
+
};
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { QHTTPXContext, QHTTPXMiddleware } from '../core/types';
|
|
2
|
+
import { CorsOptions, createCorsMiddleware } from './cors';
|
|
3
|
+
|
|
4
|
+
export type SecurityHeadersOptions = {
|
|
5
|
+
contentSecurityPolicy?: string | null;
|
|
6
|
+
referrerPolicy?: string | null;
|
|
7
|
+
xFrameOptions?: 'DENY' | 'SAMEORIGIN' | null;
|
|
8
|
+
xContentTypeOptions?: 'nosniff' | null;
|
|
9
|
+
xXssProtection?: '0' | '1; mode=block' | null;
|
|
10
|
+
strictTransportSecurity?: string | null;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export function createSecurityHeadersMiddleware(
|
|
14
|
+
options: SecurityHeadersOptions = {},
|
|
15
|
+
): QHTTPXMiddleware {
|
|
16
|
+
const {
|
|
17
|
+
contentSecurityPolicy = "default-src 'self'",
|
|
18
|
+
referrerPolicy = 'no-referrer',
|
|
19
|
+
xFrameOptions = 'SAMEORIGIN',
|
|
20
|
+
xContentTypeOptions = 'nosniff',
|
|
21
|
+
xXssProtection = '1; mode=block',
|
|
22
|
+
strictTransportSecurity,
|
|
23
|
+
} = options;
|
|
24
|
+
|
|
25
|
+
return async (ctx, next) => {
|
|
26
|
+
if (contentSecurityPolicy) {
|
|
27
|
+
ctx.res.setHeader('content-security-policy', contentSecurityPolicy);
|
|
28
|
+
}
|
|
29
|
+
if (referrerPolicy) {
|
|
30
|
+
ctx.res.setHeader('referrer-policy', referrerPolicy);
|
|
31
|
+
}
|
|
32
|
+
if (xFrameOptions) {
|
|
33
|
+
ctx.res.setHeader('x-frame-options', xFrameOptions);
|
|
34
|
+
}
|
|
35
|
+
if (xContentTypeOptions) {
|
|
36
|
+
ctx.res.setHeader('x-content-type-options', xContentTypeOptions);
|
|
37
|
+
}
|
|
38
|
+
if (xXssProtection) {
|
|
39
|
+
ctx.res.setHeader('x-xss-protection', xXssProtection);
|
|
40
|
+
}
|
|
41
|
+
if (strictTransportSecurity) {
|
|
42
|
+
ctx.res.setHeader('strict-transport-security', strictTransportSecurity);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
await next();
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export type SecureDefaultsOptions = {
|
|
50
|
+
cors?: CorsOptions;
|
|
51
|
+
securityHeaders?: SecurityHeadersOptions;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export function createSecureDefaults(
|
|
55
|
+
options: SecureDefaultsOptions = {},
|
|
56
|
+
): QHTTPXMiddleware[] {
|
|
57
|
+
const middlewares: QHTTPXMiddleware[] = [];
|
|
58
|
+
middlewares.push(createCorsMiddleware(options.cors));
|
|
59
|
+
middlewares.push(createSecurityHeadersMiddleware(options.securityHeaders));
|
|
60
|
+
return middlewares;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export type RateLimitOptions = {
|
|
64
|
+
maxRequests: number;
|
|
65
|
+
windowMs: number;
|
|
66
|
+
keyGenerator?: (ctx: QHTTPXContext) => string;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
export function createRateLimitMiddleware(
|
|
70
|
+
options: RateLimitOptions,
|
|
71
|
+
): QHTTPXMiddleware {
|
|
72
|
+
const maxRequests = options.maxRequests;
|
|
73
|
+
const windowMs = options.windowMs;
|
|
74
|
+
const keyGenerator =
|
|
75
|
+
options.keyGenerator ??
|
|
76
|
+
((ctx: QHTTPXContext) => {
|
|
77
|
+
const addr = ctx.req.socket.remoteAddress;
|
|
78
|
+
return addr || 'anonymous';
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
const buckets = new Map<string, { count: number; resetAt: number }>();
|
|
82
|
+
|
|
83
|
+
return async (ctx, next) => {
|
|
84
|
+
const now = Date.now();
|
|
85
|
+
const key = keyGenerator(ctx);
|
|
86
|
+
const existing = buckets.get(key);
|
|
87
|
+
|
|
88
|
+
let bucket = existing;
|
|
89
|
+
if (!bucket || bucket.resetAt <= now) {
|
|
90
|
+
bucket = {
|
|
91
|
+
count: 0,
|
|
92
|
+
resetAt: now + windowMs,
|
|
93
|
+
};
|
|
94
|
+
buckets.set(key, bucket);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
bucket.count += 1;
|
|
98
|
+
if (bucket.count > maxRequests) {
|
|
99
|
+
if (!ctx.res.headersSent) {
|
|
100
|
+
ctx.res.statusCode = 429;
|
|
101
|
+
ctx.res.setHeader('content-type', 'text/plain; charset=utf-8');
|
|
102
|
+
}
|
|
103
|
+
ctx.res.end('Too Many Requests');
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
await next();
|
|
108
|
+
};
|
|
109
|
+
}
|