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,190 @@
|
|
|
1
|
+
import { RadixTree } from './radix-tree';
|
|
2
|
+
import { HTTPMethod, QHTTPXHandler, RouteOptions, RoutePriority } from '../core/types';
|
|
3
|
+
import { RouteSchema } from '../validation/types';
|
|
4
|
+
|
|
5
|
+
type RouteDefinition = {
|
|
6
|
+
path: string;
|
|
7
|
+
segments: string[];
|
|
8
|
+
handler: QHTTPXHandler;
|
|
9
|
+
priority: RoutePriority;
|
|
10
|
+
schema?: RouteSchema | Record<string, unknown>;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export type RouteMatch = {
|
|
14
|
+
handler: QHTTPXHandler;
|
|
15
|
+
params: Record<string, string>;
|
|
16
|
+
priority: RoutePriority;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export class Router {
|
|
20
|
+
// Per-method route buckets
|
|
21
|
+
private readonly methodBuckets: Map<HTTPMethod, RouteDefinition[]> = new Map([
|
|
22
|
+
['GET', []],
|
|
23
|
+
['POST', []],
|
|
24
|
+
['PUT', []],
|
|
25
|
+
['DELETE', []],
|
|
26
|
+
['PATCH', []],
|
|
27
|
+
['HEAD', []],
|
|
28
|
+
['OPTIONS', []],
|
|
29
|
+
]);
|
|
30
|
+
|
|
31
|
+
// Derived structures (built at freeze time)
|
|
32
|
+
private readonly radixTrees: Map<HTTPMethod, RadixTree> = new Map();
|
|
33
|
+
|
|
34
|
+
// Freeze state
|
|
35
|
+
private isFrozen = false;
|
|
36
|
+
|
|
37
|
+
register(
|
|
38
|
+
method: HTTPMethod,
|
|
39
|
+
path: string,
|
|
40
|
+
handler: QHTTPXHandler,
|
|
41
|
+
options?: RouteOptions & { schema?: RouteSchema | Record<string, unknown> },
|
|
42
|
+
): void {
|
|
43
|
+
if (this.isFrozen) {
|
|
44
|
+
console.warn(
|
|
45
|
+
`Router is frozen. Late route registration (${method} ${path}) may not be optimized.`,
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const segments = this.normalize(path);
|
|
50
|
+
const bucket = this.methodBuckets.get(method);
|
|
51
|
+
if (bucket) {
|
|
52
|
+
bucket.push({
|
|
53
|
+
path,
|
|
54
|
+
segments,
|
|
55
|
+
handler,
|
|
56
|
+
priority: options?.priority ?? RoutePriority.STANDARD,
|
|
57
|
+
schema: options?.schema,
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
getRoutes(): Map<HTTPMethod, RouteDefinition[]> {
|
|
63
|
+
return this.methodBuckets;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
match(method: HTTPMethod, path: string): RouteMatch | undefined {
|
|
67
|
+
// Fast path for frozen router
|
|
68
|
+
if (this.isFrozen) {
|
|
69
|
+
const tree = this.radixTrees.get(method);
|
|
70
|
+
if (tree) {
|
|
71
|
+
const segments = this.normalize(path);
|
|
72
|
+
const match = tree.lookup(segments);
|
|
73
|
+
if (match) {
|
|
74
|
+
return match;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return undefined;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Slow path for unfrozen router (legacy behavior)
|
|
81
|
+
const segments = this.normalize(path);
|
|
82
|
+
const bucket = this.methodBuckets.get(method);
|
|
83
|
+
|
|
84
|
+
if (!bucket || bucket.length === 0) {
|
|
85
|
+
return undefined;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Try to match against routes
|
|
89
|
+
for (const route of bucket) {
|
|
90
|
+
if (route.segments.length !== segments.length) {
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const params: Record<string, string> = {};
|
|
95
|
+
let matched = true;
|
|
96
|
+
|
|
97
|
+
for (let i = 0; i < route.segments.length; i += 1) {
|
|
98
|
+
const pattern = route.segments[i];
|
|
99
|
+
const value = segments[i];
|
|
100
|
+
|
|
101
|
+
if (pattern.startsWith(':')) {
|
|
102
|
+
const key = pattern.slice(1);
|
|
103
|
+
params[key] = value;
|
|
104
|
+
} else if (pattern !== value) {
|
|
105
|
+
matched = false;
|
|
106
|
+
break;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (matched) {
|
|
111
|
+
return {
|
|
112
|
+
handler: route.handler,
|
|
113
|
+
params,
|
|
114
|
+
priority: route.priority,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return undefined;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
getAllowedMethods(path: string): HTTPMethod[] {
|
|
123
|
+
const segments = this.normalize(path);
|
|
124
|
+
const methods: HTTPMethod[] = [];
|
|
125
|
+
|
|
126
|
+
for (const [method, routes] of this.methodBuckets.entries()) {
|
|
127
|
+
for (const route of routes) {
|
|
128
|
+
if (route.segments.length !== segments.length) {
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
let matched = true;
|
|
133
|
+
for (let i = 0; i < route.segments.length; i += 1) {
|
|
134
|
+
const pattern = route.segments[i];
|
|
135
|
+
const value = segments[i];
|
|
136
|
+
|
|
137
|
+
if (pattern.startsWith(':')) {
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (pattern !== value) {
|
|
142
|
+
matched = false;
|
|
143
|
+
break;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (matched) {
|
|
148
|
+
methods.push(method);
|
|
149
|
+
break;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return methods;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Freeze the router after server starts.
|
|
159
|
+
* Prevents further route registration and builds derived structures for optimized matching.
|
|
160
|
+
*/
|
|
161
|
+
freeze(): void {
|
|
162
|
+
if (this.isFrozen) {
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
this.isFrozen = true;
|
|
167
|
+
|
|
168
|
+
// Build derived structures for faster matching
|
|
169
|
+
for (const [method, routes] of this.methodBuckets.entries()) {
|
|
170
|
+
const tree = new RadixTree();
|
|
171
|
+
|
|
172
|
+
for (const route of routes) {
|
|
173
|
+
tree.insert(route.segments, route.handler, route.priority);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
this.radixTrees.set(method, tree);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
isFrozenRouter(): boolean {
|
|
181
|
+
return this.isFrozen;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
private normalize(path: string): string[] {
|
|
185
|
+
if (!path || path === '/') {
|
|
186
|
+
return [];
|
|
187
|
+
}
|
|
188
|
+
return path.split('/').filter((segment) => segment.length > 0);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { QHTTPX } from '../core/server';
|
|
2
|
+
import { HTTPMethod } from '../core/types';
|
|
3
|
+
|
|
4
|
+
export class TestClient {
|
|
5
|
+
private app: QHTTPX;
|
|
6
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
7
|
+
private server: any = null;
|
|
8
|
+
private baseURL: string = '';
|
|
9
|
+
|
|
10
|
+
constructor(app: QHTTPX) {
|
|
11
|
+
this.app = app;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Starts the server on a random port.
|
|
16
|
+
* Automatically called by request methods if not started.
|
|
17
|
+
*/
|
|
18
|
+
async start(): Promise<void> {
|
|
19
|
+
if (this.server) return;
|
|
20
|
+
const { port } = await this.app.listen(0);
|
|
21
|
+
this.server = this.app.serverInstance;
|
|
22
|
+
this.baseURL = `http://127.0.0.1:${port}`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async stop(): Promise<void> {
|
|
26
|
+
if (this.server) {
|
|
27
|
+
await this.app.close();
|
|
28
|
+
this.server = null;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async request(
|
|
33
|
+
method: HTTPMethod,
|
|
34
|
+
path: string,
|
|
35
|
+
options: {
|
|
36
|
+
headers?: Record<string, string>;
|
|
37
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
38
|
+
body?: any;
|
|
39
|
+
query?: Record<string, string | number | boolean>;
|
|
40
|
+
} = {}
|
|
41
|
+
): Promise<Response> {
|
|
42
|
+
if (!this.server) {
|
|
43
|
+
await this.start();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const url = new URL(path, this.baseURL);
|
|
47
|
+
if (options.query) {
|
|
48
|
+
Object.entries(options.query).forEach(([k, v]) => {
|
|
49
|
+
url.searchParams.append(k, String(v));
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const headers: Record<string, string> = options.headers || {};
|
|
54
|
+
let bodyPayload: BodyInit | undefined;
|
|
55
|
+
|
|
56
|
+
if (options.body) {
|
|
57
|
+
if (typeof options.body === 'object' &&
|
|
58
|
+
!(options.body instanceof Uint8Array) &&
|
|
59
|
+
!(options.body instanceof ArrayBuffer) &&
|
|
60
|
+
!(options.body instanceof FormData) &&
|
|
61
|
+
!(options.body instanceof URLSearchParams)) {
|
|
62
|
+
bodyPayload = JSON.stringify(options.body);
|
|
63
|
+
if (!headers['content-type']) {
|
|
64
|
+
headers['content-type'] = 'application/json';
|
|
65
|
+
}
|
|
66
|
+
} else {
|
|
67
|
+
bodyPayload = options.body as BodyInit;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return fetch(url, {
|
|
72
|
+
method,
|
|
73
|
+
headers,
|
|
74
|
+
body: bodyPayload,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
get(path: string, options?: Omit<Parameters<TestClient['request']>[2], 'body'>) {
|
|
79
|
+
return this.request('GET', path, options);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
83
|
+
post(path: string, body?: any, options?: Omit<Parameters<TestClient['request']>[2], 'body'>) {
|
|
84
|
+
return this.request('POST', path, { ...options, body });
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
88
|
+
put(path: string, body?: any, options?: Omit<Parameters<TestClient['request']>[2], 'body'>) {
|
|
89
|
+
return this.request('PUT', path, { ...options, body });
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
delete(path: string, options?: Omit<Parameters<TestClient['request']>[2], 'body'>) {
|
|
93
|
+
return this.request('DELETE', path, options);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
97
|
+
patch(path: string, body?: any, options?: Omit<Parameters<TestClient['request']>[2], 'body'>) {
|
|
98
|
+
return this.request('PATCH', path, { ...options, body });
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function createTestClient(app: QHTTPX) {
|
|
103
|
+
return new TestClient(app);
|
|
104
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { CookieOptions } from '../core/types';
|
|
2
|
+
|
|
3
|
+
export function parseCookies(header: string | undefined): Record<string, string> {
|
|
4
|
+
const list: Record<string, string> = {};
|
|
5
|
+
if (!header) {
|
|
6
|
+
return list;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
header.split(';').forEach((cookie) => {
|
|
10
|
+
const parts = cookie.split('=');
|
|
11
|
+
const name = parts.shift()?.trim();
|
|
12
|
+
if (name) {
|
|
13
|
+
const value = parts.join('=');
|
|
14
|
+
list[name] = decodeURIComponent(value);
|
|
15
|
+
}
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
return list;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function serializeCookie(name: string, value: string, options: CookieOptions = {}): string {
|
|
22
|
+
let str = `${name}=${encodeURIComponent(value)}`;
|
|
23
|
+
|
|
24
|
+
if (options.maxAge) {
|
|
25
|
+
str += `; Max-Age=${Math.floor(options.maxAge)}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (options.domain) {
|
|
29
|
+
str += `; Domain=${options.domain}`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (options.path) {
|
|
33
|
+
str += `; Path=${options.path}`;
|
|
34
|
+
} else {
|
|
35
|
+
str += '; Path=/';
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (options.expires) {
|
|
39
|
+
str += `; Expires=${options.expires.toUTCString()}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (options.httpOnly) {
|
|
43
|
+
str += '; HttpOnly';
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (options.secure) {
|
|
47
|
+
str += '; Secure';
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (options.sameSite) {
|
|
51
|
+
switch (options.sameSite) {
|
|
52
|
+
case 'lax':
|
|
53
|
+
str += '; SameSite=Lax';
|
|
54
|
+
break;
|
|
55
|
+
case 'strict':
|
|
56
|
+
str += '; SameSite=Strict';
|
|
57
|
+
break;
|
|
58
|
+
case 'none':
|
|
59
|
+
str += '; SameSite=None';
|
|
60
|
+
break;
|
|
61
|
+
default:
|
|
62
|
+
break;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return str;
|
|
67
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { QHTTPXContext, QHTTPXMiddleware } from '../core/types';
|
|
2
|
+
|
|
3
|
+
export type LogEntry = {
|
|
4
|
+
method: string;
|
|
5
|
+
path: string;
|
|
6
|
+
status: number;
|
|
7
|
+
durationMs: number;
|
|
8
|
+
requestId?: string;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export type LoggerOptions = {
|
|
12
|
+
sink?: (entry: LogEntry, ctx: QHTTPXContext) => void;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export function createLoggerMiddleware(
|
|
16
|
+
options: LoggerOptions = {},
|
|
17
|
+
): QHTTPXMiddleware {
|
|
18
|
+
const sink =
|
|
19
|
+
options.sink ??
|
|
20
|
+
((entry: LogEntry) => {
|
|
21
|
+
const status = entry.status;
|
|
22
|
+
let color = '\x1b[37m';
|
|
23
|
+
if (status >= 200 && status < 300) {
|
|
24
|
+
color = '\x1b[32m';
|
|
25
|
+
} else if (status === 404) {
|
|
26
|
+
color = '\x1b[33m';
|
|
27
|
+
} else if (status >= 500) {
|
|
28
|
+
color = '\x1b[34m';
|
|
29
|
+
} else if (status >= 400) {
|
|
30
|
+
color = '\x1b[35m';
|
|
31
|
+
}
|
|
32
|
+
const reset = '\x1b[0m';
|
|
33
|
+
const prefix = entry.requestId ? `${entry.requestId} ` : '';
|
|
34
|
+
const line = `${prefix}${entry.method} ${entry.path} ${status} ${entry.durationMs}ms`;
|
|
35
|
+
console.log(`${color}${line}${reset}`);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
return async (ctx, next) => {
|
|
39
|
+
const start = ctx.requestStart ?? Date.now();
|
|
40
|
+
try {
|
|
41
|
+
await next();
|
|
42
|
+
} finally {
|
|
43
|
+
const durationMs = Math.max(1, Date.now() - start);
|
|
44
|
+
const method = ctx.req.method || 'GET';
|
|
45
|
+
const path = ctx.url.pathname;
|
|
46
|
+
const status = ctx.res.statusCode || 200;
|
|
47
|
+
sink(
|
|
48
|
+
{
|
|
49
|
+
method,
|
|
50
|
+
path,
|
|
51
|
+
status,
|
|
52
|
+
durationMs,
|
|
53
|
+
requestId: ctx.requestId,
|
|
54
|
+
},
|
|
55
|
+
ctx,
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { QHTTPX } from '../core/server';
|
|
2
|
+
|
|
3
|
+
export type SignalHandlerOptions = {
|
|
4
|
+
signals?: NodeJS.Signals[];
|
|
5
|
+
timeoutMs?: number;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
export function attachSignalHandlers(
|
|
9
|
+
app: QHTTPX,
|
|
10
|
+
options: SignalHandlerOptions = {},
|
|
11
|
+
): void {
|
|
12
|
+
const signals = options.signals ?? ['SIGINT', 'SIGTERM'];
|
|
13
|
+
const timeoutMs = options.timeoutMs ?? 10000;
|
|
14
|
+
|
|
15
|
+
let shuttingDown = false;
|
|
16
|
+
|
|
17
|
+
const handler = async (signal: NodeJS.Signals) => {
|
|
18
|
+
if (shuttingDown) {
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
shuttingDown = true;
|
|
22
|
+
|
|
23
|
+
console.log(`Received ${signal}, shutting down...`);
|
|
24
|
+
|
|
25
|
+
const timer = setTimeout(() => {
|
|
26
|
+
console.error(
|
|
27
|
+
'Shutdown timed out, forcing exit. (some connections might be lost)',
|
|
28
|
+
);
|
|
29
|
+
process.exit(1);
|
|
30
|
+
}, timeoutMs);
|
|
31
|
+
|
|
32
|
+
try {
|
|
33
|
+
await app.shutdown();
|
|
34
|
+
clearTimeout(timer);
|
|
35
|
+
process.exit(0);
|
|
36
|
+
} catch (err) {
|
|
37
|
+
console.error('Error during shutdown:', err);
|
|
38
|
+
process.exit(1);
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
for (const signal of signals) {
|
|
43
|
+
process.on(signal, handler);
|
|
44
|
+
}
|
|
45
|
+
}
|
package/src/utils/sse.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { QHTTPXContext } from '../core/types';
|
|
2
|
+
|
|
3
|
+
export interface SSEStream {
|
|
4
|
+
send(data: unknown, event?: string, id?: string): void;
|
|
5
|
+
close(): void;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function createSSE(ctx: QHTTPXContext): SSEStream {
|
|
9
|
+
const res = ctx.res;
|
|
10
|
+
|
|
11
|
+
// Disable auto-end so we can keep the connection open
|
|
12
|
+
ctx.disableAutoEnd = true;
|
|
13
|
+
|
|
14
|
+
// Only write headers if not already sent
|
|
15
|
+
if (!res.headersSent) {
|
|
16
|
+
res.setHeader('Content-Type', 'text/event-stream');
|
|
17
|
+
res.setHeader('Cache-Control', 'no-cache');
|
|
18
|
+
res.setHeader('Connection', 'keep-alive');
|
|
19
|
+
res.setHeader('X-Accel-Buffering', 'no'); // For Nginx
|
|
20
|
+
|
|
21
|
+
// Send initial ping/comment to flush headers and establish connection
|
|
22
|
+
res.write(': connected\n\n');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const send = (data: unknown, event?: string, id?: string) => {
|
|
26
|
+
if (id) res.write(`id: ${id}\n`);
|
|
27
|
+
if (event) res.write(`event: ${event}\n`);
|
|
28
|
+
const payload = typeof data === 'string' ? data : JSON.stringify(data);
|
|
29
|
+
res.write(`data: ${payload}\n\n`);
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const close = () => {
|
|
33
|
+
res.end();
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
ctx.req.on('close', () => {
|
|
37
|
+
// console.log('SSE client disconnected');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
return { send, close };
|
|
41
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { SimpleSchema, Validator, ValidationResult, ValidationError } from './types';
|
|
2
|
+
|
|
3
|
+
export class SimpleValidator implements Validator {
|
|
4
|
+
validate(schema: unknown, data: unknown): ValidationResult {
|
|
5
|
+
try {
|
|
6
|
+
// If schema is not a SimpleSchema object, we can't validate it with this validator
|
|
7
|
+
// But for simplicity, we assume the user passes a valid SimpleSchema if they use this validator.
|
|
8
|
+
const validData = this.check(schema as SimpleSchema, data);
|
|
9
|
+
return { success: true, data: validData };
|
|
10
|
+
} catch (err) {
|
|
11
|
+
return {
|
|
12
|
+
success: false,
|
|
13
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
14
|
+
error: new ValidationError((err as any).message || 'Validation failed')
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
20
|
+
private check(schema: SimpleSchema, data: unknown, path: string = ''): any {
|
|
21
|
+
if (schema.required !== false && (data === undefined || data === null)) {
|
|
22
|
+
throw new Error(`Field '${path}' is required`);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (data === undefined || data === null) {
|
|
26
|
+
return data;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
switch (schema.type) {
|
|
30
|
+
case 'string':
|
|
31
|
+
if (typeof data !== 'string') throw new Error(`Field '${path}' must be a string`);
|
|
32
|
+
if (schema.min !== undefined && data.length < schema.min) throw new Error(`Field '${path}' too short (min ${schema.min})`);
|
|
33
|
+
if (schema.max !== undefined && data.length > schema.max) throw new Error(`Field '${path}' too long (max ${schema.max})`);
|
|
34
|
+
if (schema.pattern && !new RegExp(schema.pattern).test(data)) throw new Error(`Field '${path}' format invalid`);
|
|
35
|
+
if (schema.enum && !schema.enum.includes(data)) throw new Error(`Field '${path}' must be one of [${schema.enum.join(', ')}]`);
|
|
36
|
+
return data;
|
|
37
|
+
|
|
38
|
+
case 'number':
|
|
39
|
+
// Try to coerce if it's a string looking like a number (useful for query params)
|
|
40
|
+
let num = data;
|
|
41
|
+
if (typeof data === 'string' && !isNaN(Number(data))) {
|
|
42
|
+
num = Number(data);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (typeof num !== 'number' || isNaN(num as number)) throw new Error(`Field '${path}' must be a number`);
|
|
46
|
+
if (schema.min !== undefined && (num as number) < schema.min) throw new Error(`Field '${path}' too small (min ${schema.min})`);
|
|
47
|
+
if (schema.max !== undefined && (num as number) > schema.max) throw new Error(`Field '${path}' too large (max ${schema.max})`);
|
|
48
|
+
if (schema.enum && !schema.enum.includes(num as number)) throw new Error(`Field '${path}' must be one of [${schema.enum.join(', ')}]`);
|
|
49
|
+
return num;
|
|
50
|
+
|
|
51
|
+
case 'boolean':
|
|
52
|
+
if (typeof data === 'boolean') return data;
|
|
53
|
+
if (data === 'true') return true;
|
|
54
|
+
if (data === 'false') return false;
|
|
55
|
+
throw new Error(`Field '${path}' must be a boolean`);
|
|
56
|
+
|
|
57
|
+
case 'array':
|
|
58
|
+
if (!Array.isArray(data)) throw new Error(`Field '${path}' must be an array`);
|
|
59
|
+
if (schema.min !== undefined && data.length < schema.min) throw new Error(`Field '${path}' too few items (min ${schema.min})`);
|
|
60
|
+
if (schema.max !== undefined && data.length > schema.max) throw new Error(`Field '${path}' too many items (max ${schema.max})`);
|
|
61
|
+
if (schema.items) {
|
|
62
|
+
return data.map((item, i) => this.check(schema.items!, item, `${path}[${i}]`));
|
|
63
|
+
}
|
|
64
|
+
return data;
|
|
65
|
+
|
|
66
|
+
case 'object':
|
|
67
|
+
if (typeof data !== 'object' || Array.isArray(data)) throw new Error(`Field '${path}' must be an object`);
|
|
68
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
69
|
+
const result: any = {};
|
|
70
|
+
const props = schema.properties || {};
|
|
71
|
+
|
|
72
|
+
// Check known properties
|
|
73
|
+
for (const key in props) {
|
|
74
|
+
const propSchema = props[key];
|
|
75
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
76
|
+
const propValue = (data as any)[key];
|
|
77
|
+
result[key] = this.check(propSchema, propValue, path ? `${path}.${key}` : key);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Pass through unknown properties?
|
|
81
|
+
// For strictness, maybe we should strip them?
|
|
82
|
+
// Let's keep unknown properties for now to be safe, or make it configurable.
|
|
83
|
+
// For now: Only keep validated properties if properties are defined.
|
|
84
|
+
if (Object.keys(props).length > 0) {
|
|
85
|
+
return result;
|
|
86
|
+
}
|
|
87
|
+
return data;
|
|
88
|
+
|
|
89
|
+
default:
|
|
90
|
+
return data;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
2
|
+
export type ValidationResult<T = any> =
|
|
3
|
+
| { success: true; data: T }
|
|
4
|
+
| { success: false; error: ValidationError };
|
|
5
|
+
|
|
6
|
+
export class ValidationError extends Error {
|
|
7
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
8
|
+
constructor(public details: any) {
|
|
9
|
+
super('Validation Error');
|
|
10
|
+
this.name = 'ValidationError';
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface Validator {
|
|
15
|
+
validate(schema: unknown, data: unknown): Promise<ValidationResult> | ValidationResult;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export type RouteSchema = {
|
|
19
|
+
body?: unknown;
|
|
20
|
+
query?: unknown;
|
|
21
|
+
params?: unknown;
|
|
22
|
+
headers?: unknown;
|
|
23
|
+
response?: unknown;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
// Simple Schema Types for the built-in validator
|
|
27
|
+
export type SimpleType = 'string' | 'number' | 'boolean' | 'object' | 'array';
|
|
28
|
+
|
|
29
|
+
export interface SimpleSchema {
|
|
30
|
+
type: SimpleType;
|
|
31
|
+
required?: boolean; // default true
|
|
32
|
+
properties?: Record<string, SimpleSchema>; // for object
|
|
33
|
+
items?: SimpleSchema; // for array
|
|
34
|
+
min?: number; // min length or value
|
|
35
|
+
max?: number; // max length or value
|
|
36
|
+
pattern?: string; // regex for string
|
|
37
|
+
enum?: (string | number)[];
|
|
38
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { Validator, ValidationResult, ValidationError } from './types';
|
|
2
|
+
|
|
3
|
+
export class ZodValidator implements Validator {
|
|
4
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
5
|
+
async validate<T = any>(schema: unknown, data: unknown): Promise<ValidationResult<T>> {
|
|
6
|
+
try {
|
|
7
|
+
const zodSchema = schema as { parseAsync: (data: unknown) => Promise<T> };
|
|
8
|
+
const result = await zodSchema.parseAsync(data);
|
|
9
|
+
return { success: true, data: result };
|
|
10
|
+
} catch (error) {
|
|
11
|
+
return { success: false, error: new ValidationError(error) };
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './types';
|