@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,178 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Zod validation schema for the merged `NexusConfig`. Validates types and the
|
|
5
|
+
* most error-prone fields (ports, URLs, TTLs). Provider sub-configs use a
|
|
6
|
+
* permissive record so custom keys flow through.
|
|
7
|
+
*/
|
|
8
|
+
const providerConfig = z
|
|
9
|
+
.object({ enabled: z.boolean().default(false), sandbox: z.boolean().default(true) })
|
|
10
|
+
.passthrough();
|
|
11
|
+
|
|
12
|
+
export const nexusConfigSchema = z.object({
|
|
13
|
+
env: z.enum(['development', 'production', 'test']).default('development'),
|
|
14
|
+
server: z.object({
|
|
15
|
+
port: z.number().int().min(1).max(65535),
|
|
16
|
+
host: z.string().min(1),
|
|
17
|
+
https: z.boolean(),
|
|
18
|
+
certFile: z.string().optional(),
|
|
19
|
+
keyFile: z.string().optional(),
|
|
20
|
+
trustProxy: z.union([z.boolean(), z.number().int().nonnegative()]),
|
|
21
|
+
bodyLimit: z.number().int().positive(),
|
|
22
|
+
}),
|
|
23
|
+
uploads: z.object({
|
|
24
|
+
dir: z.string().min(1),
|
|
25
|
+
path: z.string().startsWith('/'),
|
|
26
|
+
maxFileSize: z.number().int().positive(),
|
|
27
|
+
maxFiles: z.number().int().positive(),
|
|
28
|
+
allowedTypes: z.array(z.string()),
|
|
29
|
+
}),
|
|
30
|
+
db: z.object({
|
|
31
|
+
uri: z.string().min(1),
|
|
32
|
+
name: z.string().optional(),
|
|
33
|
+
maxPoolSize: z.number().int().positive(),
|
|
34
|
+
autoIndex: z.boolean(),
|
|
35
|
+
}),
|
|
36
|
+
redis: z.object({ url: z.string().min(1), keyPrefix: z.string() }),
|
|
37
|
+
graphql: z.object({
|
|
38
|
+
path: z.string().min(1),
|
|
39
|
+
federation: z.enum(['in-process', 'distributed']),
|
|
40
|
+
subscriptions: z.boolean(),
|
|
41
|
+
introspection: z.boolean(),
|
|
42
|
+
}),
|
|
43
|
+
ws: z.object({
|
|
44
|
+
path: z.string().min(1),
|
|
45
|
+
heartbeatMs: z.number().int().positive(),
|
|
46
|
+
requireCsrf: z.boolean(),
|
|
47
|
+
}),
|
|
48
|
+
auth: z.object({
|
|
49
|
+
jwt: z.object({
|
|
50
|
+
secret: z.string().min(8, 'jwt.secret must be at least 8 characters'),
|
|
51
|
+
accessTtl: z.number().int().positive(),
|
|
52
|
+
refreshTtl: z.number().int().positive(),
|
|
53
|
+
issuer: z.string(),
|
|
54
|
+
audience: z.string(),
|
|
55
|
+
}),
|
|
56
|
+
cookieName: z.string(),
|
|
57
|
+
refreshCookieName: z.string(),
|
|
58
|
+
google: z
|
|
59
|
+
.object({
|
|
60
|
+
clientId: z.string(),
|
|
61
|
+
clientSecret: z.string(),
|
|
62
|
+
callbackPath: z.string(),
|
|
63
|
+
scope: z.string(),
|
|
64
|
+
})
|
|
65
|
+
.optional(),
|
|
66
|
+
facebook: z
|
|
67
|
+
.object({
|
|
68
|
+
clientId: z.string(),
|
|
69
|
+
clientSecret: z.string(),
|
|
70
|
+
callbackPath: z.string(),
|
|
71
|
+
scope: z.string(),
|
|
72
|
+
})
|
|
73
|
+
.optional(),
|
|
74
|
+
requireEmailVerification: z.boolean(),
|
|
75
|
+
}),
|
|
76
|
+
payments: z.object({
|
|
77
|
+
razorpay: providerConfig.optional(),
|
|
78
|
+
paypal: providerConfig.optional(),
|
|
79
|
+
payu: providerConfig.optional(),
|
|
80
|
+
skrill: providerConfig.optional(),
|
|
81
|
+
payoneer: providerConfig.optional(),
|
|
82
|
+
webhookPath: z.string(),
|
|
83
|
+
currency: z.string().length(3),
|
|
84
|
+
}),
|
|
85
|
+
email: z.object({
|
|
86
|
+
provider: z.enum(['smtp', 'log']),
|
|
87
|
+
smtp: z
|
|
88
|
+
.object({
|
|
89
|
+
host: z.string(),
|
|
90
|
+
port: z.number().int().min(1).max(65535),
|
|
91
|
+
secure: z.boolean(),
|
|
92
|
+
user: z.string(),
|
|
93
|
+
pass: z.string(),
|
|
94
|
+
})
|
|
95
|
+
.optional(),
|
|
96
|
+
from: z.string(),
|
|
97
|
+
}),
|
|
98
|
+
certs: z.object({
|
|
99
|
+
dir: z.string(),
|
|
100
|
+
keyType: z.enum(['rsa', 'ec']),
|
|
101
|
+
rsaModulus: z.number().int().positive(),
|
|
102
|
+
ecCurve: z.enum(['prime256v1', 'secp384r1', 'secp521r1']),
|
|
103
|
+
validityDays: z.number().int().positive(),
|
|
104
|
+
}),
|
|
105
|
+
ads: z.object({
|
|
106
|
+
enabled: z.boolean(),
|
|
107
|
+
developerToken: z.string(),
|
|
108
|
+
clientId: z.string(),
|
|
109
|
+
clientSecret: z.string(),
|
|
110
|
+
refreshToken: z.string(),
|
|
111
|
+
customerId: z.string(),
|
|
112
|
+
}),
|
|
113
|
+
webrtc: z.object({
|
|
114
|
+
rtcMinPort: z.number().int().min(1).max(65535),
|
|
115
|
+
rtcMaxPort: z.number().int().min(1).max(65535),
|
|
116
|
+
announceIp: z.string(),
|
|
117
|
+
}),
|
|
118
|
+
ai: z.object({
|
|
119
|
+
serverUrl: z.string().url(),
|
|
120
|
+
timeoutMs: z.number().int().positive(),
|
|
121
|
+
defaultProvider: z.enum(['openai', 'ollama', 'auto']),
|
|
122
|
+
schemaModel: z.string().min(1).optional(),
|
|
123
|
+
providers: z.array(z.object({
|
|
124
|
+
id: z.string().min(1),
|
|
125
|
+
label: z.string().min(1),
|
|
126
|
+
baseUrl: z.string(),
|
|
127
|
+
enabled: z.boolean(),
|
|
128
|
+
apiKey: z.string().optional(),
|
|
129
|
+
defaultModel: z.string().optional(),
|
|
130
|
+
})).optional(),
|
|
131
|
+
}),
|
|
132
|
+
logging: z.object({
|
|
133
|
+
level: z.enum(['trace', 'debug', 'info', 'warn', 'error', 'fatal']),
|
|
134
|
+
format: z.enum(['json', 'pretty']),
|
|
135
|
+
console: z.boolean(),
|
|
136
|
+
dir: z.string(),
|
|
137
|
+
maxFileSize: z.number().int().positive(),
|
|
138
|
+
maxFiles: z.number().int().positive(),
|
|
139
|
+
}),
|
|
140
|
+
plugins: z.object({
|
|
141
|
+
dir: z.string(),
|
|
142
|
+
entries: z.array(
|
|
143
|
+
z.object({ path: z.string(), enabled: z.boolean(), config: z.record(z.unknown()).optional() }),
|
|
144
|
+
),
|
|
145
|
+
}),
|
|
146
|
+
frontend: z.object({
|
|
147
|
+
port: z.number().int().min(1).max(65535),
|
|
148
|
+
host: z.string().min(1),
|
|
149
|
+
enabled: z.boolean(),
|
|
150
|
+
}),
|
|
151
|
+
admin: z.object({
|
|
152
|
+
port: z.number().int().min(1).max(65535),
|
|
153
|
+
host: z.string().min(1),
|
|
154
|
+
enabled: z.boolean(),
|
|
155
|
+
}),
|
|
156
|
+
cluster: z.object({
|
|
157
|
+
enabled: z.boolean(),
|
|
158
|
+
failOpenSingleNode: z.boolean(),
|
|
159
|
+
lbHost: z.string().min(1),
|
|
160
|
+
lbPort: z.number().int().min(1).max(65535),
|
|
161
|
+
nodeAgentHost: z.string().min(1),
|
|
162
|
+
nodeAgentPort: z.number().int().min(1).max(65535),
|
|
163
|
+
registryFile: z.string().min(1),
|
|
164
|
+
token: z.string(),
|
|
165
|
+
autoscale: z.object({
|
|
166
|
+
enabled: z.boolean(),
|
|
167
|
+
mode: z.enum(['auto', 'manual']),
|
|
168
|
+
minNodes: z.number().int().min(1),
|
|
169
|
+
maxNodes: z.number().int().min(1),
|
|
170
|
+
cooldownMs: z.number().int().nonnegative(),
|
|
171
|
+
cpuHigh: z.number().min(0).max(100),
|
|
172
|
+
rpsPerNodeHigh: z.number().nonnegative(),
|
|
173
|
+
rpsPerNodeLow: z.number().nonnegative(),
|
|
174
|
+
}),
|
|
175
|
+
}),
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
export type ValidatedNexusConfig = z.infer<typeof nexusConfigSchema>;
|
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BhooAI Nexus — root configuration types.
|
|
3
|
+
*
|
|
4
|
+
* The single config file (`nexus.config.ts`) is typed by `NexusConfig`.
|
|
5
|
+
* Precedence (low → high): code defaults < nexus.config.ts < nexus.runtime.json
|
|
6
|
+
* < environment variables < CLI flags.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export type Env = 'development' | 'production' | 'test';
|
|
10
|
+
|
|
11
|
+
export interface ServerConfig {
|
|
12
|
+
port: number;
|
|
13
|
+
host: string;
|
|
14
|
+
/** When true, serve over HTTPS using the certs from `certs`. */
|
|
15
|
+
https: boolean;
|
|
16
|
+
/** Path to a generated TLS cert/key pair (relative to project root). */
|
|
17
|
+
certFile?: string;
|
|
18
|
+
keyFile?: string;
|
|
19
|
+
/** Trust proxy headers (X-Forwarded-*) — set to the number of hops or true. */
|
|
20
|
+
trustProxy: boolean | number;
|
|
21
|
+
/** Maximum request body size in bytes. */
|
|
22
|
+
bodyLimit: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface UploadsConfig {
|
|
26
|
+
/** Directory for persisted files, relative to the project root. */
|
|
27
|
+
dir: string;
|
|
28
|
+
/** Public URL path for upload and download requests. */
|
|
29
|
+
path: string;
|
|
30
|
+
/** Maximum size of one uploaded file in bytes. */
|
|
31
|
+
maxFileSize: number;
|
|
32
|
+
/** Maximum number of files accepted in one request. */
|
|
33
|
+
maxFiles: number;
|
|
34
|
+
/** Empty means all MIME types are accepted. */
|
|
35
|
+
allowedTypes: string[];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface DbConfig {
|
|
39
|
+
uri: string;
|
|
40
|
+
/** Database name override; otherwise taken from the URI. */
|
|
41
|
+
name?: string;
|
|
42
|
+
/** Connection pool size. */
|
|
43
|
+
maxPoolSize: number;
|
|
44
|
+
/** Auto-create indexes declared in schemas on boot. */
|
|
45
|
+
autoIndex: boolean;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface RedisConfig {
|
|
49
|
+
url: string;
|
|
50
|
+
/** Key prefix for namespacing. */
|
|
51
|
+
keyPrefix: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface JwtConfig {
|
|
55
|
+
secret: string;
|
|
56
|
+
/** Access token TTL in seconds. */
|
|
57
|
+
accessTtl: number;
|
|
58
|
+
/** Refresh token TTL in seconds. */
|
|
59
|
+
refreshTtl: number;
|
|
60
|
+
issuer: string;
|
|
61
|
+
audience: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface OAuthProviderConfig {
|
|
65
|
+
clientId: string;
|
|
66
|
+
clientSecret: string;
|
|
67
|
+
/** OAuth redirect path relative to the server root, e.g. "/auth/google/callback". */
|
|
68
|
+
callbackPath: string;
|
|
69
|
+
/** Space-separated OAuth scopes. */
|
|
70
|
+
scope: string;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface AuthConfig {
|
|
74
|
+
jwt: JwtConfig;
|
|
75
|
+
/** Cookie name for the session/access token. */
|
|
76
|
+
cookieName: string;
|
|
77
|
+
/** Cookie name for the refresh token. */
|
|
78
|
+
refreshCookieName: string;
|
|
79
|
+
google?: OAuthProviderConfig;
|
|
80
|
+
facebook?: OAuthProviderConfig;
|
|
81
|
+
/** Require email verification before login. */
|
|
82
|
+
requireEmailVerification: boolean;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface PaymentsProviderConfig {
|
|
86
|
+
enabled: boolean;
|
|
87
|
+
/** Sandbox/test mode. */
|
|
88
|
+
sandbox: boolean;
|
|
89
|
+
[key: string]: unknown;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export interface PaymentsConfig {
|
|
93
|
+
razorpay?: PaymentsProviderConfig;
|
|
94
|
+
paypal?: PaymentsProviderConfig;
|
|
95
|
+
payu?: PaymentsProviderConfig;
|
|
96
|
+
skrill?: PaymentsProviderConfig;
|
|
97
|
+
payoneer?: PaymentsProviderConfig;
|
|
98
|
+
/** Path on which payment webhooks are mounted, e.g. "/payments/webhook/:provider". */
|
|
99
|
+
webhookPath: string;
|
|
100
|
+
/** Default currency (ISO 4217). */
|
|
101
|
+
currency: string;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export interface EmailConfig {
|
|
105
|
+
provider: 'smtp' | 'log';
|
|
106
|
+
smtp?: {
|
|
107
|
+
host: string;
|
|
108
|
+
port: number;
|
|
109
|
+
secure: boolean;
|
|
110
|
+
user: string;
|
|
111
|
+
pass: string;
|
|
112
|
+
};
|
|
113
|
+
from: string;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export interface CertsConfig {
|
|
117
|
+
/** Directory (relative to project root) where generated certs/keys are written. */
|
|
118
|
+
dir: string;
|
|
119
|
+
/** Default key type for new keypairs. */
|
|
120
|
+
keyType: 'rsa' | 'ec';
|
|
121
|
+
/** RSA key size in bits. */
|
|
122
|
+
rsaModulus: number;
|
|
123
|
+
/** EC curve name. */
|
|
124
|
+
ecCurve: 'prime256v1' | 'secp384r1' | 'secp521r1';
|
|
125
|
+
/** Self-signed cert validity in days. */
|
|
126
|
+
validityDays: number;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export interface AdsConfig {
|
|
130
|
+
enabled: boolean;
|
|
131
|
+
developerToken: string;
|
|
132
|
+
clientId: string;
|
|
133
|
+
clientSecret: string;
|
|
134
|
+
refreshToken: string;
|
|
135
|
+
customerId: string;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export interface WebRtcConfig {
|
|
139
|
+
/** mediasoup worker RTC listen IPs. */
|
|
140
|
+
rtcMinPort: number;
|
|
141
|
+
rtcMaxPort: number;
|
|
142
|
+
/** IP announced to clients (must be reachable from the browser). */
|
|
143
|
+
announceIp: string;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export interface GraphqlConfig {
|
|
147
|
+
/** Path on which GraphQL is mounted. */
|
|
148
|
+
path: string;
|
|
149
|
+
/** Federation mode: "in-process" (monolith) or "distributed" (HTTP subgraphs). */
|
|
150
|
+
federation: 'in-process' | 'distributed';
|
|
151
|
+
/** Enable GraphQL over WebSocket (subscriptions). */
|
|
152
|
+
subscriptions: boolean;
|
|
153
|
+
/** Introspection enabled (disable in production). */
|
|
154
|
+
introspection: boolean;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export interface WsConfig {
|
|
158
|
+
/** Path on which the WebSocket server listens. */
|
|
159
|
+
path: string;
|
|
160
|
+
/** Heartbeat ping interval in ms. */
|
|
161
|
+
heartbeatMs: number;
|
|
162
|
+
/** Require CSRF token + origin check on the WS upgrade. */
|
|
163
|
+
requireCsrf: boolean;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export interface AiProviderConfig {
|
|
167
|
+
/** Unique id for this provider (e.g. "openai", "ollama", "anthropic"). */
|
|
168
|
+
id: string;
|
|
169
|
+
/** Human-readable label. */
|
|
170
|
+
label: string;
|
|
171
|
+
/** API base URL the AI server should call. */
|
|
172
|
+
baseUrl: string;
|
|
173
|
+
/** Whether this provider is enabled (has a valid API key and is selectable). */
|
|
174
|
+
enabled: boolean;
|
|
175
|
+
/** API key stored in .env as NEXUS_AI_<ID>_API_KEY (never in config). */
|
|
176
|
+
apiKey?: string;
|
|
177
|
+
/** Default model for this provider. */
|
|
178
|
+
defaultModel?: string;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export interface AiConfig {
|
|
182
|
+
/** Python AI server base URL. */
|
|
183
|
+
serverUrl: string;
|
|
184
|
+
/** Request timeout in ms. */
|
|
185
|
+
timeoutMs: number;
|
|
186
|
+
/** Default provider: "openai" | "ollama" | "auto". */
|
|
187
|
+
defaultProvider: 'openai' | 'ollama' | 'auto';
|
|
188
|
+
/** Model used by the admin AI schema generator (e.g. "gpt-4o-mini"). */
|
|
189
|
+
schemaModel: string;
|
|
190
|
+
/** Configured AI providers with API keys + enable state. Optional —
|
|
191
|
+
* defaults are provided in code. */
|
|
192
|
+
providers?: AiProviderConfig[];
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export interface LoggingConfig {
|
|
196
|
+
level: 'trace' | 'debug' | 'info' | 'warn' | 'error' | 'fatal';
|
|
197
|
+
/** "json" (machine) or "pretty" (colored, dev). */
|
|
198
|
+
format: 'json' | 'pretty';
|
|
199
|
+
/** Write logs to stdout in addition to file. */
|
|
200
|
+
console: boolean;
|
|
201
|
+
/** Directory for rotating log files. */
|
|
202
|
+
dir: string;
|
|
203
|
+
/** Max log file size in bytes before rotation. */
|
|
204
|
+
maxFileSize: number;
|
|
205
|
+
/** Number of rotated files to keep. */
|
|
206
|
+
maxFiles: number;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export interface PluginEntry {
|
|
210
|
+
/** Package or path to the plugin. */
|
|
211
|
+
path: string;
|
|
212
|
+
enabled: boolean;
|
|
213
|
+
/** Plugin-specific config. */
|
|
214
|
+
config?: Record<string, unknown>;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export interface PluginsConfig {
|
|
218
|
+
/** Directory holding local plugins. */
|
|
219
|
+
dir: string;
|
|
220
|
+
entries: PluginEntry[];
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export interface FrontendConfig {
|
|
224
|
+
/** Vite dev server port. */
|
|
225
|
+
port: number;
|
|
226
|
+
/** Vite dev server bind host. "localhost" = loopback only, "0.0.0.0" = LAN. */
|
|
227
|
+
host: string;
|
|
228
|
+
/** Launch the frontend dev server under `nexus dev`. */
|
|
229
|
+
enabled: boolean;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export interface AdminConfig {
|
|
233
|
+
/** Vite dev server port for the admin app. */
|
|
234
|
+
port: number;
|
|
235
|
+
/** Vite dev server bind host. */
|
|
236
|
+
host: string;
|
|
237
|
+
/** Launch the admin dev server under `nexus dev`. */
|
|
238
|
+
enabled: boolean;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** Node type — the service profile a node advertises and exposes. */
|
|
242
|
+
export type NodeRole = 'backend' | 'files' | 'database' | 'ai';
|
|
243
|
+
|
|
244
|
+
/** A registered remote node in the central registry. */
|
|
245
|
+
export interface NodeInfo {
|
|
246
|
+
/** Stable node id (hostname-derived slug or caller-provided). */
|
|
247
|
+
id: string;
|
|
248
|
+
/** Base URL that reaches the node's agent, e.g. "https://node.example.com:7575". */
|
|
249
|
+
baseUrl: string;
|
|
250
|
+
/** The node's role profile. */
|
|
251
|
+
role: NodeRole;
|
|
252
|
+
/** Node reported version string. */
|
|
253
|
+
version?: string;
|
|
254
|
+
/** First successful registration time (ISO). */
|
|
255
|
+
registeredAt: string;
|
|
256
|
+
/** Last successful health/link time (ISO). */
|
|
257
|
+
lastSeenAt: string;
|
|
258
|
+
/** Last known metric sample. */
|
|
259
|
+
lastMetrics?: NodeMetrics;
|
|
260
|
+
/** Linked (discovered yet unverified) vs ready nodes. */
|
|
261
|
+
status: 'pending' | 'ready' | 'unreachable';
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** Rolling snapshot of a node's load, sampled by the central autoscaler. */
|
|
265
|
+
export interface NodeMetrics {
|
|
266
|
+
/** Requests per second the node reports receiving (if role exposes it). */
|
|
267
|
+
rps: number;
|
|
268
|
+
/** CPU usage of the node process (0-100). */
|
|
269
|
+
cpu: number;
|
|
270
|
+
/** Memory usage of the node process in MiB. */
|
|
271
|
+
memoryMb: number;
|
|
272
|
+
/** Unix ms timestamp the sample was taken. */
|
|
273
|
+
sampledAt: number;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
export interface ClusterAutoscaleConfig {
|
|
277
|
+
/** Enable the autoscaler entirely. */
|
|
278
|
+
enabled: boolean;
|
|
279
|
+
/** Scale decision mode: "auto" (deterministic + AI) or "manual". */
|
|
280
|
+
mode: 'auto' | 'manual';
|
|
281
|
+
/** Minimum number of backend nodes the LB keeps ready. */
|
|
282
|
+
minNodes: number;
|
|
283
|
+
/** Maximum number of backend nodes the LB may scale to. */
|
|
284
|
+
maxNodes: number;
|
|
285
|
+
/** Minimum wait (ms) between two autoscale decisions. */
|
|
286
|
+
cooldownMs: number;
|
|
287
|
+
/** CPU% at/above which the cluster considers a node overloaded. */
|
|
288
|
+
cpuHigh: number;
|
|
289
|
+
/** RPS per backend node at/above which the cluster scales up. */
|
|
290
|
+
rpsPerNodeHigh: number;
|
|
291
|
+
/** RPS per backend node below which the cluster scales down. */
|
|
292
|
+
rpsPerNodeLow: number;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
export interface ClusterConfig {
|
|
296
|
+
/** Turn the cluster (registry + LB + autoscaler) on. */
|
|
297
|
+
enabled: boolean;
|
|
298
|
+
/** When false, the LB fails open to a single direct node (no scaling). */
|
|
299
|
+
failOpenSingleNode: boolean;
|
|
300
|
+
/** Bind address of the central cluster's public load balancer. */
|
|
301
|
+
lbHost: string;
|
|
302
|
+
/** Port of the central cluster's public load balancer. */
|
|
303
|
+
lbPort: number;
|
|
304
|
+
/** Bind address of the node agent's control API (per node side). */
|
|
305
|
+
nodeAgentHost: string;
|
|
306
|
+
/** Port of the node agent's control API (per node side). */
|
|
307
|
+
nodeAgentPort: number;
|
|
308
|
+
/** File path (relative to this config's root) for the persisted registry. */
|
|
309
|
+
registryFile: string;
|
|
310
|
+
/** Shared pairing secret minted at node setup; pasted into the central. */
|
|
311
|
+
token: string;
|
|
312
|
+
autoscale: ClusterAutoscaleConfig;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
export interface NexusConfig {
|
|
316
|
+
env: Env;
|
|
317
|
+
server: ServerConfig;
|
|
318
|
+
uploads: UploadsConfig;
|
|
319
|
+
db: DbConfig;
|
|
320
|
+
redis: RedisConfig;
|
|
321
|
+
graphql: GraphqlConfig;
|
|
322
|
+
ws: WsConfig;
|
|
323
|
+
auth: AuthConfig;
|
|
324
|
+
payments: PaymentsConfig;
|
|
325
|
+
email: EmailConfig;
|
|
326
|
+
certs: CertsConfig;
|
|
327
|
+
ads: AdsConfig;
|
|
328
|
+
webrtc: WebRtcConfig;
|
|
329
|
+
ai: AiConfig;
|
|
330
|
+
logging: LoggingConfig;
|
|
331
|
+
plugins: PluginsConfig;
|
|
332
|
+
frontend: FrontendConfig;
|
|
333
|
+
admin: AdminConfig;
|
|
334
|
+
cluster: ClusterConfig;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** Deep partial used for user config files and runtime overrides. */
|
|
338
|
+
export type DeepPartial<T> = {
|
|
339
|
+
[K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : T[K];
|
|
340
|
+
};
|
|
341
|
+
|
|
342
|
+
export type UserNexusConfig = DeepPartial<NexusConfig>;
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A minimal inbuilt dependency-injection container.
|
|
3
|
+
*
|
|
4
|
+
* Supports factory-based registration (factories receive the container and any
|
|
5
|
+
* declared dependencies), singletons, and transient resolution. Intentionally
|
|
6
|
+
* small — this is the service backbone used by the framework and plugins.
|
|
7
|
+
*/
|
|
8
|
+
export type Factory<T> = (container: Container, ...deps: unknown[]) => T | Promise<T>;
|
|
9
|
+
export type Lifetime = 'singleton' | 'transient';
|
|
10
|
+
|
|
11
|
+
interface Registration<T = unknown> {
|
|
12
|
+
factory: Factory<T>;
|
|
13
|
+
lifetime: Lifetime;
|
|
14
|
+
deps: readonly string[];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export class Container {
|
|
18
|
+
private registrations = new Map<string, Registration>();
|
|
19
|
+
private singletons = new Map<string, unknown>();
|
|
20
|
+
private resolving = new Set<string>();
|
|
21
|
+
|
|
22
|
+
/** Register a service with explicit dependency ids. */
|
|
23
|
+
register<T>(
|
|
24
|
+
id: string,
|
|
25
|
+
factory: Factory<T>,
|
|
26
|
+
options: { lifetime?: Lifetime; deps?: readonly string[] } = {},
|
|
27
|
+
): this {
|
|
28
|
+
this.registrations.set(id, {
|
|
29
|
+
factory,
|
|
30
|
+
lifetime: options.lifetime ?? 'singleton',
|
|
31
|
+
deps: options.deps ?? [],
|
|
32
|
+
});
|
|
33
|
+
return this;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Register an already-constructed value as a singleton. */
|
|
37
|
+
instance<T>(id: string, value: T): this {
|
|
38
|
+
this.singletons.set(id, value);
|
|
39
|
+
return this;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
has(id: string): boolean {
|
|
43
|
+
return this.registrations.has(id) || this.singletons.has(id);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Resolve a service, throwing if it isn't registered. */
|
|
47
|
+
resolve<T>(id: string): T {
|
|
48
|
+
if (this.singletons.has(id)) return this.singletons.get(id) as T;
|
|
49
|
+
|
|
50
|
+
const reg = this.registrations.get(id);
|
|
51
|
+
if (!reg) throw new ResolutionError(id, `Service "${id}" is not registered.`);
|
|
52
|
+
|
|
53
|
+
if (this.resolving.has(id)) {
|
|
54
|
+
throw new ResolutionError(id, `Circular dependency detected while resolving "${id}".`);
|
|
55
|
+
}
|
|
56
|
+
this.resolving.add(id);
|
|
57
|
+
try {
|
|
58
|
+
const deps = reg.deps.map((d) => this.resolve(d));
|
|
59
|
+
const value = reg.factory(this, ...deps);
|
|
60
|
+
if (reg.lifetime === 'singleton') this.singletons.set(id, value);
|
|
61
|
+
return value as T;
|
|
62
|
+
} finally {
|
|
63
|
+
this.resolving.delete(id);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Resolve a possibly-async factory. */
|
|
68
|
+
async resolveAsync<T>(id: string): Promise<T> {
|
|
69
|
+
if (this.singletons.has(id)) return this.singletons.get(id) as T;
|
|
70
|
+
const value = this.resolve<unknown>(id);
|
|
71
|
+
const resolved = await Promise.resolve(value as T | Promise<T>);
|
|
72
|
+
if (this.registrations.get(id)?.lifetime === 'singleton') {
|
|
73
|
+
this.singletons.set(id, resolved);
|
|
74
|
+
}
|
|
75
|
+
return resolved as T;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
child(): Container {
|
|
79
|
+
// A child container inherits registrations/values but can override locally.
|
|
80
|
+
const child = new Container();
|
|
81
|
+
for (const [id, value] of this.singletons) child.singletons.set(id, value);
|
|
82
|
+
for (const [id, reg] of this.registrations) child.registrations.set(id, reg);
|
|
83
|
+
return child;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
clear(): void {
|
|
87
|
+
this.registrations.clear();
|
|
88
|
+
this.singletons.clear();
|
|
89
|
+
this.resolving.clear();
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export class ResolutionError extends Error {
|
|
94
|
+
constructor(public readonly serviceId: string, message: string) {
|
|
95
|
+
super(message);
|
|
96
|
+
this.name = 'ResolutionError';
|
|
97
|
+
}
|
|
98
|
+
}
|
package/src/di/index.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './Container.js';
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/** Framework-wide error base class with a stable `code` for programmatic handling. */
|
|
2
|
+
export class NexusError extends Error {
|
|
3
|
+
code: string;
|
|
4
|
+
statusCode: number;
|
|
5
|
+
details?: unknown;
|
|
6
|
+
constructor(message: string, options: { code?: string; statusCode?: number; details?: unknown } = {}) {
|
|
7
|
+
super(message);
|
|
8
|
+
this.name = this.constructor.name;
|
|
9
|
+
this.code = options.code ?? 'NEXUS_ERROR';
|
|
10
|
+
this.statusCode = options.statusCode ?? 500;
|
|
11
|
+
this.details = options.details;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export class ConfigError extends NexusError {
|
|
16
|
+
constructor(message: string, details?: unknown) {
|
|
17
|
+
super(message, { code: 'CONFIG_ERROR', statusCode: 500, details });
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export class ValidationError extends NexusError {
|
|
22
|
+
constructor(message: string, details?: unknown) {
|
|
23
|
+
super(message, { code: 'VALIDATION_ERROR', statusCode: 400, details });
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export class AuthenticationError extends NexusError {
|
|
28
|
+
constructor(message = 'Authentication required') {
|
|
29
|
+
super(message, { code: 'AUTHENTICATION_ERROR', statusCode: 401 });
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export class AuthorizationError extends NexusError {
|
|
34
|
+
constructor(message = 'Insufficient permissions') {
|
|
35
|
+
super(message, { code: 'AUTHORIZATION_ERROR', statusCode: 403 });
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export class NotFoundError extends NexusError {
|
|
40
|
+
constructor(message = 'Not found') {
|
|
41
|
+
super(message, { code: 'NOT_FOUND', statusCode: 404 });
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export class ConflictError extends NexusError {
|
|
46
|
+
constructor(message: string, details?: unknown) {
|
|
47
|
+
super(message, { code: 'CONFLICT', statusCode: 409, details });
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export class PaymentError extends NexusError {
|
|
52
|
+
constructor(message: string, details?: unknown) {
|
|
53
|
+
super(message, { code: 'PAYMENT_ERROR', statusCode: 402, details });
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Normalize an unknown thrown value into a NexusError. */
|
|
58
|
+
export function toNexusError(err: unknown): NexusError {
|
|
59
|
+
if (err instanceof NexusError) return err;
|
|
60
|
+
if (err instanceof Error) return new NexusError(err.message, { code: 'NEXUS_ERROR' });
|
|
61
|
+
return new NexusError(String(err));
|
|
62
|
+
}
|