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.
Files changed (197) hide show
  1. package/.eslintrc.json +22 -0
  2. package/.github/workflows/ci.yml +32 -0
  3. package/.github/workflows/npm-publish.yml +37 -0
  4. package/.github/workflows/release.yml +21 -0
  5. package/.prettierrc +7 -0
  6. package/CHANGELOG.md +145 -0
  7. package/LICENSE +21 -0
  8. package/README.md +343 -0
  9. package/dist/package.json +61 -0
  10. package/dist/src/benchmarks/compare-frameworks.js +119 -0
  11. package/dist/src/benchmarks/quantam-users.js +56 -0
  12. package/dist/src/benchmarks/simple-json.js +58 -0
  13. package/dist/src/benchmarks/ultra-mode.js +122 -0
  14. package/dist/src/cli/index.js +200 -0
  15. package/dist/src/client/index.js +72 -0
  16. package/dist/src/core/batch.js +97 -0
  17. package/dist/src/core/body-parser.js +121 -0
  18. package/dist/src/core/buffer-pool.js +70 -0
  19. package/dist/src/core/config.js +50 -0
  20. package/dist/src/core/fusion.js +183 -0
  21. package/dist/src/core/logger.js +49 -0
  22. package/dist/src/core/metrics.js +111 -0
  23. package/dist/src/core/resources.js +25 -0
  24. package/dist/src/core/scheduler.js +85 -0
  25. package/dist/src/core/scope.js +68 -0
  26. package/dist/src/core/serializer.js +44 -0
  27. package/dist/src/core/server.js +905 -0
  28. package/dist/src/core/stream.js +71 -0
  29. package/dist/src/core/tasks.js +87 -0
  30. package/dist/src/core/types.js +19 -0
  31. package/dist/src/core/websocket.js +86 -0
  32. package/dist/src/core/worker-queue.js +73 -0
  33. package/dist/src/database/adapters/memory.js +90 -0
  34. package/dist/src/database/adapters/mongo.js +141 -0
  35. package/dist/src/database/adapters/postgres.js +111 -0
  36. package/dist/src/database/adapters/sqlite.js +42 -0
  37. package/dist/src/database/coalescer.js +134 -0
  38. package/dist/src/database/manager.js +87 -0
  39. package/dist/src/database/types.js +2 -0
  40. package/dist/src/index.js +61 -0
  41. package/dist/src/middleware/compression.js +133 -0
  42. package/dist/src/middleware/cors.js +66 -0
  43. package/dist/src/middleware/presets.js +33 -0
  44. package/dist/src/middleware/rate-limit.js +77 -0
  45. package/dist/src/middleware/security.js +69 -0
  46. package/dist/src/middleware/static.js +191 -0
  47. package/dist/src/openapi/generator.js +149 -0
  48. package/dist/src/router/radix-router.js +89 -0
  49. package/dist/src/router/radix-tree.js +81 -0
  50. package/dist/src/router/router.js +146 -0
  51. package/dist/src/testing/index.js +84 -0
  52. package/dist/src/utils/cookies.js +59 -0
  53. package/dist/src/utils/logger.js +45 -0
  54. package/dist/src/utils/signals.js +31 -0
  55. package/dist/src/utils/sse.js +32 -0
  56. package/dist/src/validation/index.js +19 -0
  57. package/dist/src/validation/simple.js +102 -0
  58. package/dist/src/validation/types.js +12 -0
  59. package/dist/src/validation/zod.js +18 -0
  60. package/dist/src/views/index.js +17 -0
  61. package/dist/src/views/types.js +2 -0
  62. package/dist/tests/adapters.test.js +106 -0
  63. package/dist/tests/batch.test.js +117 -0
  64. package/dist/tests/body-parser.test.js +52 -0
  65. package/dist/tests/compression-sse.test.js +87 -0
  66. package/dist/tests/cookies.test.js +63 -0
  67. package/dist/tests/cors.test.js +55 -0
  68. package/dist/tests/database.test.js +80 -0
  69. package/dist/tests/dx.test.js +64 -0
  70. package/dist/tests/ecosystem.test.js +133 -0
  71. package/dist/tests/features.test.js +47 -0
  72. package/dist/tests/fusion.test.js +92 -0
  73. package/dist/tests/http-basic.test.js +124 -0
  74. package/dist/tests/logger.test.js +33 -0
  75. package/dist/tests/middleware.test.js +109 -0
  76. package/dist/tests/observability.test.js +59 -0
  77. package/dist/tests/openapi.test.js +64 -0
  78. package/dist/tests/plugin.test.js +65 -0
  79. package/dist/tests/plugins.test.js +71 -0
  80. package/dist/tests/rate-limit.test.js +77 -0
  81. package/dist/tests/resources.test.js +44 -0
  82. package/dist/tests/scheduler.test.js +46 -0
  83. package/dist/tests/schema-routes.test.js +77 -0
  84. package/dist/tests/security.test.js +83 -0
  85. package/dist/tests/server-db.test.js +72 -0
  86. package/dist/tests/smoke.test.js +10 -0
  87. package/dist/tests/sqlite-fusion.test.js +92 -0
  88. package/dist/tests/static.test.js +102 -0
  89. package/dist/tests/stream.test.js +44 -0
  90. package/dist/tests/task-metrics.test.js +53 -0
  91. package/dist/tests/tasks.test.js +62 -0
  92. package/dist/tests/testing.test.js +47 -0
  93. package/dist/tests/validation.test.js +107 -0
  94. package/dist/tests/websocket.test.js +146 -0
  95. package/dist/vitest.config.js +9 -0
  96. package/docs/AEGIS.md +76 -0
  97. package/docs/BENCHMARKS.md +36 -0
  98. package/docs/CAPABILITIES.md +70 -0
  99. package/docs/CLI.md +43 -0
  100. package/docs/DATABASE.md +142 -0
  101. package/docs/ECOSYSTEM.md +146 -0
  102. package/docs/NEXT_STEPS.md +99 -0
  103. package/docs/OPENAPI.md +99 -0
  104. package/docs/PLUGINS.md +59 -0
  105. package/docs/REAL_WORLD_EXAMPLES.md +109 -0
  106. package/docs/ROADMAP.md +366 -0
  107. package/docs/VALIDATION.md +136 -0
  108. package/eslint.config.cjs +26 -0
  109. package/examples/api-server.ts +254 -0
  110. package/package.json +61 -0
  111. package/src/benchmarks/compare-frameworks.ts +149 -0
  112. package/src/benchmarks/quantam-users.ts +70 -0
  113. package/src/benchmarks/simple-json.ts +71 -0
  114. package/src/benchmarks/ultra-mode.ts +159 -0
  115. package/src/cli/index.ts +214 -0
  116. package/src/client/index.ts +93 -0
  117. package/src/core/batch.ts +110 -0
  118. package/src/core/body-parser.ts +151 -0
  119. package/src/core/buffer-pool.ts +96 -0
  120. package/src/core/config.ts +60 -0
  121. package/src/core/fusion.ts +210 -0
  122. package/src/core/logger.ts +70 -0
  123. package/src/core/metrics.ts +166 -0
  124. package/src/core/resources.ts +38 -0
  125. package/src/core/scheduler.ts +126 -0
  126. package/src/core/scope.ts +87 -0
  127. package/src/core/serializer.ts +41 -0
  128. package/src/core/server.ts +1113 -0
  129. package/src/core/stream.ts +111 -0
  130. package/src/core/tasks.ts +138 -0
  131. package/src/core/types.ts +178 -0
  132. package/src/core/websocket.ts +112 -0
  133. package/src/core/worker-queue.ts +90 -0
  134. package/src/database/adapters/memory.ts +99 -0
  135. package/src/database/adapters/mongo.ts +116 -0
  136. package/src/database/adapters/postgres.ts +86 -0
  137. package/src/database/adapters/sqlite.ts +44 -0
  138. package/src/database/coalescer.ts +153 -0
  139. package/src/database/manager.ts +97 -0
  140. package/src/database/types.ts +24 -0
  141. package/src/index.ts +42 -0
  142. package/src/middleware/compression.ts +147 -0
  143. package/src/middleware/cors.ts +98 -0
  144. package/src/middleware/presets.ts +50 -0
  145. package/src/middleware/rate-limit.ts +106 -0
  146. package/src/middleware/security.ts +109 -0
  147. package/src/middleware/static.ts +216 -0
  148. package/src/openapi/generator.ts +167 -0
  149. package/src/router/radix-router.ts +119 -0
  150. package/src/router/radix-tree.ts +106 -0
  151. package/src/router/router.ts +190 -0
  152. package/src/testing/index.ts +104 -0
  153. package/src/utils/cookies.ts +67 -0
  154. package/src/utils/logger.ts +59 -0
  155. package/src/utils/signals.ts +45 -0
  156. package/src/utils/sse.ts +41 -0
  157. package/src/validation/index.ts +3 -0
  158. package/src/validation/simple.ts +93 -0
  159. package/src/validation/types.ts +38 -0
  160. package/src/validation/zod.ts +14 -0
  161. package/src/views/index.ts +1 -0
  162. package/src/views/types.ts +4 -0
  163. package/tests/adapters.test.ts +120 -0
  164. package/tests/batch.test.ts +139 -0
  165. package/tests/body-parser.test.ts +83 -0
  166. package/tests/compression-sse.test.ts +98 -0
  167. package/tests/cookies.test.ts +74 -0
  168. package/tests/cors.test.ts +79 -0
  169. package/tests/database.test.ts +90 -0
  170. package/tests/dx.test.ts +78 -0
  171. package/tests/ecosystem.test.ts +156 -0
  172. package/tests/features.test.ts +51 -0
  173. package/tests/fusion.test.ts +121 -0
  174. package/tests/http-basic.test.ts +161 -0
  175. package/tests/logger.test.ts +48 -0
  176. package/tests/middleware.test.ts +137 -0
  177. package/tests/observability.test.ts +91 -0
  178. package/tests/openapi.test.ts +74 -0
  179. package/tests/plugin.test.ts +85 -0
  180. package/tests/plugins.test.ts +93 -0
  181. package/tests/rate-limit.test.ts +97 -0
  182. package/tests/resources.test.ts +64 -0
  183. package/tests/scheduler.test.ts +71 -0
  184. package/tests/schema-routes.test.ts +89 -0
  185. package/tests/security.test.ts +128 -0
  186. package/tests/server-db.test.ts +72 -0
  187. package/tests/smoke.test.ts +9 -0
  188. package/tests/sqlite-fusion.test.ts +106 -0
  189. package/tests/static.test.ts +111 -0
  190. package/tests/stream.test.ts +58 -0
  191. package/tests/task-metrics.test.ts +78 -0
  192. package/tests/tasks.test.ts +90 -0
  193. package/tests/testing.test.ts +53 -0
  194. package/tests/validation.test.ts +126 -0
  195. package/tests/websocket.test.ts +132 -0
  196. package/tsconfig.json +16 -0
  197. package/vitest.config.ts +9 -0
