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
package/tests/dx.test.ts
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { QHTTPX, createHttpApp } from '../src/index';
|
|
3
|
+
import { createApiPreset, createStaticAppPreset } from '../src/middleware/presets';
|
|
4
|
+
|
|
5
|
+
describe('Developer Experience Features', () => {
|
|
6
|
+
describe('Routing Ergonomics', () => {
|
|
7
|
+
it('supports chainable route() builder', async () => {
|
|
8
|
+
const app = new QHTTPX();
|
|
9
|
+
|
|
10
|
+
app.route('/users/:id')
|
|
11
|
+
.get((ctx) => ctx.json({ method: 'GET', id: ctx.params.id }))
|
|
12
|
+
.post((ctx) => ctx.json({ method: 'POST', id: ctx.params.id }))
|
|
13
|
+
.put((ctx) => ctx.json({ method: 'PUT', id: ctx.params.id }))
|
|
14
|
+
.delete((ctx) => ctx.json({ method: 'DELETE', id: ctx.params.id }));
|
|
15
|
+
|
|
16
|
+
const { port } = await app.listen(0, '127.0.0.1');
|
|
17
|
+
const baseUrl = `http://127.0.0.1:${port}`;
|
|
18
|
+
|
|
19
|
+
// Test GET
|
|
20
|
+
const resGet = await fetch(`${baseUrl}/users/123`);
|
|
21
|
+
expect(await resGet.json()).toEqual({ method: 'GET', id: '123' });
|
|
22
|
+
|
|
23
|
+
// Test POST
|
|
24
|
+
const resPost = await fetch(`${baseUrl}/users/123`, { method: 'POST' });
|
|
25
|
+
expect(await resPost.json()).toEqual({ method: 'POST', id: '123' });
|
|
26
|
+
|
|
27
|
+
// Test PUT
|
|
28
|
+
const resPut = await fetch(`${baseUrl}/users/123`, { method: 'PUT' });
|
|
29
|
+
expect(await resPut.json()).toEqual({ method: 'PUT', id: '123' });
|
|
30
|
+
|
|
31
|
+
// Test DELETE
|
|
32
|
+
const resDelete = await fetch(`${baseUrl}/users/123`, { method: 'DELETE' });
|
|
33
|
+
expect(await resDelete.json()).toEqual({ method: 'DELETE', id: '123' });
|
|
34
|
+
|
|
35
|
+
await app.close();
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe('Simple App Helper', () => {
|
|
40
|
+
it('supports createHttpApp basic usage', async () => {
|
|
41
|
+
const app = createHttpApp();
|
|
42
|
+
|
|
43
|
+
app.get('/', (ctx) => {
|
|
44
|
+
ctx.send('Hello World!');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
const { port } = await app.listen(0, '127.0.0.1');
|
|
48
|
+
const response = await fetch(`http://127.0.0.1:${port}/`);
|
|
49
|
+
|
|
50
|
+
expect(response.status).toBe(200);
|
|
51
|
+
expect(await response.text()).toBe('Hello World!');
|
|
52
|
+
|
|
53
|
+
await app.close();
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe('Presets', () => {
|
|
58
|
+
it('createApiPreset returns middlewares', () => {
|
|
59
|
+
const middlewares = createApiPreset();
|
|
60
|
+
// Should have CORS (1) + Security Headers (1) + Logger (1) = 3
|
|
61
|
+
expect(middlewares.length).toBe(3);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('createApiPreset allows disabling logger', () => {
|
|
65
|
+
const middlewares = createApiPreset({ logging: false });
|
|
66
|
+
// CORS + Security Headers = 2
|
|
67
|
+
expect(middlewares.length).toBe(2);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('createStaticAppPreset returns middlewares with static', () => {
|
|
71
|
+
const middlewares = createStaticAppPreset({
|
|
72
|
+
static: { root: './public' },
|
|
73
|
+
});
|
|
74
|
+
// CORS + Security Headers + Logger + Static = 4
|
|
75
|
+
expect(middlewares.length).toBe(4);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
});
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { QHTTPX } from '../src/core/server';
|
|
3
|
+
import { QHTTPXMiddleware } from '../src/core/types';
|
|
4
|
+
|
|
5
|
+
// Mock External Libraries
|
|
6
|
+
const mockJose = {
|
|
7
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
8
|
+
jwtVerify: async (token: string, _secret: unknown) => {
|
|
9
|
+
if (token === 'valid_token') return { payload: { sub: 'user_123', role: 'admin' } };
|
|
10
|
+
throw new Error('Invalid Token');
|
|
11
|
+
}
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const mockRedis = {
|
|
15
|
+
get: async (key: string) => {
|
|
16
|
+
if (key === 'cache:page:home') return '<html>Cached Home</html>';
|
|
17
|
+
return null;
|
|
18
|
+
},
|
|
19
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
20
|
+
set: async (key: string, val: string) => 'OK'
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
describe('SaaS Ecosystem Compatibility', () => {
|
|
24
|
+
|
|
25
|
+
it('should allow integration of 3rd party JWT libraries (e.g. jose)', async () => {
|
|
26
|
+
const app = new QHTTPX();
|
|
27
|
+
const secret = 'super-secret';
|
|
28
|
+
// const token = await new SignJWT({ 'urn:example:claim': true })
|
|
29
|
+
// .setProtectedHeader({ alg: 'HS256' })
|
|
30
|
+
// .setIssuedAt()
|
|
31
|
+
// .setExpirationTime('2h')
|
|
32
|
+
// .sign(new TextEncoder().encode(secret));
|
|
33
|
+
|
|
34
|
+
// 1. Create a custom Authentication Middleware using "jose"
|
|
35
|
+
const authMiddleware: QHTTPXMiddleware = async (ctx, next) => {
|
|
36
|
+
const authHeader = ctx.req.headers['authorization'];
|
|
37
|
+
if (!authHeader) {
|
|
38
|
+
ctx.res.statusCode = 401;
|
|
39
|
+
ctx.res.end('Unauthorized');
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
try {
|
|
44
|
+
const token = authHeader.split(' ')[1];
|
|
45
|
+
const { payload } = await mockJose.jwtVerify(token, secret);
|
|
46
|
+
|
|
47
|
+
// Store user in ctx.state (Standard Pattern)
|
|
48
|
+
ctx.state.user = payload;
|
|
49
|
+
await next();
|
|
50
|
+
} catch {
|
|
51
|
+
ctx.res.statusCode = 403;
|
|
52
|
+
ctx.res.end('Forbidden');
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
app.use(authMiddleware);
|
|
57
|
+
|
|
58
|
+
// Protected Route
|
|
59
|
+
app.get('/dashboard', async (ctx) => {
|
|
60
|
+
// Access the user injected by middleware
|
|
61
|
+
const user = ctx.state.user;
|
|
62
|
+
ctx.json({ message: `Welcome ${user.sub}`, role: user.role });
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
const { port } = await app.listen(0);
|
|
66
|
+
const url = `http://localhost:${port}`;
|
|
67
|
+
|
|
68
|
+
// Test Valid Token
|
|
69
|
+
const res1 = await fetch(`${url}/dashboard`, { headers: { Authorization: 'Bearer valid_token' } });
|
|
70
|
+
expect(res1.status).toBe(200);
|
|
71
|
+
const data1 = await res1.json();
|
|
72
|
+
expect(data1).toEqual({ message: 'Welcome user_123', role: 'admin' });
|
|
73
|
+
|
|
74
|
+
// Test Invalid Token
|
|
75
|
+
const res2 = await fetch(`${url}/dashboard`, { headers: { Authorization: 'Bearer bad_token' } });
|
|
76
|
+
expect(res2.status).toBe(403);
|
|
77
|
+
|
|
78
|
+
await app.close();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('should allow integration of 3rd party Caching (e.g. Redis)', async () => {
|
|
82
|
+
const app = new QHTTPX();
|
|
83
|
+
|
|
84
|
+
// 2. Create a Caching Middleware using "redis"
|
|
85
|
+
const cacheMiddleware: QHTTPXMiddleware = async (ctx, next) => {
|
|
86
|
+
const key = `cache:page:${ctx.url.pathname.replace('/', '')}`;
|
|
87
|
+
const cached = await mockRedis.get(key);
|
|
88
|
+
|
|
89
|
+
if (cached) {
|
|
90
|
+
ctx.res.setHeader('X-Cache', 'HIT');
|
|
91
|
+
ctx.res.end(cached);
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
ctx.res.setHeader('X-Cache', 'MISS');
|
|
96
|
+
await next();
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
app.use(cacheMiddleware);
|
|
100
|
+
|
|
101
|
+
app.get('/home', async (ctx) => {
|
|
102
|
+
ctx.html('<html>Fresh Home</html>');
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
const { port } = await app.listen(0);
|
|
106
|
+
const url = `http://localhost:${port}`;
|
|
107
|
+
|
|
108
|
+
// Test Cache Hit (Mocked)
|
|
109
|
+
const res1 = await fetch(`${url}/home`);
|
|
110
|
+
expect(res1.status).toBe(200);
|
|
111
|
+
expect(res1.headers.get('x-cache')).toBe('HIT');
|
|
112
|
+
const text1 = await res1.text();
|
|
113
|
+
expect(text1).toBe('<html>Cached Home</html>');
|
|
114
|
+
|
|
115
|
+
// Test Cache Miss (Mocked for other route)
|
|
116
|
+
// Note: In a real app we'd need to intercept res.end to write to redis,
|
|
117
|
+
// but for this capability check, reading is enough proof.
|
|
118
|
+
|
|
119
|
+
await app.close();
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('should allow integration of ORM/DB libraries', async () => {
|
|
123
|
+
const app = new QHTTPX();
|
|
124
|
+
|
|
125
|
+
// Mock TypeORM/Prisma usage
|
|
126
|
+
const db = {
|
|
127
|
+
users: {
|
|
128
|
+
findMany: async () => [{ id: 1, name: 'Alice' }],
|
|
129
|
+
},
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
// Middleware to attach DB to context
|
|
133
|
+
app.use(async (ctx, next) => {
|
|
134
|
+
// We can attach to ctx.state or extend ctx type if we want
|
|
135
|
+
ctx.state.db = db;
|
|
136
|
+
await next();
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
app.get('/users', async (ctx) => {
|
|
140
|
+
try {
|
|
141
|
+
const users = await ctx.state.db.users.findMany();
|
|
142
|
+
ctx.res.end(JSON.stringify(users));
|
|
143
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
144
|
+
} catch (err) {
|
|
145
|
+
ctx.res.statusCode = 500;
|
|
146
|
+
ctx.res.end('DB Error');
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
const { port } = await app.listen(0);
|
|
151
|
+
const url = `http://localhost:${port}`;
|
|
152
|
+
const res = await fetch(`${url}/users`);
|
|
153
|
+
expect(res.status).toBe(200);
|
|
154
|
+
await app.close();
|
|
155
|
+
});
|
|
156
|
+
});
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { QHTTPX } from '../src/index';
|
|
3
|
+
import { Logger } from '../src/core/logger';
|
|
4
|
+
import { hc } from '../src/client';
|
|
5
|
+
|
|
6
|
+
describe('Logger', () => {
|
|
7
|
+
it('should initialize with pino', () => {
|
|
8
|
+
const logger = new Logger({ level: 'info' });
|
|
9
|
+
expect(logger).toBeDefined();
|
|
10
|
+
// Check if methods exist (can't easily mock internal pino without spy)
|
|
11
|
+
expect(typeof logger.info).toBe('function');
|
|
12
|
+
expect(typeof logger.error).toBe('function');
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('should be integrated into QHTTPX', () => {
|
|
16
|
+
const app = new QHTTPX();
|
|
17
|
+
expect(app.logger).toBeDefined();
|
|
18
|
+
expect(app.logger instanceof Logger).toBe(true);
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
describe('RPC Client', () => {
|
|
23
|
+
it('should construct correct URLs and methods', async () => {
|
|
24
|
+
// We mock fetch globally
|
|
25
|
+
const originalFetch = global.fetch;
|
|
26
|
+
global.fetch = async (url: RequestInfo | URL, options?: RequestInit) => {
|
|
27
|
+
return new Response(JSON.stringify({ url, method: options?.method }), {
|
|
28
|
+
status: 200,
|
|
29
|
+
headers: { 'Content-Type': 'application/json' },
|
|
30
|
+
});
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
try {
|
|
34
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
35
|
+
const client = hc<any>('http://localhost:3000');
|
|
36
|
+
|
|
37
|
+
const res1 = await client.api.users[':id'].$get({ param: { id: '123' } });
|
|
38
|
+
const data1 = await res1.json();
|
|
39
|
+
expect(data1.url).toBe('http://localhost:3000/api/users/123');
|
|
40
|
+
expect(data1.method).toBe('GET');
|
|
41
|
+
|
|
42
|
+
const res2 = await client.auth.login.$post({ json: { username: 'test' } });
|
|
43
|
+
const data2 = await res2.json();
|
|
44
|
+
expect(data2.url).toBe('http://localhost:3000/auth/login');
|
|
45
|
+
expect(data2.method).toBe('POST');
|
|
46
|
+
|
|
47
|
+
} finally {
|
|
48
|
+
global.fetch = originalFetch;
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
});
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { describe, it, expect, afterEach } from 'vitest';
|
|
2
|
+
import { QHTTPX } from '../src/core/server';
|
|
3
|
+
|
|
4
|
+
describe('Request Fusion Engine', () => {
|
|
5
|
+
let app: QHTTPX;
|
|
6
|
+
|
|
7
|
+
afterEach(async () => {
|
|
8
|
+
if (app) await app.close();
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it('should coalesce simultaneous requests to the same endpoint', async () => {
|
|
12
|
+
app = new QHTTPX({ enableRequestFusion: true });
|
|
13
|
+
|
|
14
|
+
let executionCount = 0;
|
|
15
|
+
|
|
16
|
+
// Simulate a slow handler
|
|
17
|
+
app.get('/slow', async (ctx) => {
|
|
18
|
+
executionCount++;
|
|
19
|
+
await new Promise(resolve => setTimeout(resolve, 300));
|
|
20
|
+
ctx.json({ count: executionCount });
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
const { port } = await app.listen(0);
|
|
24
|
+
const url = `http://localhost:${port}/slow`;
|
|
25
|
+
|
|
26
|
+
// Fire 3 requests simultaneously
|
|
27
|
+
const p1 = fetch(url).then(r => r.json());
|
|
28
|
+
const p2 = fetch(url).then(r => r.json());
|
|
29
|
+
const p3 = fetch(url).then(r => r.json());
|
|
30
|
+
|
|
31
|
+
const results = await Promise.all([p1, p2, p3]);
|
|
32
|
+
|
|
33
|
+
// All should get the same result
|
|
34
|
+
expect(results[0]).toEqual({ count: 1 });
|
|
35
|
+
expect(results[1]).toEqual({ count: 1 });
|
|
36
|
+
expect(results[2]).toEqual({ count: 1 });
|
|
37
|
+
|
|
38
|
+
// Handler should have run only once
|
|
39
|
+
expect(executionCount).toBe(1);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('should NOT coalesce requests with different query params', async () => {
|
|
43
|
+
app = new QHTTPX({ enableRequestFusion: true });
|
|
44
|
+
|
|
45
|
+
let executionCount = 0;
|
|
46
|
+
|
|
47
|
+
app.get('/echo', async (ctx) => {
|
|
48
|
+
executionCount++;
|
|
49
|
+
await new Promise(resolve => setTimeout(resolve, 20));
|
|
50
|
+
ctx.json({ q: ctx.query.q, count: executionCount });
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
const { port } = await app.listen(0);
|
|
54
|
+
|
|
55
|
+
const p1 = fetch(`http://localhost:${port}/echo?q=a`).then(r => r.json());
|
|
56
|
+
const p2 = fetch(`http://localhost:${port}/echo?q=b`).then(r => r.json());
|
|
57
|
+
|
|
58
|
+
const results = await Promise.all([p1, p2]);
|
|
59
|
+
|
|
60
|
+
expect(results[0].q).toBe('a');
|
|
61
|
+
expect(results[1].q).toBe('b');
|
|
62
|
+
|
|
63
|
+
// Should run twice because keys are different
|
|
64
|
+
expect(executionCount).toBe(2);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('should use cache window to coalesce burst traffic', async () => {
|
|
68
|
+
// 50ms window
|
|
69
|
+
app = new QHTTPX({
|
|
70
|
+
enableRequestFusion: { windowMs: 50 }
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
let executionCount = 0;
|
|
74
|
+
|
|
75
|
+
app.get('/burst', async (ctx) => {
|
|
76
|
+
executionCount++;
|
|
77
|
+
// Fast handler
|
|
78
|
+
ctx.json({ count: executionCount });
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
const { port } = await app.listen(0);
|
|
82
|
+
const url = `http://localhost:${port}/burst`;
|
|
83
|
+
|
|
84
|
+
// Request 1
|
|
85
|
+
const r1 = await fetch(url).then(r => r.json());
|
|
86
|
+
expect(r1.count).toBe(1);
|
|
87
|
+
|
|
88
|
+
// Request 2 (immediate, within window)
|
|
89
|
+
const r2 = await fetch(url).then(r => r.json());
|
|
90
|
+
expect(r2.count).toBe(1); // Should be cached
|
|
91
|
+
|
|
92
|
+
// Request 3 (wait 100ms, outside window)
|
|
93
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
94
|
+
const r3 = await fetch(url).then(r => r.json());
|
|
95
|
+
expect(r3.count).toBe(2); // New execution
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('should respect vary headers (Authorization)', async () => {
|
|
99
|
+
app = new QHTTPX({ enableRequestFusion: true });
|
|
100
|
+
|
|
101
|
+
let executionCount = 0;
|
|
102
|
+
|
|
103
|
+
app.get('/auth', async (ctx) => {
|
|
104
|
+
executionCount++;
|
|
105
|
+
await new Promise(resolve => setTimeout(resolve, 20));
|
|
106
|
+
ctx.json({ user: ctx.req.headers['authorization'], count: executionCount });
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
const { port } = await app.listen(0);
|
|
110
|
+
const url = `http://localhost:${port}/auth`;
|
|
111
|
+
|
|
112
|
+
const p1 = fetch(url, { headers: { 'Authorization': 'UserA' } }).then(r => r.json());
|
|
113
|
+
const p2 = fetch(url, { headers: { 'Authorization': 'UserB' } }).then(r => r.json());
|
|
114
|
+
|
|
115
|
+
const results = await Promise.all([p1, p2]);
|
|
116
|
+
|
|
117
|
+
expect(results[0].user).toBe('UserA');
|
|
118
|
+
expect(results[1].user).toBe('UserB');
|
|
119
|
+
expect(executionCount).toBe(2);
|
|
120
|
+
});
|
|
121
|
+
});
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
2
|
+
import { QHTTPX } from '../src/index';
|
|
3
|
+
|
|
4
|
+
describe('QHTTPX minimal HTTP runtime', () => {
|
|
5
|
+
let app: QHTTPX | undefined;
|
|
6
|
+
|
|
7
|
+
afterEach(async () => {
|
|
8
|
+
if (app) {
|
|
9
|
+
await app.close();
|
|
10
|
+
app = undefined;
|
|
11
|
+
}
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('handles GET / and returns JSON', async () => {
|
|
15
|
+
app = new QHTTPX();
|
|
16
|
+
|
|
17
|
+
app.get('/', (ctx) => {
|
|
18
|
+
ctx.json({ message: 'Hello from QHTTPX' });
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
const { port } = await app.listen(0, '127.0.0.1');
|
|
22
|
+
|
|
23
|
+
const response = await fetch(`http://127.0.0.1:${port}/`);
|
|
24
|
+
expect(response.status).toBe(200);
|
|
25
|
+
const json = (await response.json()) as { message: string };
|
|
26
|
+
expect(json.message).toBe('Hello from QHTTPX');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('returns 404 for unknown route', async () => {
|
|
30
|
+
app = new QHTTPX();
|
|
31
|
+
|
|
32
|
+
const { port } = await app.listen(0, '127.0.0.1');
|
|
33
|
+
|
|
34
|
+
const response = await fetch(`http://127.0.0.1:${port}/unknown`);
|
|
35
|
+
expect(response.status).toBe(404);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('supports params and query parsing', async () => {
|
|
39
|
+
app = new QHTTPX();
|
|
40
|
+
|
|
41
|
+
app.get('/users/:id', (ctx) => {
|
|
42
|
+
ctx.json({
|
|
43
|
+
id: ctx.params.id,
|
|
44
|
+
q: ctx.query.q,
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const { port } = await app.listen(0, '127.0.0.1');
|
|
49
|
+
|
|
50
|
+
const response = await fetch(`http://127.0.0.1:${port}/users/123?q=test`);
|
|
51
|
+
expect(response.status).toBe(200);
|
|
52
|
+
const json = (await response.json()) as { id: string; q: string };
|
|
53
|
+
expect(json.id).toBe('123');
|
|
54
|
+
expect(json.q).toBe('test');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('parses JSON body for POST', async () => {
|
|
58
|
+
app = new QHTTPX();
|
|
59
|
+
|
|
60
|
+
app.post('/echo', (ctx) => {
|
|
61
|
+
ctx.json({ body: ctx.body });
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
const { port } = await app.listen(0, '127.0.0.1');
|
|
65
|
+
|
|
66
|
+
const response = await fetch(`http://127.0.0.1:${port}/echo`, {
|
|
67
|
+
method: 'POST',
|
|
68
|
+
headers: {
|
|
69
|
+
'content-type': 'application/json',
|
|
70
|
+
},
|
|
71
|
+
body: JSON.stringify({ hello: 'world' }),
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
expect(response.status).toBe(200);
|
|
75
|
+
const json = (await response.json()) as { body: { hello: string } };
|
|
76
|
+
expect(json.body.hello).toBe('world');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('returns 400 for invalid JSON body', async () => {
|
|
80
|
+
app = new QHTTPX();
|
|
81
|
+
|
|
82
|
+
const { port } = await app.listen(0, '127.0.0.1');
|
|
83
|
+
|
|
84
|
+
app.post('/echo', (ctx) => {
|
|
85
|
+
ctx.json({ body: ctx.body });
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
const response = await fetch(`http://127.0.0.1:${port}/echo`, {
|
|
89
|
+
method: 'POST',
|
|
90
|
+
headers: {
|
|
91
|
+
'content-type': 'application/json',
|
|
92
|
+
},
|
|
93
|
+
body: '{ invalid',
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
expect(response.status).toBe(400);
|
|
97
|
+
const text = await response.text();
|
|
98
|
+
expect(text).toBe('Invalid JSON');
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('enforces maxBodyBytes with 413 status', async () => {
|
|
102
|
+
app = new QHTTPX({ maxBodyBytes: 8 });
|
|
103
|
+
|
|
104
|
+
app.post('/echo', (ctx) => {
|
|
105
|
+
ctx.json({ body: ctx.body });
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
const { port } = await app.listen(0, '127.0.0.1');
|
|
109
|
+
|
|
110
|
+
const response = await fetch(`http://127.0.0.1:${port}/echo`, {
|
|
111
|
+
method: 'POST',
|
|
112
|
+
headers: {
|
|
113
|
+
'content-type': 'application/json',
|
|
114
|
+
},
|
|
115
|
+
body: JSON.stringify({ hello: 'world' }),
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
expect(response.status).toBe(413);
|
|
119
|
+
const text = await response.text();
|
|
120
|
+
expect(text).toBe('Payload Too Large');
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('enforces maxConcurrency with 503 overload', async () => {
|
|
124
|
+
app = new QHTTPX({ maxConcurrency: 1 });
|
|
125
|
+
|
|
126
|
+
app.get('/slow', async (ctx) => {
|
|
127
|
+
await new Promise<void>((resolve) => {
|
|
128
|
+
setTimeout(resolve, 100);
|
|
129
|
+
});
|
|
130
|
+
ctx.json({ ok: true });
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
const { port } = await app.listen(0, '127.0.0.1');
|
|
134
|
+
|
|
135
|
+
const url = `http://127.0.0.1:${port}/slow`;
|
|
136
|
+
|
|
137
|
+
const first = fetch(url);
|
|
138
|
+
const second = fetch(url);
|
|
139
|
+
|
|
140
|
+
const [firstRes, secondRes] = await Promise.all([first, second]);
|
|
141
|
+
|
|
142
|
+
const statuses = [firstRes.status, secondRes.status].sort();
|
|
143
|
+
expect(statuses).toEqual([200, 503]);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('applies request timeout with 504 status', async () => {
|
|
147
|
+
app = new QHTTPX({ requestTimeoutMs: 20 });
|
|
148
|
+
|
|
149
|
+
app.get('/timeout', async (ctx) => {
|
|
150
|
+
await new Promise<void>((resolve) => {
|
|
151
|
+
setTimeout(resolve, 100);
|
|
152
|
+
});
|
|
153
|
+
ctx.json({ ok: true });
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
const { port } = await app.listen(0, '127.0.0.1');
|
|
157
|
+
|
|
158
|
+
const response = await fetch(`http://127.0.0.1:${port}/timeout`);
|
|
159
|
+
expect(response.status).toBe(504);
|
|
160
|
+
});
|
|
161
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { QHTTPX, createLoggerMiddleware } from '../src';
|
|
3
|
+
|
|
4
|
+
describe('Logger middleware', () => {
|
|
5
|
+
let app: QHTTPX | undefined;
|
|
6
|
+
|
|
7
|
+
afterEach(async () => {
|
|
8
|
+
if (app) {
|
|
9
|
+
await app.close();
|
|
10
|
+
app = undefined;
|
|
11
|
+
}
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('logs basic request information to sink', async () => {
|
|
15
|
+
const sink = vi.fn();
|
|
16
|
+
|
|
17
|
+
app = new QHTTPX();
|
|
18
|
+
app.use(
|
|
19
|
+
createLoggerMiddleware({
|
|
20
|
+
sink,
|
|
21
|
+
}),
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
app.get('/hello', (ctx) => {
|
|
25
|
+
ctx.send('ok');
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const { port } = await app.listen(0, '127.0.0.1');
|
|
29
|
+
|
|
30
|
+
const response = await fetch(`http://127.0.0.1:${port}/hello`);
|
|
31
|
+
|
|
32
|
+
expect(response.status).toBe(200);
|
|
33
|
+
expect(await response.text()).toBe('ok');
|
|
34
|
+
|
|
35
|
+
expect(sink).toHaveBeenCalledTimes(1);
|
|
36
|
+
const entry = sink.mock.calls[0][0] as {
|
|
37
|
+
method: string;
|
|
38
|
+
path: string;
|
|
39
|
+
status: number;
|
|
40
|
+
durationMs: number;
|
|
41
|
+
};
|
|
42
|
+
expect(entry.method).toBe('GET');
|
|
43
|
+
expect(entry.path).toBe('/hello');
|
|
44
|
+
expect(entry.status).toBe(200);
|
|
45
|
+
expect(entry.durationMs).toBeGreaterThanOrEqual(0);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|