@bhooai/nexus-core 0.1.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/README.md +35 -0
- package/package.json +38 -0
- package/src/config/ConfigLoader.ts +161 -0
- package/src/config/defaults.ts +155 -0
- package/src/config/env.ts +113 -0
- package/src/config/index.ts +6 -0
- package/src/config/merge.ts +28 -0
- package/src/config/schema.ts +178 -0
- package/src/config/types.ts +342 -0
- package/src/di/Container.ts +98 -0
- package/src/di/index.ts +1 -0
- package/src/errors.ts +62 -0
- package/src/http/Router.ts +149 -0
- package/src/http/Server.ts +145 -0
- package/src/http/bodyParser.ts +129 -0
- package/src/http/context.ts +112 -0
- package/src/http/index.ts +6 -0
- package/src/http/static.ts +85 -0
- package/src/http/uploads.ts +85 -0
- package/src/index.ts +4 -0
- package/tests/config.test.ts +44 -0
- package/tests/di.test.ts +39 -0
- package/tests/http.test.ts +163 -0
- package/tsconfig.json +8 -0
- package/vitest.config.ts +9 -0
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { mkdir, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { extname, basename, join, resolve } from 'node:path';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import type { Router } from './Router.js';
|
|
5
|
+
import type { Middleware, RequestContext } from './context.js';
|
|
6
|
+
import type { UploadedFile } from './bodyParser.js';
|
|
7
|
+
import { ValidationError } from '../errors.js';
|
|
8
|
+
|
|
9
|
+
export interface UploadRouteOptions {
|
|
10
|
+
/** Directory where uploaded files are persisted. */
|
|
11
|
+
directory: string;
|
|
12
|
+
/** POST endpoint that accepts multipart/form-data. */
|
|
13
|
+
path?: string;
|
|
14
|
+
/** Public URL prefix returned for saved files. */
|
|
15
|
+
publicPath?: string;
|
|
16
|
+
/** Maximum size of an individual file. */
|
|
17
|
+
maxFileSize?: number;
|
|
18
|
+
/** Maximum number of files accepted in one request. */
|
|
19
|
+
maxFiles?: number;
|
|
20
|
+
/** Empty means all MIME types are accepted. */
|
|
21
|
+
allowedTypes?: string[];
|
|
22
|
+
/** Optional route middleware, such as authentication. */
|
|
23
|
+
middleware?: Middleware[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface SavedUpload {
|
|
27
|
+
field: string;
|
|
28
|
+
originalName: string;
|
|
29
|
+
filename: string;
|
|
30
|
+
contentType: string;
|
|
31
|
+
size: number;
|
|
32
|
+
url: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Register a safe multipart upload endpoint backed by a local directory. */
|
|
36
|
+
export function registerUploadRoutes(router: Router, options: UploadRouteOptions): void {
|
|
37
|
+
const path = options.path ?? '/uploads';
|
|
38
|
+
const publicPath = (options.publicPath ?? path).replace(/\/$/, '');
|
|
39
|
+
const maxFileSize = options.maxFileSize ?? 10 * 1024 * 1024;
|
|
40
|
+
const maxFiles = options.maxFiles ?? 20;
|
|
41
|
+
const allowedTypes = new Set(options.allowedTypes ?? []);
|
|
42
|
+
const directory = resolve(options.directory);
|
|
43
|
+
|
|
44
|
+
router.post(path, async (ctx) => {
|
|
45
|
+
const files = (ctx.state.files as UploadedFile[] | undefined) ?? [];
|
|
46
|
+
if (files.length === 0) throw new ValidationError('At least one file is required');
|
|
47
|
+
if (files.length > maxFiles) throw new ValidationError(`A maximum of ${maxFiles} files may be uploaded`);
|
|
48
|
+
|
|
49
|
+
for (const file of files) {
|
|
50
|
+
if (file.data.length > maxFileSize) {
|
|
51
|
+
throw new ValidationError(`File "${file.filename}" exceeds the ${maxFileSize}-byte limit`);
|
|
52
|
+
}
|
|
53
|
+
if (allowedTypes.size > 0 && !allowedTypes.has(file.contentType)) {
|
|
54
|
+
throw new ValidationError(`File type "${file.contentType}" is not allowed`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
await mkdir(directory, { recursive: true });
|
|
59
|
+
const saved: SavedUpload[] = [];
|
|
60
|
+
for (const file of files) {
|
|
61
|
+
const extension = safeExtension(file.filename);
|
|
62
|
+
const filename = `${randomUUID()}${extension}`;
|
|
63
|
+
await writeFile(join(directory, filename), file.data, { flag: 'wx' });
|
|
64
|
+
saved.push({
|
|
65
|
+
field: file.field,
|
|
66
|
+
originalName: basename(file.filename),
|
|
67
|
+
filename,
|
|
68
|
+
contentType: file.contentType,
|
|
69
|
+
size: file.data.length,
|
|
70
|
+
url: `${publicPath}/${filename}`,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
ctx.json({ files: saved }, 201);
|
|
74
|
+
}, options.middleware ?? []);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function safeExtension(filename: string): string {
|
|
78
|
+
const extension = extname(basename(filename)).toLowerCase();
|
|
79
|
+
return /^\.[a-z0-9]{1,10}$/.test(extension) ? extension : '';
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Type guard useful to route handlers that consume parsed multipart state. */
|
|
83
|
+
export function uploadedFiles(ctx: RequestContext): UploadedFile[] {
|
|
84
|
+
return (ctx.state.files as UploadedFile[] | undefined) ?? [];
|
|
85
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { mergeConfig, configFromEnv, deepMerge } from '../src/index.js';
|
|
3
|
+
|
|
4
|
+
describe('deepMerge', () => {
|
|
5
|
+
it('merges nested objects with later sources winning', () => {
|
|
6
|
+
const out = deepMerge({ a: { b: 1, c: 2 } }, { a: { b: 9 } });
|
|
7
|
+
expect(out).toEqual({ a: { b: 9, c: 2 } });
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it('replaces arrays rather than concatenating', () => {
|
|
11
|
+
const out = deepMerge({ list: [1, 2] }, { list: [3] });
|
|
12
|
+
expect(out).toEqual({ list: [3] });
|
|
13
|
+
});
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
describe('configFromEnv', () => {
|
|
17
|
+
it('maps NEXUS_SERVER_PORT to { server: { port } } with coercion', () => {
|
|
18
|
+
const cfg = configFromEnv({ NEXUS_SERVER_PORT: '5000', NEXUS_SERVER_HTTPS: 'true' });
|
|
19
|
+
expect(cfg.server?.port).toBe(5000);
|
|
20
|
+
expect(cfg.server?.https).toBe(true);
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe('mergeConfig', () => {
|
|
25
|
+
it('applies user overrides over defaults and validates', () => {
|
|
26
|
+
const cfg = mergeConfig({ server: { port: 5000 }, auth: { jwt: { secret: 'supersecret' } } });
|
|
27
|
+
expect(cfg.server.port).toBe(5000);
|
|
28
|
+
expect(cfg.server.host).toBe('0.0.0.0'); // default retained
|
|
29
|
+
expect(cfg.auth.jwt.secret).toBe('supersecret');
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('rejects an invalid jwt secret length', () => {
|
|
33
|
+
expect(() => mergeConfig({ auth: { jwt: { secret: 'short' } } })).toThrow();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('rejects an out-of-range port', () => {
|
|
37
|
+
expect(() => mergeConfig({ server: { port: 99999 } })).toThrow();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('returns a frozen object', () => {
|
|
41
|
+
const cfg = mergeConfig({});
|
|
42
|
+
expect(Object.isFrozen(cfg)).toBe(true);
|
|
43
|
+
});
|
|
44
|
+
});
|
package/tests/di.test.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { Container, ResolutionError } from '../src/index.js';
|
|
3
|
+
|
|
4
|
+
describe('Container', () => {
|
|
5
|
+
it('resolves singletons once', () => {
|
|
6
|
+
const c = new Container();
|
|
7
|
+
let calls = 0;
|
|
8
|
+
c.register('counter', () => ++calls, { lifetime: 'singleton' });
|
|
9
|
+
expect(c.resolve('counter')).toBe(1);
|
|
10
|
+
expect(c.resolve('counter')).toBe(1);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('resolves transients each time', () => {
|
|
14
|
+
const c = new Container();
|
|
15
|
+
let calls = 0;
|
|
16
|
+
c.register('counter', () => ++calls, { lifetime: 'transient' });
|
|
17
|
+
expect(c.resolve('counter')).toBe(1);
|
|
18
|
+
expect(c.resolve('counter')).toBe(2);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('injects declared dependencies', () => {
|
|
22
|
+
const c = new Container();
|
|
23
|
+
c.register('base', () => 41);
|
|
24
|
+
c.register('derived', (container, base: number) => base + 1, { deps: ['base'] });
|
|
25
|
+
expect(c.resolve('derived')).toBe(42);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('detects circular dependencies', () => {
|
|
29
|
+
const c = new Container();
|
|
30
|
+
c.register('a', (container) => container.resolve('b'), { deps: ['b'] });
|
|
31
|
+
c.register('b', (container) => container.resolve('a'), { deps: ['a'] });
|
|
32
|
+
expect(() => c.resolve('a')).toThrow(ResolutionError);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('throws for unregistered services', () => {
|
|
36
|
+
const c = new Container();
|
|
37
|
+
expect(() => c.resolve('missing')).toThrow(ResolutionError);
|
|
38
|
+
});
|
|
39
|
+
});
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { describe, it, expect, afterEach } from 'vitest';
|
|
2
|
+
import { Router, NexusServer, bodyParser, parseUrlEncoded, parseMultipart, registerUploadRoutes, serveStatic } from '../src/index.js';
|
|
3
|
+
import { request } from 'node:http';
|
|
4
|
+
import { mkdtemp, readFile, rm } from 'node:fs/promises';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { tmpdir } from 'node:os';
|
|
7
|
+
|
|
8
|
+
function getJson(url: string, opts: { method?: string; headers?: Record<string, string>; body?: string } = {}): Promise<{ status: number; body: string; headers: Record<string, string | string[] | undefined> }> {
|
|
9
|
+
return new Promise((resolve, reject) => {
|
|
10
|
+
const u = new URL(url);
|
|
11
|
+
const req = request(
|
|
12
|
+
{ hostname: u.hostname, port: u.port, path: u.pathname + u.search, method: opts.method ?? 'GET', headers: opts.headers },
|
|
13
|
+
(res) => {
|
|
14
|
+
let body = '';
|
|
15
|
+
res.on('data', (c) => (body += c));
|
|
16
|
+
res.on('end', () => resolve({ status: res.statusCode ?? 0, body, headers: res.headers }));
|
|
17
|
+
},
|
|
18
|
+
);
|
|
19
|
+
req.on('error', reject);
|
|
20
|
+
if (opts.body) req.write(opts.body);
|
|
21
|
+
req.end();
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe('Router', () => {
|
|
26
|
+
it('matches static routes and extracts params', () => {
|
|
27
|
+
const r = new Router();
|
|
28
|
+
r.get('/users/:id', (ctx) => ctx.json({ id: ctx.params.id }));
|
|
29
|
+
const m = r.match('GET', '/users/42');
|
|
30
|
+
expect(m).not.toBeNull();
|
|
31
|
+
expect(m!.params.id).toBe('42');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('matches wildcards', () => {
|
|
35
|
+
const r = new Router();
|
|
36
|
+
r.get('/assets/*', (ctx) => ctx.text('ok'));
|
|
37
|
+
expect(r.match('GET', '/assets/a/b.css')).not.toBeNull();
|
|
38
|
+
expect(r.match('GET', '/other/x')).toBeNull();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('returns 405 handler when method mismatches but path matches', () => {
|
|
42
|
+
const r = new Router();
|
|
43
|
+
r.post('/items', () => undefined);
|
|
44
|
+
const m = r.match('GET', '/items');
|
|
45
|
+
expect(m).not.toBeNull();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('decodes URI-encoded params', () => {
|
|
49
|
+
const r = new Router();
|
|
50
|
+
r.get('/q/:term', () => undefined);
|
|
51
|
+
expect(r.match('GET', '/q/hello%20world')!.params.term).toBe('hello world');
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe('bodyParser', () => {
|
|
56
|
+
it('parses urlencoded', () => {
|
|
57
|
+
expect(parseUrlEncoded('a=1&b=hi%20there&b=2')).toEqual({ a: '1', b: ['hi there', '2'] });
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('parses multipart fields and files', () => {
|
|
61
|
+
const boundary = '----testboundary';
|
|
62
|
+
const body = Buffer.from(
|
|
63
|
+
`--${boundary}\r\n` +
|
|
64
|
+
'Content-Disposition: form-data; name="title"\r\n\r\n' +
|
|
65
|
+
'Hello\r\n' +
|
|
66
|
+
`--${boundary}\r\n` +
|
|
67
|
+
'Content-Disposition: form-data; name="upload"; filename="f.txt"\r\n' +
|
|
68
|
+
'Content-Type: text/plain\r\n\r\n' +
|
|
69
|
+
'file contents\r\n' +
|
|
70
|
+
`--${boundary}--\r\n`,
|
|
71
|
+
);
|
|
72
|
+
const { fields, files } = parseMultipart(body, boundary);
|
|
73
|
+
expect(fields.title).toBe('Hello');
|
|
74
|
+
expect(files[0]?.filename).toBe('f.txt');
|
|
75
|
+
expect(files[0]?.data.toString('utf8')).toBe('file contents');
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe('NexusServer end-to-end', () => {
|
|
80
|
+
let server: NexusServer;
|
|
81
|
+
let port: number;
|
|
82
|
+
|
|
83
|
+
afterEach(async () => {
|
|
84
|
+
if (server) await server.close();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('routes a request and returns JSON', async () => {
|
|
88
|
+
const router = new Router();
|
|
89
|
+
router.get('/health', (ctx) => ctx.json({ ok: true }));
|
|
90
|
+
server = new NexusServer({ router, middleware: [bodyParser()] });
|
|
91
|
+
await server.listen(0, '127.0.0.1');
|
|
92
|
+
port = (server.address as import('node:net').AddressInfo).port;
|
|
93
|
+
const res = await getJson(`http://127.0.0.1:${port}/health`);
|
|
94
|
+
expect(res.status).toBe(200);
|
|
95
|
+
expect(JSON.parse(res.body)).toEqual({ ok: true });
|
|
96
|
+
expect(res.headers['x-request-id']).toBeDefined();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('parses a JSON POST body', async () => {
|
|
100
|
+
const router = new Router();
|
|
101
|
+
router.post('/echo', (ctx) => ctx.json({ received: ctx.body }));
|
|
102
|
+
server = new NexusServer({ router, middleware: [bodyParser()] });
|
|
103
|
+
await server.listen(0, '127.0.0.1');
|
|
104
|
+
port = (server.address as import('node:net').AddressInfo).port;
|
|
105
|
+
const res = await getJson(`http://127.0.0.1:${port}/echo`, {
|
|
106
|
+
method: 'POST',
|
|
107
|
+
headers: { 'content-type': 'application/json' },
|
|
108
|
+
body: JSON.stringify({ msg: 'hi' }),
|
|
109
|
+
});
|
|
110
|
+
expect(res.status).toBe(200);
|
|
111
|
+
expect(JSON.parse(res.body)).toEqual({ received: { msg: 'hi' } });
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('returns 404 for unknown routes and 405 for wrong method', async () => {
|
|
115
|
+
const router = new Router();
|
|
116
|
+
router.post('/only', () => undefined);
|
|
117
|
+
server = new NexusServer({ router });
|
|
118
|
+
await server.listen(0, '127.0.0.1');
|
|
119
|
+
port = (server.address as import('node:net').AddressInfo).port;
|
|
120
|
+
const notFound = await getJson(`http://127.0.0.1:${port}/nope`);
|
|
121
|
+
expect(notFound.status).toBe(404);
|
|
122
|
+
const wrong = await getJson(`http://127.0.0.1:${port}/only`);
|
|
123
|
+
expect(wrong.status).toBe(405);
|
|
124
|
+
expect(wrong.headers['allow']).toBe('POST');
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('stores multipart uploads and serves them below a URL prefix', async () => {
|
|
128
|
+
const directory = await mkdtemp(join(tmpdir(), 'nexus-uploads-'));
|
|
129
|
+
const boundary = '----nexus-upload-test';
|
|
130
|
+
const body =
|
|
131
|
+
`--${boundary}\r\n` +
|
|
132
|
+
'Content-Disposition: form-data; name="file"; filename="hello.txt"\r\n' +
|
|
133
|
+
'Content-Type: text/plain\r\n\r\n' +
|
|
134
|
+
'hello nexus\r\n' +
|
|
135
|
+
`--${boundary}--\r\n`;
|
|
136
|
+
try {
|
|
137
|
+
const router = new Router();
|
|
138
|
+
registerUploadRoutes(router, { directory });
|
|
139
|
+
server = new NexusServer({
|
|
140
|
+
router,
|
|
141
|
+
middleware: [serveStatic(directory, { prefix: '/uploads' }), bodyParser()],
|
|
142
|
+
});
|
|
143
|
+
await server.listen(0, '127.0.0.1');
|
|
144
|
+
port = (server.address as import('node:net').AddressInfo).port;
|
|
145
|
+
|
|
146
|
+
const uploaded = await getJson(`http://127.0.0.1:${port}/uploads`, {
|
|
147
|
+
method: 'POST',
|
|
148
|
+
headers: { 'content-type': `multipart/form-data; boundary=${boundary}` },
|
|
149
|
+
body,
|
|
150
|
+
});
|
|
151
|
+
expect(uploaded.status).toBe(201);
|
|
152
|
+
const saved = JSON.parse(uploaded.body).files[0] as { url: string; filename: string };
|
|
153
|
+
expect(saved.url).toBe(`/uploads/${saved.filename}`);
|
|
154
|
+
|
|
155
|
+
const downloaded = await getJson(`http://127.0.0.1:${port}${saved.url}`);
|
|
156
|
+
expect(downloaded.status).toBe(200);
|
|
157
|
+
expect(downloaded.body).toBe('hello nexus');
|
|
158
|
+
expect((await readFile(join(directory, saved.filename))).toString()).toBe('hello nexus');
|
|
159
|
+
} finally {
|
|
160
|
+
await rm(directory, { recursive: true, force: true });
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
});
|
package/tsconfig.json
ADDED