@@ -0,0 +1,216 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ // import crypto from 'crypto';
4
+ import { QHTTPXMiddleware } from '../core/types';
5
+
6
+ export type StaticOptions = {
7
+ root: string;
8
+ index?: string;
9
+ fallthrough?: boolean;
10
+ etag?: boolean;
11
+ lastModified?: boolean;
12
+ maxAge?: number; // in seconds
13
+ immutable?: boolean;
14
+ };
15
+
16
+ function guessContentType(filePath: string): string {
17
+ const ext = path.extname(filePath).toLowerCase();
18
+ const types: Record<string, string> = {
19
+ '.html': 'text/html; charset=utf-8',
20
+ '.htm': 'text/html; charset=utf-8',
21
+ '.js': 'application/javascript; charset=utf-8',
22
+ '.mjs': 'application/javascript; charset=utf-8',
23
+ '.css': 'text/css; charset=utf-8',
24
+ '.json': 'application/json; charset=utf-8',
25
+ '.png': 'image/png',
26
+ '.jpg': 'image/jpeg',
27
+ '.jpeg': 'image/jpeg',
28
+ '.gif': 'image/gif',
29
+ '.svg': 'image/svg+xml',
30
+ '.ico': 'image/x-icon',
31
+ '.txt': 'text/plain; charset=utf-8',
32
+ '.mp4': 'video/mp4',
33
+ '.webm': 'video/webm',
34
+ '.mp3': 'audio/mpeg',
35
+ '.wav': 'audio/wav',
36
+ '.pdf': 'application/pdf',
37
+ '.zip': 'application/zip',
38
+ };
39
+ return types[ext] || 'application/octet-stream';
40
+ }
41
+
42
+ function generateETag(stat: fs.Stats): string {
43
+ const mtime = stat.mtimeMs.toString(16);
44
+ const size = stat.size.toString(16);
45
+ return `W/"${size}-${mtime}"`;
46
+ }
47
+
48
+ export function createStaticMiddleware(options: StaticOptions): QHTTPXMiddleware {
49
+ const root = path.resolve(options.root);
50
+ const indexFile = options.index ?? 'index.html';
51
+ const fallthrough = options.fallthrough ?? false;
52
+ const etag = options.etag ?? true;
53
+ const lastModified = options.lastModified ?? true;
54
+ const maxAge = options.maxAge ?? 0;
55
+ const immutable = options.immutable ?? false;
56
+
57
+ return async (ctx, next) => {
58
+ const method = ctx.req.method || 'GET';
59
+ if (method !== 'GET' && method !== 'HEAD') {
60
+ if (fallthrough) {
61
+ await next();
62
+ return;
63
+ }
64
+ ctx.res.statusCode = 405;
65
+ ctx.res.setHeader('Allow', 'GET, HEAD');
66
+ ctx.res.setHeader('Content-Type', 'text/plain; charset=utf-8');
67
+ ctx.res.end('Method Not Allowed');
68
+ return;
69
+ }
70
+
71
+ let requestPath = ctx.url.pathname || '/';
72
+ // Prevent directory traversal
73
+ requestPath = requestPath.replace(/^(\.\.(\/|\\|$))+/, '');
74
+
75
+ // Normalize path
76
+ let safePath = path.normalize(requestPath);
77
+ if (safePath.startsWith('..')) safePath = '/'; // Extra safety
78
+
79
+ // Handle root/directory requests
80
+ let filePath = path.join(root, safePath);
81
+
82
+ let stat: fs.Stats;
83
+ try {
84
+ stat = await fs.promises.stat(filePath);
85
+ if (stat.isDirectory()) {
86
+ filePath = path.join(filePath, indexFile);
87
+ stat = await fs.promises.stat(filePath);
88
+ }
89
+ } catch {
90
+ if (fallthrough) {
91
+ await next();
92
+ return;
93
+ }
94
+ ctx.res.statusCode = 404;
95
+ ctx.res.setHeader('Content-Type', 'text/plain; charset=utf-8');
96
+ ctx.res.end('Not Found');
97
+ return;
98
+ }
99
+
100
+ if (!stat.isFile()) {
101
+ if (fallthrough) {
102
+ await next();
103
+ return;
104
+ }
105
+ ctx.res.statusCode = 404;
106
+ ctx.res.setHeader('Content-Type', 'text/plain; charset=utf-8');
107
+ ctx.res.end('Not Found');
108
+ return;
109
+ }
110
+
111
+ // Caching Headers
112
+ if (maxAge > 0 || immutable) {
113
+ let cacheControl = `public, max-age=${maxAge}`;
114
+ if (immutable) cacheControl += ', immutable';
115
+ ctx.res.setHeader('Cache-Control', cacheControl);
116
+ }
117
+
118
+ if (lastModified) {
119
+ ctx.res.setHeader('Last-Modified', stat.mtime.toUTCString());
120
+ }
121
+
122
+ if (etag) {
123
+ const tag = generateETag(stat);
124
+ ctx.res.setHeader('ETag', tag);
125
+
126
+ // ETag Freshness Check
127
+ const ifNoneMatch = ctx.req.headers['if-none-match'];
128
+ if (ifNoneMatch === tag) {
129
+ ctx.res.statusCode = 304;
130
+ ctx.res.end();
131
+ return;
132
+ }
133
+ }
134
+
135
+ // Last-Modified Freshness Check
136
+ const ifModifiedSince = ctx.req.headers['if-modified-since'];
137
+ if (ifModifiedSince) {
138
+ const since = new Date(ifModifiedSince).getTime();
139
+ // Round down mtime to seconds for comparison
140
+ const mtime = Math.floor(stat.mtimeMs / 1000) * 1000;
141
+ if (mtime <= since) {
142
+ ctx.res.statusCode = 304;
143
+ ctx.res.end();
144
+ return;
145
+ }
146
+ }
147
+
148
+ const contentType = guessContentType(filePath);
149
+ ctx.res.setHeader('Content-Type', contentType);
150
+ ctx.res.setHeader('Accept-Ranges', 'bytes');
151
+
152
+ // Range Support
153
+ const range = ctx.req.headers['range'];
154
+ let start = 0;
155
+ let end = stat.size - 1;
156
+ // let isRange = false;
157
+
158
+ if (range) {
159
+ const parts = range.replace(/bytes=/, '').split('-');
160
+ const partialStart = parts[0];
161
+ const partialEnd = parts[1];
162
+
163
+ const parsedStart = parseInt(partialStart, 10);
164
+ const parsedEnd = partialEnd ? parseInt(partialEnd, 10) : end;
165
+
166
+ if (!isNaN(parsedStart) && !isNaN(parsedEnd) && parsedStart <= parsedEnd && parsedEnd < stat.size) {
167
+ start = parsedStart;
168
+ end = parsedEnd;
169
+ // isRange = true;
170
+ ctx.res.statusCode = 206;
171
+ ctx.res.setHeader('Content-Range', `bytes ${start}-${end}/${stat.size}`);
172
+ ctx.res.setHeader('Content-Length', end - start + 1);
173
+ } else {
174
+ // Invalid range
175
+ ctx.res.statusCode = 416;
176
+ ctx.res.setHeader('Content-Range', `bytes */${stat.size}`);
177
+ ctx.res.end();
178
+ return;
179
+ }
180
+ } else {
181
+ ctx.res.statusCode = 200;
182
+ ctx.res.setHeader('Content-Length', stat.size);
183
+ }
184
+
185
+ if (method === 'HEAD') {
186
+ ctx.res.end();
187
+ return;
188
+ }
189
+
190
+ const stream = fs.createReadStream(filePath, { start, end });
191
+
192
+ // Disable auto-end if we are streaming?
193
+ // Actually, we can just pipe. But we need to make sure the server doesn't call res.end()
194
+ // The middleware architecture awaits next(), then server calls end().
195
+ // If we handle the response here, we should probably set a flag or just return without calling next() (which we do).
196
+ // But the server might still try to end it if we don't tell it we are done.
197
+ // In QHTTPX, if a handler returns, server checks `res.writableEnded`.
198
+ // Pipe calls end() by default.
199
+
200
+ // We need to wait for the stream to finish before returning from middleware
201
+ // so that the server doesn't race.
202
+
203
+ await new Promise<void>((resolve, reject) => {
204
+ stream.pipe(ctx.res);
205
+ stream.on('end', resolve);
206
+ stream.on('error', (err) => {
207
+ stream.destroy();
208
+ reject(err);
209
+ });
210
+ ctx.res.on('close', () => {
211
+ stream.destroy();
212
+ resolve();
213
+ });
214
+ });
215
+ };
216
+ }
@@ -0,0 +1,167 @@
1
+ import { RouteSchema, SimpleSchema } from '../validation/types';
2
+ import { Router } from '../router/router';
3
+
4
+ export type OpenAPIOptions = {
5
+ info: {
6
+ title: string;
7
+ version: string;
8
+ description?: string;
9
+ };
10
+ servers?: { url: string; description?: string }[];
11
+ };
12
+
13
+ export class OpenAPIGenerator {
14
+ constructor(private router: Router, private options: OpenAPIOptions) {}
15
+
16
+ generate(): object {
17
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
18
+ const paths: Record<string, any> = {};
19
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
20
+ const schemas: Record<string, any> = {};
21
+
22
+ const methodMap = this.router.getRoutes();
23
+
24
+ for (const [method, routes] of methodMap.entries()) {
25
+ for (const route of routes) {
26
+ // Convert /users/:id to /users/{id}
27
+ const pathKey = route.path.replace(/:([a-zA-Z0-9_]+)/g, '{$1}');
28
+
29
+ if (!paths[pathKey]) {
30
+ paths[pathKey] = {};
31
+ }
32
+
33
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
34
+ const operation: any = {
35
+ responses: {}
36
+ };
37
+
38
+ const schema = route.schema as RouteSchema | undefined;
39
+ if (schema) {
40
+ // Parameters (Query & Path)
41
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
42
+ const parameters: any[] = [];
43
+
44
+ // Path Params
45
+ // We can infer path params from the URL segments
46
+ const pathParams = route.path.match(/:([a-zA-Z0-9_]+)/g);
47
+ if (pathParams) {
48
+ pathParams.forEach(p => {
49
+ const name = p.substring(1);
50
+ // Check if we have specific schema for this param
51
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
52
+ const paramSchema = (schema.params as any)?.properties?.[name];
53
+ parameters.push({
54
+ name,
55
+ in: 'path',
56
+ required: true,
57
+ schema: paramSchema ? this.convertSchema(paramSchema) : { type: 'string' }
58
+ });
59
+ });
60
+ }
61
+
62
+ // Query Params
63
+ if (schema.query) {
64
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
65
+ const queryProps = (schema.query as any).properties || {};
66
+ for (const [key, prop] of Object.entries(queryProps)) {
67
+ parameters.push({
68
+ name: key,
69
+ in: 'query',
70
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
71
+ required: (schema.query as any).required !== false && (prop as any).required !== false, // simplified
72
+ schema: this.convertSchema(prop as SimpleSchema)
73
+ });
74
+ }
75
+ }
76
+
77
+ if (parameters.length > 0) {
78
+ operation.parameters = parameters;
79
+ }
80
+
81
+ // Request Body
82
+ if (schema.body) {
83
+ operation.requestBody = {
84
+ content: {
85
+ 'application/json': {
86
+ schema: this.convertSchema(schema.body as SimpleSchema)
87
+ }
88
+ }
89
+ };
90
+ }
91
+
92
+ // Responses
93
+ // For now, default to 200 OK
94
+ operation.responses['200'] = {
95
+ description: 'Successful response',
96
+ content: {
97
+ 'application/json': {
98
+ schema: schema.response ? this.convertSchema(schema.response as SimpleSchema) : {}
99
+ }
100
+ }
101
+ };
102
+
103
+ // 400 Bad Request if validation exists
104
+ if (schema.body || schema.query || schema.params) {
105
+ operation.responses['400'] = {
106
+ description: 'Validation Error'
107
+ };
108
+ }
109
+
110
+ } else {
111
+ // No schema, generic response
112
+ operation.responses['200'] = {
113
+ description: 'Successful response'
114
+ };
115
+ }
116
+
117
+ paths[pathKey][method.toLowerCase()] = operation;
118
+ }
119
+ }
120
+
121
+ return {
122
+ openapi: '3.0.0',
123
+ info: this.options.info,
124
+ servers: this.options.servers,
125
+ paths,
126
+ components: {
127
+ schemas
128
+ }
129
+ };
130
+ }
131
+
132
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
133
+ private convertSchema(schema: SimpleSchema): any {
134
+ if (!schema) return {};
135
+
136
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
137
+ const res: any = { type: schema.type };
138
+
139
+ if (schema.properties) {
140
+ res.properties = {};
141
+ for (const [key, prop] of Object.entries(schema.properties)) {
142
+ res.properties[key] = this.convertSchema(prop);
143
+ }
144
+ }
145
+
146
+ if (schema.items) {
147
+ res.items = this.convertSchema(schema.items);
148
+ }
149
+
150
+ if (schema.min !== undefined) {
151
+ if (schema.type === 'string') res.minLength = schema.min;
152
+ if (schema.type === 'number') res.minimum = schema.min;
153
+ if (schema.type === 'array') res.minItems = schema.min;
154
+ }
155
+
156
+ if (schema.max !== undefined) {
157
+ if (schema.type === 'string') res.maxLength = schema.max;
158
+ if (schema.type === 'number') res.maximum = schema.max;
159
+ if (schema.type === 'array') res.maxItems = schema.max;
160
+ }
161
+
162
+ if (schema.pattern) res.pattern = schema.pattern;
163
+ if (schema.enum) res.enum = schema.enum;
164
+
165
+ return res;
166
+ }
167
+ }
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Radix tree router compiled into a flat array structure for zero-allocation matching.
3
+ * Built once at server startup (frozen).
4
+ */
5
+
6
+ import { HTTPMethod, QHTTPXHandler } from '../core/types';
7
+
8
+ type RadixNode = {
9
+ charCode?: number; // Single char to match
10
+ prefix?: string; // Static path prefix
11
+ children?: Map<string | number, RadixNode>; // char/paramKey -> child
12
+ handler?: QHTTPXHandler;
13
+ paramKey?: string; // For :param routes
14
+ };
15
+
16
+ export type CompiledRadixMatch = {
17
+ handler: QHTTPXHandler;
18
+ params: Record<string, string>;
19
+ };
20
+
21
+ export class RadixRouter {
22
+ private roots: Map<HTTPMethod, RadixNode> = new Map();
23
+ private isFrozen = false;
24
+
25
+ register(method: HTTPMethod, path: string, handler: QHTTPXHandler): void {
26
+ if (this.isFrozen) {
27
+ console.warn(`Radix router is frozen. Late route registration (${method} ${path}) may not be optimized.`);
28
+ }
29
+
30
+ if (!this.roots.has(method)) {
31
+ this.roots.set(method, {});
32
+ }
33
+
34
+ const root = this.roots.get(method)!;
35
+ const segments = this.normalize(path);
36
+
37
+ let node = root;
38
+ for (const segment of segments) {
39
+ if (!node.children) {
40
+ node.children = new Map();
41
+ }
42
+
43
+ const isParam = segment.startsWith(':');
44
+ const key = isParam ? segment.slice(1) : segment;
45
+
46
+ if (!node.children.has(key)) {
47
+ node.children.set(key, {
48
+ paramKey: isParam ? key : undefined,
49
+ });
50
+ }
51
+
52
+ node = node.children.get(key)!;
53
+ }
54
+
55
+ node.handler = handler;
56
+ }
57
+
58
+ match(method: HTTPMethod, path: string): CompiledRadixMatch | undefined {
59
+ const root = this.roots.get(method);
60
+ if (!root) {
61
+ return undefined;
62
+ }
63
+
64
+ const segments = this.normalize(path);
65
+ const params: Record<string, string> = {};
66
+
67
+ let node = root;
68
+ for (const segment of segments) {
69
+ if (!node.children) {
70
+ return undefined;
71
+ }
72
+
73
+ // Try exact match first
74
+ let child = node.children.get(segment);
75
+
76
+ // If no exact match, try param match
77
+ if (!child) {
78
+ for (const [key, candidate] of node.children.entries()) {
79
+ if (candidate.paramKey && typeof key === 'string') {
80
+ params[key] = segment;
81
+ child = candidate;
82
+ break;
83
+ }
84
+ }
85
+ }
86
+
87
+ if (!child) {
88
+ return undefined;
89
+ }
90
+
91
+ node = child;
92
+ }
93
+
94
+ if (!node.handler) {
95
+ return undefined;
96
+ }
97
+
98
+ return { handler: node.handler, params };
99
+ }
100
+
101
+ freeze(): void {
102
+ if (this.isFrozen) {
103
+ return;
104
+ }
105
+ this.isFrozen = true;
106
+ // Future: compile to flat array structure here
107
+ }
108
+
109
+ isFrozenRouter(): boolean {
110
+ return this.isFrozen;
111
+ }
112
+
113
+ private normalize(path: string): string[] {
114
+ if (!path || path === '/') {
115
+ return [];
116
+ }
117
+ return path.split('/').filter((segment) => segment.length > 0);
118
+ }
119
+ }
@@ -0,0 +1,106 @@
1
+ import { QHTTPXHandler, RoutePriority } from '../core/types';
2
+
3
+ export type RouteData = {
4
+ handler: QHTTPXHandler;
5
+ priority: RoutePriority;
6
+ };
7
+
8
+ class Node {
9
+ // Map segment -> Node
10
+ children: Map<string, Node> = new Map();
11
+ // Parameter child node (only one allowed per level for simplicity)
12
+ paramChild: Node | null = null;
13
+ paramName: string | null = null;
14
+
15
+ // Data if this node is a route end
16
+ data: RouteData | null = null;
17
+ }
18
+
19
+ export type MatchResult = {
20
+ handler: QHTTPXHandler;
21
+ priority: RoutePriority;
22
+ params: Record<string, string>;
23
+ };
24
+
25
+ export class RadixTree {
26
+ private root: Node = new Node();
27
+
28
+ insert(
29
+ segments: string[],
30
+ handler: QHTTPXHandler,
31
+ priority: RoutePriority,
32
+ ): void {
33
+ let node = this.root;
34
+
35
+ for (const segment of segments) {
36
+ if (segment.startsWith(':')) {
37
+ const paramName = segment.slice(1);
38
+ if (!node.paramChild) {
39
+ node.paramChild = new Node();
40
+ node.paramChild.paramName = paramName;
41
+ } else if (node.paramChild.paramName !== paramName) {
42
+ throw new Error(
43
+ `Cannot have two different parameter names at the same level: "${node.paramChild.paramName}" and "${paramName}"`,
44
+ );
45
+ }
46
+ node = node.paramChild;
47
+ } else {
48
+ if (!node.children.has(segment)) {
49
+ node.children.set(segment, new Node());
50
+ }
51
+ node = node.children.get(segment)!;
52
+ }
53
+ }
54
+
55
+ node.data = { handler, priority };
56
+ }
57
+
58
+ lookup(segments: string[]): MatchResult | null {
59
+ // Use a stack-based approach or recursion.
60
+ // For simplicity and correctness with backtracking, we'll use recursion.
61
+ // To minimize allocations, we pass the same params object and only copy on success?
62
+ // Actually, creating a params object is inevitable for the result.
63
+
64
+ return this.find(this.root, segments, 0);
65
+ }
66
+
67
+ private find(
68
+ node: Node,
69
+ segments: string[],
70
+ index: number,
71
+ ): MatchResult | null {
72
+ if (index === segments.length) {
73
+ if (node.data) {
74
+ return {
75
+ handler: node.data.handler,
76
+ priority: node.data.priority,
77
+ params: {},
78
+ };
79
+ }
80
+ return null;
81
+ }
82
+
83
+ const segment = segments[index];
84
+
85
+ // 1. Try exact match
86
+ const child = node.children.get(segment);
87
+ if (child) {
88
+ const result = this.find(child, segments, index + 1);
89
+ if (result) {
90
+ return result;
91
+ }
92
+ }
93
+
94
+ // 2. Try param match
95
+ if (node.paramChild) {
96
+ const result = this.find(node.paramChild, segments, index + 1);
97
+ if (result) {
98
+ // If match found down this path, add current param to it
99
+ result.params[node.paramChild.paramName!] = segment;
100
+ return result;
101
+ }
102
+ }
103
+
104
+ return null;
105
+ }
106
+ }