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,71 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { Scheduler } from '../src/core/scheduler';
|
|
3
|
+
|
|
4
|
+
describe('Scheduler', () => {
|
|
5
|
+
it('runs tasks within concurrency limit', async () => {
|
|
6
|
+
const scheduler = new Scheduler({ maxConcurrency: 4 });
|
|
7
|
+
const order: number[] = [];
|
|
8
|
+
|
|
9
|
+
await Promise.all([
|
|
10
|
+
scheduler.run(
|
|
11
|
+
async () => {
|
|
12
|
+
order.push(1);
|
|
13
|
+
},
|
|
14
|
+
{},
|
|
15
|
+
),
|
|
16
|
+
scheduler.run(
|
|
17
|
+
async () => {
|
|
18
|
+
order.push(2);
|
|
19
|
+
},
|
|
20
|
+
{},
|
|
21
|
+
),
|
|
22
|
+
]);
|
|
23
|
+
|
|
24
|
+
expect(order.sort()).toEqual([1, 2]);
|
|
25
|
+
expect(scheduler.getCurrentInFlight()).toBe(0);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('calls onOverloaded when limit is reached', async () => {
|
|
29
|
+
const scheduler = new Scheduler({ maxConcurrency: 1 });
|
|
30
|
+
const overloaded = vi.fn();
|
|
31
|
+
|
|
32
|
+
let resolveFirst: (() => void) | undefined;
|
|
33
|
+
const firstTask = scheduler.run(
|
|
34
|
+
() =>
|
|
35
|
+
new Promise<void>((resolve) => {
|
|
36
|
+
resolveFirst = resolve;
|
|
37
|
+
}),
|
|
38
|
+
{},
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
await scheduler.run(async () => {}, { onOverloaded: overloaded });
|
|
42
|
+
|
|
43
|
+
expect(overloaded).toHaveBeenCalledTimes(1);
|
|
44
|
+
expect(scheduler.getCurrentInFlight()).toBe(1);
|
|
45
|
+
|
|
46
|
+
resolveFirst?.();
|
|
47
|
+
await firstTask;
|
|
48
|
+
|
|
49
|
+
expect(scheduler.getCurrentInFlight()).toBe(0);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('invokes timeout callback when task exceeds timeout', async () => {
|
|
53
|
+
const scheduler = new Scheduler({ maxConcurrency: 1 });
|
|
54
|
+
const onTimeout = vi.fn();
|
|
55
|
+
|
|
56
|
+
await scheduler.run(
|
|
57
|
+
() =>
|
|
58
|
+
new Promise<void>((resolve) => {
|
|
59
|
+
setTimeout(resolve, 50);
|
|
60
|
+
}),
|
|
61
|
+
{
|
|
62
|
+
timeoutMs: 10,
|
|
63
|
+
onTimeout,
|
|
64
|
+
},
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
expect(onTimeout).toHaveBeenCalledTimes(1);
|
|
68
|
+
expect(scheduler.getCurrentInFlight()).toBe(0);
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
|
|
2
|
+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
|
3
|
+
import { QHTTPX } from '../src/index';
|
|
4
|
+
|
|
5
|
+
describe('Schema-based Routes', () => {
|
|
6
|
+
let app: QHTTPX;
|
|
7
|
+
let port: number;
|
|
8
|
+
|
|
9
|
+
beforeAll(async () => {
|
|
10
|
+
app = new QHTTPX({
|
|
11
|
+
performanceMode: 'ultra'
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
const UserSchema = {
|
|
15
|
+
type: 'object',
|
|
16
|
+
properties: {
|
|
17
|
+
id: { type: 'integer' },
|
|
18
|
+
name: { type: 'string' },
|
|
19
|
+
email: { type: 'string' }
|
|
20
|
+
},
|
|
21
|
+
required: ['id', 'name']
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
app.get('/user', {
|
|
25
|
+
schema: UserSchema,
|
|
26
|
+
handler(ctx) {
|
|
27
|
+
ctx.json({
|
|
28
|
+
id: 1,
|
|
29
|
+
name: 'John Doe',
|
|
30
|
+
email: 'john@example.com',
|
|
31
|
+
extra: 'should be ignored'
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
app.get('/no-schema', (ctx) => {
|
|
37
|
+
ctx.json({ foo: 'bar', run: ctx.state.middlewareRun });
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
app.use(async (ctx, next) => {
|
|
41
|
+
ctx.state.middlewareRun = true;
|
|
42
|
+
await next();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
app.get('/middleware-check', (ctx) => {
|
|
46
|
+
ctx.json({ run: ctx.state.middlewareRun });
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
const result = await app.listen(0);
|
|
50
|
+
port = result.port;
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
afterAll(async () => {
|
|
54
|
+
await app.close();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('should serialize using the provided schema', async () => {
|
|
58
|
+
const response = await fetch(`http://localhost:${port}/user`);
|
|
59
|
+
expect(response.status).toBe(200);
|
|
60
|
+
const body = await response.json();
|
|
61
|
+
|
|
62
|
+
expect(body).toEqual({
|
|
63
|
+
id: 1,
|
|
64
|
+
name: 'John Doe',
|
|
65
|
+
email: 'john@example.com'
|
|
66
|
+
});
|
|
67
|
+
// 'extra' should be missing
|
|
68
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
69
|
+
expect((body as any).extra).toBeUndefined();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('should work without schema', async () => {
|
|
73
|
+
const response = await fetch(`http://localhost:${port}/no-schema`);
|
|
74
|
+
expect(response.status).toBe(200);
|
|
75
|
+
const body = await response.json();
|
|
76
|
+
expect(body).toEqual({ foo: 'bar' });
|
|
77
|
+
// Middleware was added AFTER this route, so it should NOT run
|
|
78
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
79
|
+
expect((body as any).run).toBeUndefined();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('should run middlewares', async () => {
|
|
83
|
+
const response = await fetch(`http://localhost:${port}/middleware-check`);
|
|
84
|
+
expect(response.status).toBe(200);
|
|
85
|
+
const body = await response.json();
|
|
86
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
87
|
+
expect((body as any).run).toBe(true);
|
|
88
|
+
});
|
|
89
|
+
});
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
QHTTPX,
|
|
4
|
+
createRateLimitMiddleware,
|
|
5
|
+
createSecureDefaults,
|
|
6
|
+
createSecurityHeadersMiddleware,
|
|
7
|
+
} from '../src';
|
|
8
|
+
|
|
9
|
+
describe('Security and rate limiting middleware', () => {
|
|
10
|
+
let app: QHTTPX | undefined;
|
|
11
|
+
|
|
12
|
+
afterEach(async () => {
|
|
13
|
+
if (app) {
|
|
14
|
+
await app.close();
|
|
15
|
+
app = undefined;
|
|
16
|
+
}
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('applies default security headers', async () => {
|
|
20
|
+
app = new QHTTPX();
|
|
21
|
+
|
|
22
|
+
app.use(createSecurityHeadersMiddleware());
|
|
23
|
+
|
|
24
|
+
app.get('/ping', (ctx) => {
|
|
25
|
+
ctx.send('pong');
|
|
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}/ping`);
|
|
31
|
+
|
|
32
|
+
expect(response.status).toBe(200);
|
|
33
|
+
expect(await response.text()).toBe('pong');
|
|
34
|
+
|
|
35
|
+
expect(response.headers.get('content-security-policy')).toBe(
|
|
36
|
+
"default-src 'self'",
|
|
37
|
+
);
|
|
38
|
+
expect(response.headers.get('referrer-policy')).toBe('no-referrer');
|
|
39
|
+
expect(response.headers.get('x-frame-options')).toBe('SAMEORIGIN');
|
|
40
|
+
expect(response.headers.get('x-content-type-options')).toBe('nosniff');
|
|
41
|
+
expect(response.headers.get('x-xss-protection')).toBe('1; mode=block');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('allows overriding and disabling specific headers', async () => {
|
|
45
|
+
app = new QHTTPX();
|
|
46
|
+
|
|
47
|
+
app.use(
|
|
48
|
+
createSecurityHeadersMiddleware({
|
|
49
|
+
contentSecurityPolicy: "default-src 'self' https://cdn.example.com",
|
|
50
|
+
referrerPolicy: 'strict-origin-when-cross-origin',
|
|
51
|
+
xFrameOptions: null,
|
|
52
|
+
strictTransportSecurity: 'max-age=31536000; includeSubDomains',
|
|
53
|
+
}),
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
app.get('/ping', (ctx) => {
|
|
57
|
+
ctx.send('pong');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
const { port } = await app.listen(0, '127.0.0.1');
|
|
61
|
+
|
|
62
|
+
const response = await fetch(`http://127.0.0.1:${port}/ping`);
|
|
63
|
+
|
|
64
|
+
expect(response.status).toBe(200);
|
|
65
|
+
|
|
66
|
+
expect(response.headers.get('content-security-policy')).toBe(
|
|
67
|
+
"default-src 'self' https://cdn.example.com",
|
|
68
|
+
);
|
|
69
|
+
expect(response.headers.get('referrer-policy')).toBe(
|
|
70
|
+
'strict-origin-when-cross-origin',
|
|
71
|
+
);
|
|
72
|
+
expect(response.headers.get('x-frame-options')).toBeNull();
|
|
73
|
+
expect(response.headers.get('strict-transport-security')).toBe(
|
|
74
|
+
'max-age=31536000; includeSubDomains',
|
|
75
|
+
);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('provides secure defaults preset combining CORS and headers', async () => {
|
|
79
|
+
app = new QHTTPX();
|
|
80
|
+
|
|
81
|
+
const middlewares = createSecureDefaults();
|
|
82
|
+
for (const mw of middlewares) {
|
|
83
|
+
app.use(mw);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
app.get('/ping', (ctx) => {
|
|
87
|
+
ctx.send('pong');
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
const { port } = await app.listen(0, '127.0.0.1');
|
|
91
|
+
|
|
92
|
+
const response = await fetch(`http://127.0.0.1:${port}/ping`, {
|
|
93
|
+
method: 'GET',
|
|
94
|
+
headers: {
|
|
95
|
+
origin: 'http://example.com',
|
|
96
|
+
},
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
expect(response.status).toBe(200);
|
|
100
|
+
expect(response.headers.get('access-control-allow-origin')).toBe('*');
|
|
101
|
+
expect(response.headers.get('content-security-policy')).toBe(
|
|
102
|
+
"default-src 'self'",
|
|
103
|
+
);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('applies in-memory rate limiting per remote address', async () => {
|
|
107
|
+
app = new QHTTPX();
|
|
108
|
+
|
|
109
|
+
app.use(
|
|
110
|
+
createRateLimitMiddleware({
|
|
111
|
+
maxRequests: 1,
|
|
112
|
+
windowMs: 1000,
|
|
113
|
+
}),
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
app.get('/ping', (ctx) => {
|
|
117
|
+
ctx.send('pong');
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
const { port } = await app.listen(0, '127.0.0.1');
|
|
121
|
+
|
|
122
|
+
const first = await fetch(`http://127.0.0.1:${port}/ping`);
|
|
123
|
+
const second = await fetch(`http://127.0.0.1:${port}/ping`);
|
|
124
|
+
|
|
125
|
+
expect(first.status).toBe(200);
|
|
126
|
+
expect(second.status).toBe(429);
|
|
127
|
+
});
|
|
128
|
+
});
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { QHTTPX } from '../src/core/server';
|
|
3
|
+
import { DatabaseManager } from '../src/database/manager';
|
|
4
|
+
import { MemoryAdapter } from '../src/database/adapters/memory';
|
|
5
|
+
import http from 'http';
|
|
6
|
+
|
|
7
|
+
describe('Server Database Integration', () => {
|
|
8
|
+
let app: QHTTPX;
|
|
9
|
+
let dbManager: DatabaseManager;
|
|
10
|
+
|
|
11
|
+
beforeEach(async () => {
|
|
12
|
+
DatabaseManager.registerAdapter('memory', MemoryAdapter);
|
|
13
|
+
dbManager = new DatabaseManager({
|
|
14
|
+
default: 'main',
|
|
15
|
+
connections: {
|
|
16
|
+
main: {
|
|
17
|
+
type: 'memory',
|
|
18
|
+
database: 'test_db'
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
});
|
|
22
|
+
await dbManager.connect();
|
|
23
|
+
|
|
24
|
+
app = new QHTTPX({
|
|
25
|
+
database: dbManager
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
afterEach(async () => {
|
|
30
|
+
await app.close();
|
|
31
|
+
await dbManager.disconnect();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('should expose database manager in context', async () => {
|
|
35
|
+
return new Promise<void>(async (resolve, reject) => {
|
|
36
|
+
app.get('/db-test', (ctx) => {
|
|
37
|
+
try {
|
|
38
|
+
expect(ctx.db).toBeDefined();
|
|
39
|
+
expect(ctx.db).toBe(dbManager);
|
|
40
|
+
expect(ctx.db?.get('main').isConnected()).toBe(true);
|
|
41
|
+
ctx.json({ status: 'ok' });
|
|
42
|
+
} catch (error) {
|
|
43
|
+
// If expect fails, we need to make sure the test fails
|
|
44
|
+
// But throwing here might be caught by the server error handler
|
|
45
|
+
// So we set a flag or just let it fail naturally if vitest handles async exceptions
|
|
46
|
+
// For now, let's reject the promise
|
|
47
|
+
reject(error);
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
const { port } = await app.listen(0);
|
|
53
|
+
|
|
54
|
+
const req = http.get(`http://localhost:${port}/db-test`, (res) => {
|
|
55
|
+
if (res.statusCode !== 200) {
|
|
56
|
+
reject(new Error(`Status code: ${res.statusCode}`));
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
res.on('data', () => {});
|
|
60
|
+
res.on('end', () => {
|
|
61
|
+
resolve();
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
req.on('error', (err) => {
|
|
65
|
+
reject(err);
|
|
66
|
+
});
|
|
67
|
+
} catch (err) {
|
|
68
|
+
reject(err);
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
});
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { QHTTPX } from '../src/core/server';
|
|
3
|
+
import { DatabaseManager } from '../src/database/manager';
|
|
4
|
+
import { SQLiteAdapter } from '../src/database/adapters/sqlite';
|
|
5
|
+
|
|
6
|
+
describe('SQLite Query Fusion', () => {
|
|
7
|
+
let app: QHTTPX;
|
|
8
|
+
let dbManager: DatabaseManager;
|
|
9
|
+
|
|
10
|
+
beforeEach(async () => {
|
|
11
|
+
// Register SQLite adapter
|
|
12
|
+
DatabaseManager.registerAdapter('sqlite', SQLiteAdapter);
|
|
13
|
+
|
|
14
|
+
// Use in-memory SQLite DB
|
|
15
|
+
dbManager = new DatabaseManager({
|
|
16
|
+
default: 'main',
|
|
17
|
+
connections: {
|
|
18
|
+
main: { type: 'sqlite', database: ':memory:' }
|
|
19
|
+
}
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
await dbManager.connect();
|
|
23
|
+
|
|
24
|
+
// Setup schema and data
|
|
25
|
+
const adapter = dbManager.get('main');
|
|
26
|
+
await adapter.query('CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)');
|
|
27
|
+
await adapter.query("INSERT INTO users (id, name) VALUES (1, 'Alice')");
|
|
28
|
+
await adapter.query("INSERT INTO users (id, name) VALUES (2, 'Bob')");
|
|
29
|
+
await adapter.query("INSERT INTO users (id, name) VALUES (3, 'Charlie')");
|
|
30
|
+
|
|
31
|
+
app = new QHTTPX({
|
|
32
|
+
database: dbManager,
|
|
33
|
+
enableBatching: true
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
afterEach(async () => {
|
|
38
|
+
if (app) await app.close();
|
|
39
|
+
if (dbManager) await dbManager.disconnect();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('should fuse SELECT queries with SQLite', async () => {
|
|
43
|
+
// Wrap the adapter to spy on queries
|
|
44
|
+
const realAdapter = dbManager.get('main');
|
|
45
|
+
const originalQuery = realAdapter.query.bind(realAdapter);
|
|
46
|
+
|
|
47
|
+
const queries: string[] = [];
|
|
48
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
49
|
+
realAdapter.query = async (sql: string, params?: any[]) => {
|
|
50
|
+
queries.push(sql);
|
|
51
|
+
return originalQuery(sql, params);
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
app.op('getUser', async (params, ctx) => {
|
|
55
|
+
// This matches the coalescer pattern: "SELECT ... WHERE id = ?"
|
|
56
|
+
if (!ctx.db) throw new Error('No DB');
|
|
57
|
+
const rows = await ctx.db.get().query('SELECT * FROM users WHERE id = ?', [params.id]);
|
|
58
|
+
return rows[0];
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const { port } = await app.listen(0);
|
|
62
|
+
|
|
63
|
+
const batch = {
|
|
64
|
+
batch: [
|
|
65
|
+
{ op: 'getUser', params: { id: 1 }, id: 1 },
|
|
66
|
+
{ op: 'getUser', params: { id: 2 }, id: 2 },
|
|
67
|
+
{ op: 'getUser', params: { id: 3 }, id: 3 }
|
|
68
|
+
]
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const response = await fetch(`http://localhost:${port}/qhttpx`, {
|
|
72
|
+
method: 'POST',
|
|
73
|
+
headers: { 'Content-Type': 'application/json' },
|
|
74
|
+
body: JSON.stringify(batch)
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
78
|
+
const data = await response.json() as any;
|
|
79
|
+
|
|
80
|
+
// Verify results
|
|
81
|
+
expect(data.results).toHaveLength(3);
|
|
82
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
83
|
+
const r1 = data.results.find((r: any) => r.id === 1);
|
|
84
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
85
|
+
const r2 = data.results.find((r: any) => r.id === 2);
|
|
86
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
87
|
+
const r3 = data.results.find((r: any) => r.id === 3);
|
|
88
|
+
|
|
89
|
+
expect(r1.result).toEqual({ id: 1, name: 'Alice' });
|
|
90
|
+
expect(r2.result).toEqual({ id: 2, name: 'Bob' });
|
|
91
|
+
expect(r3.result).toEqual({ id: 3, name: 'Charlie' });
|
|
92
|
+
|
|
93
|
+
// Verify FUSION
|
|
94
|
+
const selectQueries = queries.filter(q => q.trim().toUpperCase().startsWith('SELECT'));
|
|
95
|
+
|
|
96
|
+
// It should contain an IN clause
|
|
97
|
+
const fusedQuery = selectQueries.find(q => q.includes('IN ('));
|
|
98
|
+
expect(fusedQuery).toBeDefined();
|
|
99
|
+
|
|
100
|
+
// Ensure we didn't run 3 individual queries
|
|
101
|
+
// The spy catches the FUSED query, but NOT the individual ones if they were fused.
|
|
102
|
+
// If fusion FAILED, we would see 3 queries with "= ?"
|
|
103
|
+
const individualQueries = selectQueries.filter(q => q.includes('= ?'));
|
|
104
|
+
expect(individualQueries).toHaveLength(0);
|
|
105
|
+
});
|
|
106
|
+
});
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { describe, expect, it, beforeAll, afterAll } from 'vitest';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import fs from 'fs';
|
|
4
|
+
// import http from 'http';
|
|
5
|
+
import { QHTTPX } from '../src/core/server';
|
|
6
|
+
import { createStaticMiddleware } from '../src/middleware/static';
|
|
7
|
+
|
|
8
|
+
const TEST_DIR = path.join(__dirname, 'fixtures');
|
|
9
|
+
const TEST_FILE = path.join(TEST_DIR, 'test.txt');
|
|
10
|
+
const TEST_CONTENT = 'Hello World! This is a test file for QHTTPX static middleware.';
|
|
11
|
+
|
|
12
|
+
describe('static middleware', () => {
|
|
13
|
+
let app: QHTTPX;
|
|
14
|
+
let serverUrl: string;
|
|
15
|
+
|
|
16
|
+
beforeAll(async () => {
|
|
17
|
+
// Create fixtures
|
|
18
|
+
if (!fs.existsSync(TEST_DIR)) fs.mkdirSync(TEST_DIR);
|
|
19
|
+
fs.writeFileSync(TEST_FILE, TEST_CONTENT);
|
|
20
|
+
|
|
21
|
+
app = new QHTTPX();
|
|
22
|
+
app.use(createStaticMiddleware({ root: TEST_DIR }));
|
|
23
|
+
|
|
24
|
+
const { port } = await app.listen(0, '127.0.0.1');
|
|
25
|
+
serverUrl = `http://127.0.0.1:${port}`;
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
afterAll(async () => {
|
|
29
|
+
await app.close();
|
|
30
|
+
// Cleanup
|
|
31
|
+
if (fs.existsSync(TEST_FILE)) fs.unlinkSync(TEST_FILE);
|
|
32
|
+
if (fs.existsSync(TEST_DIR)) fs.rmdirSync(TEST_DIR);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('should serve a static file', async () => {
|
|
36
|
+
const response = await fetch(`${serverUrl}/test.txt`);
|
|
37
|
+
expect(response.status).toBe(200);
|
|
38
|
+
expect(response.headers.get('content-type')).toContain('text/plain');
|
|
39
|
+
const text = await response.text();
|
|
40
|
+
expect(text).toBe(TEST_CONTENT);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('should support HEAD requests', async () => {
|
|
44
|
+
const response = await fetch(`${serverUrl}/test.txt`, { method: 'HEAD' });
|
|
45
|
+
expect(response.status).toBe(200);
|
|
46
|
+
expect(response.headers.get('content-type')).toContain('text/plain');
|
|
47
|
+
expect(response.headers.get('content-length')).toBe(TEST_CONTENT.length.toString());
|
|
48
|
+
const text = await response.text();
|
|
49
|
+
expect(text).toBe('');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('should support Range requests (first byte)', async () => {
|
|
53
|
+
const response = await fetch(`${serverUrl}/test.txt`, {
|
|
54
|
+
headers: { Range: 'bytes=0-0' }
|
|
55
|
+
});
|
|
56
|
+
expect(response.status).toBe(206);
|
|
57
|
+
expect(response.headers.get('content-range')).toBe(`bytes 0-0/${TEST_CONTENT.length}`);
|
|
58
|
+
expect(response.headers.get('content-length')).toBe('1');
|
|
59
|
+
const text = await response.text();
|
|
60
|
+
expect(text).toBe('H');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('should support Range requests (partial)', async () => {
|
|
64
|
+
const response = await fetch(`${serverUrl}/test.txt`, {
|
|
65
|
+
headers: { Range: 'bytes=0-4' }
|
|
66
|
+
});
|
|
67
|
+
expect(response.status).toBe(206);
|
|
68
|
+
expect(response.headers.get('content-range')).toBe(`bytes 0-4/${TEST_CONTENT.length}`);
|
|
69
|
+
expect(response.headers.get('content-length')).toBe('5');
|
|
70
|
+
const text = await response.text();
|
|
71
|
+
expect(text).toBe('Hello');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('should return 416 for invalid Range', async () => {
|
|
75
|
+
const response = await fetch(`${serverUrl}/test.txt`, {
|
|
76
|
+
headers: { Range: 'bytes=1000-2000' }
|
|
77
|
+
});
|
|
78
|
+
expect(response.status).toBe(416);
|
|
79
|
+
expect(response.headers.get('content-range')).toBe(`bytes */${TEST_CONTENT.length}`);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('should support ETag caching (304 Not Modified)', async () => {
|
|
83
|
+
const response1 = await fetch(`${serverUrl}/test.txt`);
|
|
84
|
+
const etag = response1.headers.get('etag');
|
|
85
|
+
expect(etag).toBeTruthy();
|
|
86
|
+
|
|
87
|
+
const response2 = await fetch(`${serverUrl}/test.txt`, {
|
|
88
|
+
headers: { 'If-None-Match': etag! }
|
|
89
|
+
});
|
|
90
|
+
expect(response2.status).toBe(304);
|
|
91
|
+
const text = await response2.text();
|
|
92
|
+
expect(text).toBe('');
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('should support Last-Modified caching (304 Not Modified)', async () => {
|
|
96
|
+
const response1 = await fetch(`${serverUrl}/test.txt`);
|
|
97
|
+
const lastModified = response1.headers.get('last-modified');
|
|
98
|
+
expect(lastModified).toBeTruthy();
|
|
99
|
+
|
|
100
|
+
const response2 = await fetch(`${serverUrl}/test.txt`, {
|
|
101
|
+
headers: { 'If-Modified-Since': lastModified! }
|
|
102
|
+
});
|
|
103
|
+
expect(response2.status).toBe(304);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('should return 404 for non-existent file', async () => {
|
|
107
|
+
const response = await fetch(`${serverUrl}/does-not-exist.txt`);
|
|
108
|
+
expect(response.status).toBe(404);
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { Readable } from 'stream';
|
|
2
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
3
|
+
import { QHTTPX, createSseStream, sendStream } from '../src';
|
|
4
|
+
|
|
5
|
+
describe('Streaming helpers', () => {
|
|
6
|
+
let app: QHTTPX | undefined;
|
|
7
|
+
|
|
8
|
+
afterEach(async () => {
|
|
9
|
+
if (app) {
|
|
10
|
+
await app.close();
|
|
11
|
+
app = undefined;
|
|
12
|
+
}
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('sends a simple SSE event', async () => {
|
|
16
|
+
app = new QHTTPX({ requestTimeoutMs: 5000 });
|
|
17
|
+
|
|
18
|
+
app.get('/sse', (ctx) => {
|
|
19
|
+
const sse = createSseStream(ctx);
|
|
20
|
+
sse.send({ message: 'hello' });
|
|
21
|
+
sse.close();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const { port } = await app.listen(0, '127.0.0.1');
|
|
25
|
+
|
|
26
|
+
const response = await fetch(`http://127.0.0.1:${port}/sse`);
|
|
27
|
+
|
|
28
|
+
expect(response.status).toBe(200);
|
|
29
|
+
expect(
|
|
30
|
+
response.headers.get('content-type'),
|
|
31
|
+
).toContain('text/event-stream');
|
|
32
|
+
|
|
33
|
+
const text = await response.text();
|
|
34
|
+
expect(text.trim()).toBe('data: {"message":"hello"}');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('streams data from a readable', async () => {
|
|
38
|
+
app = new QHTTPX();
|
|
39
|
+
|
|
40
|
+
app.get('/stream', async (ctx) => {
|
|
41
|
+
const readable = Readable.from(['hello', ' ', 'world']);
|
|
42
|
+
await sendStream(ctx, readable, {
|
|
43
|
+
contentType: 'text/plain; charset=utf-8',
|
|
44
|
+
status: 200,
|
|
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}/stream`);
|
|
51
|
+
|
|
52
|
+
expect(response.status).toBe(200);
|
|
53
|
+
expect(response.headers.get('content-type')).toContain('text/plain');
|
|
54
|
+
const text = await response.text();
|
|
55
|
+
expect(text).toBe('hello world');
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|