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,78 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
2
|
+
import { QHTTPX } from '../src/index';
|
|
3
|
+
|
|
4
|
+
type TaskMetricsJson = {
|
|
5
|
+
tasks: {
|
|
6
|
+
registeredTasks: number;
|
|
7
|
+
totalEnqueued: number;
|
|
8
|
+
totalCompleted: number;
|
|
9
|
+
totalFailed: number;
|
|
10
|
+
totalOverloaded: number;
|
|
11
|
+
totalRetried?: number;
|
|
12
|
+
};
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
describe('Task Metrics', () => {
|
|
16
|
+
let app: QHTTPX | undefined;
|
|
17
|
+
|
|
18
|
+
afterEach(async () => {
|
|
19
|
+
if (app) {
|
|
20
|
+
await app.close();
|
|
21
|
+
app = undefined;
|
|
22
|
+
}
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('exposes task metrics in /__qhttpx/metrics', async () => {
|
|
26
|
+
app = new QHTTPX();
|
|
27
|
+
|
|
28
|
+
// Register a simple task
|
|
29
|
+
app.task('simple-task', async () => {
|
|
30
|
+
// do nothing
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const { port } = await app.listen(0, '127.0.0.1');
|
|
34
|
+
|
|
35
|
+
// Enqueue the task a few times
|
|
36
|
+
await app.enqueue('simple-task', {});
|
|
37
|
+
await app.enqueue('simple-task', {});
|
|
38
|
+
|
|
39
|
+
// Allow some time for async tasks to complete
|
|
40
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
41
|
+
|
|
42
|
+
const response = await fetch(`http://127.0.0.1:${port}/__qhttpx/metrics`);
|
|
43
|
+
expect(response.status).toBe(200);
|
|
44
|
+
|
|
45
|
+
const json = (await response.json()) as TaskMetricsJson;
|
|
46
|
+
|
|
47
|
+
expect(json.tasks).toBeDefined();
|
|
48
|
+
expect(json.tasks.registeredTasks).toBe(1);
|
|
49
|
+
expect(json.tasks.totalEnqueued).toBe(2);
|
|
50
|
+
expect(json.tasks.totalCompleted).toBe(2);
|
|
51
|
+
expect(json.tasks.totalFailed).toBe(0);
|
|
52
|
+
expect(json.tasks.totalOverloaded).toBe(0);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('counts failed tasks', async () => {
|
|
56
|
+
app = new QHTTPX();
|
|
57
|
+
|
|
58
|
+
app.task('failing-task', async () => {
|
|
59
|
+
throw new Error('Task failed');
|
|
60
|
+
}, { maxRetries: 1 }); // 1 retry
|
|
61
|
+
|
|
62
|
+
const { port } = await app.listen(0, '127.0.0.1');
|
|
63
|
+
|
|
64
|
+
try {
|
|
65
|
+
await app.enqueue('failing-task', {});
|
|
66
|
+
} catch {
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Allow time for retry and failure
|
|
70
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
71
|
+
|
|
72
|
+
const response = await fetch(`http://127.0.0.1:${port}/__qhttpx/metrics`);
|
|
73
|
+
const json = (await response.json()) as TaskMetricsJson;
|
|
74
|
+
|
|
75
|
+
expect(json.tasks.totalFailed).toBe(1);
|
|
76
|
+
expect(json.tasks.totalRetried).toBe(1); // 1 retry attempt
|
|
77
|
+
});
|
|
78
|
+
});
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { QHTTPX } from '../src/index';
|
|
3
|
+
|
|
4
|
+
describe('Task engine', () => {
|
|
5
|
+
it('registers and executes a task', async () => {
|
|
6
|
+
const app = new QHTTPX();
|
|
7
|
+
|
|
8
|
+
const seen: unknown[] = [];
|
|
9
|
+
|
|
10
|
+
app.task('echo', async (payload) => {
|
|
11
|
+
seen.push(payload);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
await app.enqueue('echo', { hello: 'world' });
|
|
15
|
+
|
|
16
|
+
expect(seen).toHaveLength(1);
|
|
17
|
+
expect(seen[0]).toEqual({ hello: 'world' });
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('retries failing tasks according to maxRetries', async () => {
|
|
21
|
+
const app = new QHTTPX();
|
|
22
|
+
|
|
23
|
+
const spy = vi.fn();
|
|
24
|
+
let attempts = 0;
|
|
25
|
+
|
|
26
|
+
app.task(
|
|
27
|
+
'sometimes-fails',
|
|
28
|
+
() => {
|
|
29
|
+
spy();
|
|
30
|
+
attempts += 1;
|
|
31
|
+
if (attempts < 3) {
|
|
32
|
+
throw new Error('fail');
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
{ maxRetries: 5 },
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
await app.enqueue('sometimes-fails', null);
|
|
39
|
+
|
|
40
|
+
expect(spy).toHaveBeenCalledTimes(3);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('propagates errors after exceeding retries', async () => {
|
|
44
|
+
const app = new QHTTPX();
|
|
45
|
+
|
|
46
|
+
app.task(
|
|
47
|
+
'always-fails',
|
|
48
|
+
() => {
|
|
49
|
+
throw new Error('boom');
|
|
50
|
+
},
|
|
51
|
+
{ maxRetries: 1 },
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
await expect(app.enqueue('always-fails', null)).rejects.toThrow('boom');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('integrates with HTTP route that enqueues a job', async () => {
|
|
58
|
+
const app = new QHTTPX();
|
|
59
|
+
const seen: string[] = [];
|
|
60
|
+
|
|
61
|
+
app.task('job', async (payload) => {
|
|
62
|
+
seen.push(payload as string);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
app.post('/enqueue', async (ctx) => {
|
|
66
|
+
const body = ctx.body as { value: string };
|
|
67
|
+
await app.enqueue('job', body.value);
|
|
68
|
+
ctx.json({ ok: true });
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
const { port } = await app.listen(0, '127.0.0.1');
|
|
72
|
+
|
|
73
|
+
const response = await fetch(`http://127.0.0.1:${port}/enqueue`, {
|
|
74
|
+
method: 'POST',
|
|
75
|
+
headers: {
|
|
76
|
+
'content-type': 'application/json',
|
|
77
|
+
},
|
|
78
|
+
body: JSON.stringify({ value: 'hello' }),
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
expect(response.status).toBe(200);
|
|
82
|
+
const json = (await response.json()) as { ok: boolean };
|
|
83
|
+
expect(json.ok).toBe(true);
|
|
84
|
+
|
|
85
|
+
expect(seen).toEqual(['hello']);
|
|
86
|
+
|
|
87
|
+
await app.close();
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { QHTTPX, createTestClient } from '../src';
|
|
3
|
+
|
|
4
|
+
describe('Testing utilities', () => {
|
|
5
|
+
it('creates a test client and handles requests', async () => {
|
|
6
|
+
const app = new QHTTPX();
|
|
7
|
+
|
|
8
|
+
app.get('/hello', (ctx) => {
|
|
9
|
+
ctx.json({ message: 'world' });
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
app.post('/echo', (ctx) => {
|
|
13
|
+
ctx.json(ctx.body);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
const client = createTestClient(app);
|
|
17
|
+
|
|
18
|
+
try {
|
|
19
|
+
const res1 = await client.get('/hello');
|
|
20
|
+
expect(res1.status).toBe(200);
|
|
21
|
+
expect(await res1.json()).toEqual({ message: 'world' });
|
|
22
|
+
|
|
23
|
+
const res2 = await client.post('/echo', { foo: 'bar' });
|
|
24
|
+
expect(res2.status).toBe(200);
|
|
25
|
+
expect(await res2.json()).toEqual({ foo: 'bar' });
|
|
26
|
+
} finally {
|
|
27
|
+
await client.stop();
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('handles custom headers and raw body', async () => {
|
|
32
|
+
const app = new QHTTPX();
|
|
33
|
+
|
|
34
|
+
app.post('/raw', (ctx) => {
|
|
35
|
+
ctx.send(ctx.req.headers['x-custom'] as string);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
const client = createTestClient(app);
|
|
39
|
+
|
|
40
|
+
try {
|
|
41
|
+
const res = await client.post('/raw', 'raw-body', {
|
|
42
|
+
headers: {
|
|
43
|
+
'x-custom': 'custom-value',
|
|
44
|
+
'content-type': 'text/plain',
|
|
45
|
+
},
|
|
46
|
+
});
|
|
47
|
+
expect(res.status).toBe(200);
|
|
48
|
+
expect(await res.text()).toBe('custom-value');
|
|
49
|
+
} finally {
|
|
50
|
+
await client.stop();
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
});
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { describe, it, expect, afterEach } from 'vitest';
|
|
2
|
+
import { QHTTPX } from '../src/core/server';
|
|
3
|
+
import { RouteSchema } from '../src/validation/types';
|
|
4
|
+
|
|
5
|
+
describe('Validation System', () => {
|
|
6
|
+
let app: QHTTPX;
|
|
7
|
+
|
|
8
|
+
afterEach(async () => {
|
|
9
|
+
if (app) await app.close();
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it('should validate request body and return 400 on failure', async () => {
|
|
13
|
+
app = new QHTTPX();
|
|
14
|
+
|
|
15
|
+
const schema: RouteSchema = {
|
|
16
|
+
body: {
|
|
17
|
+
type: 'object',
|
|
18
|
+
properties: {
|
|
19
|
+
name: { type: 'string', min: 3 },
|
|
20
|
+
age: { type: 'number', min: 18 }
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
app.post('/user', {
|
|
26
|
+
schema,
|
|
27
|
+
handler: (ctx: import('../src/core/types').QHTTPXContext) => {
|
|
28
|
+
ctx.json({ status: 'ok', body: ctx.body });
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
const { port } = await app.listen(0);
|
|
33
|
+
|
|
34
|
+
// Invalid request (age too low)
|
|
35
|
+
const res1 = await fetch(`http://localhost:${port}/user`, {
|
|
36
|
+
method: 'POST',
|
|
37
|
+
headers: { 'Content-Type': 'application/json' },
|
|
38
|
+
body: JSON.stringify({ name: 'Alice', age: 10 })
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
expect(res1.status).toBe(400);
|
|
42
|
+
const data1 = await res1.json();
|
|
43
|
+
expect(data1.error).toBe('Validation Error');
|
|
44
|
+
expect(data1.details).toContain('age');
|
|
45
|
+
|
|
46
|
+
// Valid request
|
|
47
|
+
const res2 = await fetch(`http://localhost:${port}/user`, {
|
|
48
|
+
method: 'POST',
|
|
49
|
+
headers: { 'Content-Type': 'application/json' },
|
|
50
|
+
body: JSON.stringify({ name: 'Alice', age: 20 })
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
expect(res2.status).toBe(200);
|
|
54
|
+
const data2 = await res2.json();
|
|
55
|
+
expect(data2.body).toEqual({ name: 'Alice', age: 20 });
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('should validate and coerce query parameters', async () => {
|
|
59
|
+
app = new QHTTPX();
|
|
60
|
+
|
|
61
|
+
const schema: RouteSchema = {
|
|
62
|
+
query: {
|
|
63
|
+
type: 'object',
|
|
64
|
+
properties: {
|
|
65
|
+
page: { type: 'number', min: 1 },
|
|
66
|
+
sort: { type: 'string', enum: ['asc', 'desc'] }
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
app.get('/items', {
|
|
72
|
+
schema,
|
|
73
|
+
handler: (ctx: import('../src/core/types').QHTTPXContext) => {
|
|
74
|
+
// ctx.query should have numbers now
|
|
75
|
+
ctx.json({
|
|
76
|
+
page: ctx.query.page,
|
|
77
|
+
pageType: typeof ctx.query.page,
|
|
78
|
+
sort: ctx.query.sort
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
const { port } = await app.listen(0);
|
|
84
|
+
|
|
85
|
+
// Valid query
|
|
86
|
+
const res1 = await fetch(`http://localhost:${port}/items?page=5&sort=desc`);
|
|
87
|
+
expect(res1.status).toBe(200);
|
|
88
|
+
const data1 = await res1.json();
|
|
89
|
+
expect(data1.page).toBe(5);
|
|
90
|
+
expect(data1.pageType).toBe('number'); // Coercion worked
|
|
91
|
+
expect(data1.sort).toBe('desc');
|
|
92
|
+
|
|
93
|
+
// Invalid query
|
|
94
|
+
const res2 = await fetch(`http://localhost:${port}/items?page=0&sort=foo`);
|
|
95
|
+
expect(res2.status).toBe(400);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('should support legacy response schema (no validation)', async () => {
|
|
99
|
+
app = new QHTTPX();
|
|
100
|
+
|
|
101
|
+
// Legacy schema: just a JSON schema at root
|
|
102
|
+
const schema = {
|
|
103
|
+
type: 'object',
|
|
104
|
+
properties: {
|
|
105
|
+
hello: { type: 'string' }
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
app.get('/legacy', {
|
|
110
|
+
schema,
|
|
111
|
+
handler: (ctx: import('../src/core/types').QHTTPXContext) => {
|
|
112
|
+
// If validation ran, it might fail because ctx.body is undefined or not matching
|
|
113
|
+
// But here we check if it IGNORES validation and uses it for serialization
|
|
114
|
+
ctx.json({ hello: 'world', extra: 'hidden' });
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
const { port } = await app.listen(0);
|
|
119
|
+
|
|
120
|
+
const res = await fetch(`http://localhost:${port}/legacy`);
|
|
121
|
+
expect(res.status).toBe(200);
|
|
122
|
+
const data = await res.json();
|
|
123
|
+
expect(data.hello).toBe('world');
|
|
124
|
+
expect(data.extra).toBeUndefined(); // fast-json-stringify filters extra fields
|
|
125
|
+
});
|
|
126
|
+
});
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import * as net from 'net';
|
|
3
|
+
import WebSocket from 'ws';
|
|
4
|
+
import { QHTTPX } from '../src';
|
|
5
|
+
|
|
6
|
+
describe('WebSocket upgrade', () => {
|
|
7
|
+
it('handles websocket upgrade for registered path', async () => {
|
|
8
|
+
const app = new QHTTPX();
|
|
9
|
+
let upgraded = false;
|
|
10
|
+
|
|
11
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
12
|
+
app.upgrade('/ws', (ws, req) => {
|
|
13
|
+
upgraded = true;
|
|
14
|
+
ws.close();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
const { port } = await app.listen(0, '127.0.0.1');
|
|
18
|
+
|
|
19
|
+
const ws = new WebSocket(`ws://127.0.0.1:${port}/ws`);
|
|
20
|
+
|
|
21
|
+
await new Promise<void>((resolve, reject) => {
|
|
22
|
+
ws.on('open', () => {
|
|
23
|
+
resolve();
|
|
24
|
+
});
|
|
25
|
+
ws.on('error', (err) => {
|
|
26
|
+
reject(err);
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
expect(upgraded).toBe(true);
|
|
31
|
+
ws.close();
|
|
32
|
+
await app.close();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('destroys socket when no upgrade handler is registered for path', async () => {
|
|
36
|
+
const app = new QHTTPX();
|
|
37
|
+
const { port } = await app.listen(0, '127.0.0.1');
|
|
38
|
+
|
|
39
|
+
const socket = net.createConnection({ port, host: '127.0.0.1' });
|
|
40
|
+
|
|
41
|
+
const done = new Promise<void>((resolve) => {
|
|
42
|
+
let finished = false;
|
|
43
|
+
const finish = () => {
|
|
44
|
+
if (!finished) {
|
|
45
|
+
finished = true;
|
|
46
|
+
resolve();
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
socket.on('end', finish);
|
|
50
|
+
socket.on('close', finish);
|
|
51
|
+
socket.on('error', () => {
|
|
52
|
+
finish();
|
|
53
|
+
});
|
|
54
|
+
setTimeout(finish, 500);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
const requestLines = [
|
|
58
|
+
'GET /no-ws HTTP/1.1',
|
|
59
|
+
'Host: 127.0.0.1',
|
|
60
|
+
'Upgrade: websocket',
|
|
61
|
+
'Connection: Upgrade',
|
|
62
|
+
'',
|
|
63
|
+
'',
|
|
64
|
+
];
|
|
65
|
+
|
|
66
|
+
socket.write(requestLines.join('\r\n'));
|
|
67
|
+
|
|
68
|
+
await done;
|
|
69
|
+
await app.close();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('supports rooms and broadcasting', async () => {
|
|
73
|
+
const app = new QHTTPX();
|
|
74
|
+
|
|
75
|
+
app.upgrade('/chat', (ws, req) => {
|
|
76
|
+
const url = new URL(req.url || '', 'http://localhost');
|
|
77
|
+
const room = url.searchParams.get('room');
|
|
78
|
+
if (room) {
|
|
79
|
+
ws.join(room);
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
app.post('/broadcast', (ctx) => {
|
|
84
|
+
const { room, message } = ctx.body as { room: string; message: string };
|
|
85
|
+
app.websocket.to(room).emit(message);
|
|
86
|
+
ctx.json({ success: true });
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
const { port } = await app.listen(0, '127.0.0.1');
|
|
90
|
+
const baseUrl = `ws://127.0.0.1:${port}/chat`;
|
|
91
|
+
|
|
92
|
+
// Client A in room1
|
|
93
|
+
const wsA = new WebSocket(`${baseUrl}?room=room1`);
|
|
94
|
+
const msgsA: string[] = [];
|
|
95
|
+
wsA.on('message', (data) => msgsA.push(data.toString()));
|
|
96
|
+
|
|
97
|
+
// Client B in room1
|
|
98
|
+
const wsB = new WebSocket(`${baseUrl}?room=room1`);
|
|
99
|
+
const msgsB: string[] = [];
|
|
100
|
+
wsB.on('message', (data) => msgsB.push(data.toString()));
|
|
101
|
+
|
|
102
|
+
// Client C in room2
|
|
103
|
+
const wsC = new WebSocket(`${baseUrl}?room=room2`);
|
|
104
|
+
const msgsC: string[] = [];
|
|
105
|
+
wsC.on('message', (data) => msgsC.push(data.toString()));
|
|
106
|
+
|
|
107
|
+
await Promise.all([
|
|
108
|
+
new Promise<void>((resolve) => wsA.on('open', () => resolve())),
|
|
109
|
+
new Promise<void>((resolve) => wsB.on('open', () => resolve())),
|
|
110
|
+
new Promise<void>((resolve) => wsC.on('open', () => resolve())),
|
|
111
|
+
]);
|
|
112
|
+
|
|
113
|
+
// Broadcast to room1
|
|
114
|
+
// We need to make a POST request.
|
|
115
|
+
// Since we are inside test, we can use fetch or http.request.
|
|
116
|
+
// Or just use app.websocket directly if we can access it?
|
|
117
|
+
// We can access app.websocket directly here since we have app instance.
|
|
118
|
+
app.websocket.to('room1').emit('hello room1');
|
|
119
|
+
|
|
120
|
+
// Wait a bit for messages
|
|
121
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
122
|
+
|
|
123
|
+
expect(msgsA).toContain('hello room1');
|
|
124
|
+
expect(msgsB).toContain('hello room1');
|
|
125
|
+
expect(msgsC).not.toContain('hello room1');
|
|
126
|
+
|
|
127
|
+
wsA.close();
|
|
128
|
+
wsB.close();
|
|
129
|
+
wsC.close();
|
|
130
|
+
await app.close();
|
|
131
|
+
});
|
|
132
|
+
});
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2020",
|
|
4
|
+
"module": "commonjs",
|
|
5
|
+
"moduleResolution": "node",
|
|
6
|
+
"rootDir": ".",
|
|
7
|
+
"outDir": "dist",
|
|
8
|
+
"strict": true,
|
|
9
|
+
"esModuleInterop": true,
|
|
10
|
+
"forceConsistentCasingInFileNames": true,
|
|
11
|
+
"skipLibCheck": true,
|
|
12
|
+
"resolveJsonModule": true,
|
|
13
|
+
"types": ["node", "vitest"]
|
|
14
|
+
},
|
|
15
|
+
"include": ["src", "tests", "vitest.config.*"]
|
|
16
|
+
}
|