@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
package/README.md
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# @bhooai/nexus-core
|
|
2
|
+
|
|
3
|
+
The framework core: config loading with full precedence, a dependency-injection
|
|
4
|
+
container, typed error classes, and the inbuilt HTTP server.
|
|
5
|
+
|
|
6
|
+
## Exports
|
|
7
|
+
|
|
8
|
+
- **config** — `loadConfig({ root })` resolves defaults < `nexus.config.ts` <
|
|
9
|
+
`nexus.runtime.json` < `NEXUS_*` env vars into a typed `NexusConfig`.
|
|
10
|
+
- **http** — `Router`, `NexusServer` (custom `node:http` server with a trie router,
|
|
11
|
+
params/wildcards, and a middleware pipeline), `bodyParser`, `RequestContext`.
|
|
12
|
+
- **di** — `Container` (the inbuilt DI used by the plugin host and services).
|
|
13
|
+
- **errors** — `HttpError`, `ValidationError`, `AuthenticationError`,
|
|
14
|
+
`ConflictError`, `NotFoundError`, etc.
|
|
15
|
+
|
|
16
|
+
## Usage
|
|
17
|
+
|
|
18
|
+
```ts
|
|
19
|
+
import { loadConfig, Router, NexusServer, bodyParser } from '@bhooai/nexus-core';
|
|
20
|
+
|
|
21
|
+
const router = new Router();
|
|
22
|
+
router.get('/health', (ctx) => ctx.json({ status: 'ok' }));
|
|
23
|
+
|
|
24
|
+
const server = new NexusServer({
|
|
25
|
+
router,
|
|
26
|
+
middleware: [bodyParser()],
|
|
27
|
+
});
|
|
28
|
+
await server.listen(4000, '0.0.0.0');
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
There is **no `server.use()`** — middleware is the `middleware` array passed to the
|
|
32
|
+
`NexusServer` constructor. Route middleware is the 3rd argument to `router.method`,
|
|
33
|
+
as an array, and runs before the handler.
|
|
34
|
+
|
|
35
|
+
See `apps/backend/src/main.ts` for the full bootstrap.
|
package/package.json
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@bhooai/nexus-core",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"publishConfig": { "access": "public" },
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./src/index.ts",
|
|
7
|
+
"types": "./src/index.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./src/index.ts",
|
|
11
|
+
"import": "./src/index.ts"
|
|
12
|
+
},
|
|
13
|
+
"./config": {
|
|
14
|
+
"types": "./src/config/index.ts",
|
|
15
|
+
"import": "./src/config/index.ts"
|
|
16
|
+
},
|
|
17
|
+
"./di": {
|
|
18
|
+
"types": "./src/di/index.ts",
|
|
19
|
+
"import": "./src/di/index.ts"
|
|
20
|
+
},
|
|
21
|
+
"./http": {
|
|
22
|
+
"types": "./src/http/index.ts",
|
|
23
|
+
"import": "./src/http/index.ts"
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
"scripts": {
|
|
27
|
+
"build": "tsc -p tsconfig.json",
|
|
28
|
+
"test": "vitest run"
|
|
29
|
+
},
|
|
30
|
+
"dependencies": {
|
|
31
|
+
"zod": "^3.23.8"
|
|
32
|
+
},
|
|
33
|
+
"devDependencies": {
|
|
34
|
+
"@types/node": "^22.5.0",
|
|
35
|
+
"typescript": "^5.6.2",
|
|
36
|
+
"vitest": "^2.1.1"
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { dirname, join, resolve } from 'node:path';
|
|
3
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
4
|
+
import { defaults } from './defaults.js';
|
|
5
|
+
import { deepMerge } from './merge.js';
|
|
6
|
+
import { configFromEnv, loadEnvFile } from './env.js';
|
|
7
|
+
import { nexusConfigSchema } from './schema.js';
|
|
8
|
+
import type { DeepPartial, NexusConfig, UserNexusConfig } from './types.js';
|
|
9
|
+
|
|
10
|
+
export interface LoadOptions {
|
|
11
|
+
/** The imported user config object (from `nexus.config.ts`). */
|
|
12
|
+
userConfig?: UserNexusConfig;
|
|
13
|
+
/** Path to a `nexus.config.ts`/`.js` to import dynamically if `userConfig` not given. */
|
|
14
|
+
userConfigPath?: string;
|
|
15
|
+
/** Path to a shipped default config (the framework's `nexus/nexus.config.ts`) merged right
|
|
16
|
+
* after the code defaults and before the user config. */
|
|
17
|
+
defaultConfigPath?: string;
|
|
18
|
+
/** Path to `nexus.runtime.json` (admin write-back). Defaults to `<root>/nexus.runtime.json`. */
|
|
19
|
+
runtimePath?: string;
|
|
20
|
+
/** Project root used to resolve relative paths. Defaults to cwd. */
|
|
21
|
+
root?: string;
|
|
22
|
+
/** Env override (for tests). */
|
|
23
|
+
env?: NodeJS.ProcessEnv;
|
|
24
|
+
/** CLI flag overrides (highest precedence). */
|
|
25
|
+
cli?: DeepPartial<NexusConfig>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Load and merge configuration in precedence order:
|
|
30
|
+
* defaults < userConfig (nexus.config.ts) < runtime.json < env < cli
|
|
31
|
+
* Then validate with the zod schema and return a frozen, typed config.
|
|
32
|
+
*/
|
|
33
|
+
export async function loadConfig(opts: LoadOptions = {}): Promise<NexusConfig> {
|
|
34
|
+
const root = resolve(opts.root ?? process.cwd());
|
|
35
|
+
const env = opts.env ?? process.env;
|
|
36
|
+
|
|
37
|
+
// 0. project .env — merged into the env object BEFORE env-var mapping below.
|
|
38
|
+
// Skipped when a caller passes its own env (tests), which must stay pure.
|
|
39
|
+
if (!opts.env) loadEnvFile(root, env);
|
|
40
|
+
|
|
41
|
+
// 1. defaults
|
|
42
|
+
let merged: unknown = defaults;
|
|
43
|
+
|
|
44
|
+
// 1b. shipped default config (the framework's nexus.config.ts) — base layer above code defaults
|
|
45
|
+
if (opts.defaultConfigPath && existsSync(resolve(opts.defaultConfigPath))) {
|
|
46
|
+
const defaultConfig = await importUserConfig(resolve(opts.defaultConfigPath));
|
|
47
|
+
merged = deepMerge(merged, defaultConfig);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// 2. user config (nexus.config.ts / nexus.config.js)
|
|
51
|
+
let userConfig = opts.userConfig;
|
|
52
|
+
if (!userConfig && opts.userConfigPath) {
|
|
53
|
+
userConfig = await importUserConfig(resolve(opts.userConfigPath));
|
|
54
|
+
}
|
|
55
|
+
if (userConfig) merged = deepMerge(merged, userConfig);
|
|
56
|
+
|
|
57
|
+
// 3. runtime.json (admin write-back, gitignored)
|
|
58
|
+
const runtimePath = opts.runtimePath ?? resolve(root, 'nexus.runtime.json');
|
|
59
|
+
if (existsSync(runtimePath)) {
|
|
60
|
+
try {
|
|
61
|
+
const runtime = JSON.parse(readFileSync(runtimePath, 'utf8')) as DeepPartial<NexusConfig>;
|
|
62
|
+
merged = deepMerge(merged, runtime);
|
|
63
|
+
} catch {
|
|
64
|
+
// A corrupt runtime file must not crash boot; ignore it.
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// 4. env
|
|
69
|
+
merged = deepMerge(merged, configFromEnv(env));
|
|
70
|
+
|
|
71
|
+
// 5. cli flags (highest)
|
|
72
|
+
if (opts.cli) merged = deepMerge(merged, opts.cli);
|
|
73
|
+
|
|
74
|
+
// validate
|
|
75
|
+
const parsed = nexusConfigSchema.parse(merged);
|
|
76
|
+
return Object.freeze(parsed) as NexusConfig;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function importUserConfig(absPath: string): Promise<UserNexusConfig> {
|
|
80
|
+
const url = pathToFileURL(absPath).href;
|
|
81
|
+
const mod = (await import(url)) as Record<string, unknown>;
|
|
82
|
+
const cfg = (mod.default ?? mod.config) as UserNexusConfig | undefined;
|
|
83
|
+
if (!cfg) {
|
|
84
|
+
throw new Error(
|
|
85
|
+
`Expected ${absPath} to export a config object as the default export or named "config".`,
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
return cfg;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Resolve a path relative to the project root. */
|
|
92
|
+
export function resolvePath(cfg: NexusConfig, rel: string): string {
|
|
93
|
+
return resolve(rel);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const CONFIG_EXTENSIONS = ['ts', 'js', 'mjs', 'cjs'] as const;
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Path to the framework's own shipped `nexus.config.ts` (at the framework workspace root).
|
|
100
|
+
* Resolved relative to THIS file so it works from both `src/` (run via tsx) and `dist/`
|
|
101
|
+
* (compiled). When `@bhooai/nexus-core` is installed inside a user app's `node_modules/`,
|
|
102
|
+
* this resolves to a non-existent path under `node_modules/` — so it is simply skipped there
|
|
103
|
+
* (a user app does not inherit the framework's example config).
|
|
104
|
+
*/
|
|
105
|
+
export function frameworkDefaultConfigPath(): string {
|
|
106
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
107
|
+
// src/config/ -> src -> <pkg> -> packages -> <framework root> (4 levels up)
|
|
108
|
+
return resolve(here, '..', '..', '..', '..', 'nexus.config.ts');
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* First `nexus.config.{ts,js,mjs,cjs}` found walking UP from `root`, skipping `exclude`.
|
|
113
|
+
* Standard find-first discovery; stops at the filesystem root.
|
|
114
|
+
*/
|
|
115
|
+
export function discoverUserConfigPath(root: string, exclude?: string): string | undefined {
|
|
116
|
+
let dir = resolve(root);
|
|
117
|
+
const excl = exclude ? resolve(exclude) : undefined;
|
|
118
|
+
for (let i = 0; i < 24; i++) {
|
|
119
|
+
for (const ext of CONFIG_EXTENSIONS) {
|
|
120
|
+
const cand = join(dir, `nexus.config.${ext}`);
|
|
121
|
+
if (existsSync(cand) && (!excl || resolve(cand) !== excl)) return cand;
|
|
122
|
+
}
|
|
123
|
+
const parent = dirname(dir);
|
|
124
|
+
if (parent === dir) break; // filesystem root
|
|
125
|
+
dir = parent;
|
|
126
|
+
}
|
|
127
|
+
return undefined;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Auto-discover and load config with the framework's two-level model:
|
|
132
|
+
* defaults < framework default (`nexus/nexus.config.ts`) < user config < runtime.json < env < cli
|
|
133
|
+
*
|
|
134
|
+
* The framework default is located via `frameworkDefaultConfigPath()` (the shipped
|
|
135
|
+
* `nexus/nexus.config.ts`). The user config is the nearest `nexus.config.{ts,js,...}` walking
|
|
136
|
+
* up from `root`, SKIPPING that default — so when running the framework's own app (cwd =
|
|
137
|
+
* `nexus/`), the discovery walks past `nexus/nexus.config.ts` and finds the user-editable
|
|
138
|
+
* `nexus.config.js` at the project root, which overrides the default. When running a user app,
|
|
139
|
+
* the framework default path does not exist, so only that app's own config is loaded.
|
|
140
|
+
*/
|
|
141
|
+
export async function loadConfigAuto(
|
|
142
|
+
opts: Omit<LoadOptions, 'userConfig' | 'userConfigPath' | 'defaultConfigPath'> = {},
|
|
143
|
+
): Promise<NexusConfig> {
|
|
144
|
+
const root = resolve(opts.root ?? process.cwd());
|
|
145
|
+
const defaultPath = frameworkDefaultConfigPath();
|
|
146
|
+
const defaultExists = existsSync(defaultPath);
|
|
147
|
+
const userPath = defaultExists
|
|
148
|
+
? discoverUserConfigPath(root, defaultPath)
|
|
149
|
+
: discoverUserConfigPath(root);
|
|
150
|
+
return loadConfig({
|
|
151
|
+
...opts,
|
|
152
|
+
root,
|
|
153
|
+
defaultConfigPath: defaultExists ? defaultPath : undefined,
|
|
154
|
+
userConfigPath: userPath,
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Read-only accessor for tests / bootstrap that don't need file loading. */
|
|
159
|
+
export function mergeConfig(...sources: DeepPartial<NexusConfig>[]): NexusConfig {
|
|
160
|
+
return Object.freeze(nexusConfigSchema.parse(deepMerge(defaults, ...sources))) as NexusConfig;
|
|
161
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import type { NexusConfig } from './types.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Code-level defaults — the lowest-precedence layer of the config stack.
|
|
5
|
+
* Every field is present so a config that supplies nothing still boots.
|
|
6
|
+
*/
|
|
7
|
+
export const defaults: NexusConfig = {
|
|
8
|
+
env: 'development',
|
|
9
|
+
server: {
|
|
10
|
+
port: 4000,
|
|
11
|
+
host: '127.0.0.1',
|
|
12
|
+
https: false,
|
|
13
|
+
trustProxy: false,
|
|
14
|
+
bodyLimit: 12 * 1024 * 1024, // 12 MiB, allowing a 10 MiB upload plus multipart overhead
|
|
15
|
+
},
|
|
16
|
+
uploads: {
|
|
17
|
+
dir: 'uploads',
|
|
18
|
+
path: '/uploads',
|
|
19
|
+
maxFileSize: 10 * 1024 * 1024,
|
|
20
|
+
maxFiles: 20,
|
|
21
|
+
allowedTypes: [],
|
|
22
|
+
},
|
|
23
|
+
db: {
|
|
24
|
+
uri: 'mongodb://localhost:27017/nexus',
|
|
25
|
+
maxPoolSize: 10,
|
|
26
|
+
autoIndex: true,
|
|
27
|
+
},
|
|
28
|
+
redis: {
|
|
29
|
+
url: 'redis://localhost:6379',
|
|
30
|
+
keyPrefix: 'nexus:',
|
|
31
|
+
},
|
|
32
|
+
graphql: {
|
|
33
|
+
path: '/graphql',
|
|
34
|
+
federation: 'in-process',
|
|
35
|
+
subscriptions: true,
|
|
36
|
+
introspection: true,
|
|
37
|
+
},
|
|
38
|
+
ws: {
|
|
39
|
+
path: '/ws',
|
|
40
|
+
heartbeatMs: 30_000,
|
|
41
|
+
requireCsrf: true,
|
|
42
|
+
},
|
|
43
|
+
auth: {
|
|
44
|
+
jwt: {
|
|
45
|
+
secret: 'change-me-please',
|
|
46
|
+
accessTtl: 60 * 15, // 15 min
|
|
47
|
+
refreshTtl: 60 * 60 * 24 * 30, // 30 days
|
|
48
|
+
issuer: 'bhooai-nexus',
|
|
49
|
+
audience: 'bhooai-nexus-client',
|
|
50
|
+
},
|
|
51
|
+
cookieName: 'nexus_sid',
|
|
52
|
+
refreshCookieName: 'nexus_rid',
|
|
53
|
+
requireEmailVerification: false,
|
|
54
|
+
google: { clientId: '', clientSecret: '', callbackPath: '/auth/google/callback', scope: 'openid email profile' },
|
|
55
|
+
facebook: { clientId: '', clientSecret: '', callbackPath: '/auth/facebook/callback', scope: 'email' },
|
|
56
|
+
},
|
|
57
|
+
payments: {
|
|
58
|
+
webhookPath: '/payments/webhook/:provider',
|
|
59
|
+
currency: 'INR',
|
|
60
|
+
},
|
|
61
|
+
email: {
|
|
62
|
+
provider: 'log',
|
|
63
|
+
from: 'no-reply@nexus.local',
|
|
64
|
+
},
|
|
65
|
+
certs: {
|
|
66
|
+
dir: 'certs',
|
|
67
|
+
keyType: 'rsa',
|
|
68
|
+
rsaModulus: 2048,
|
|
69
|
+
ecCurve: 'prime256v1',
|
|
70
|
+
validityDays: 365,
|
|
71
|
+
},
|
|
72
|
+
ads: {
|
|
73
|
+
enabled: false,
|
|
74
|
+
developerToken: '',
|
|
75
|
+
clientId: '',
|
|
76
|
+
clientSecret: '',
|
|
77
|
+
refreshToken: '',
|
|
78
|
+
customerId: '',
|
|
79
|
+
},
|
|
80
|
+
webrtc: {
|
|
81
|
+
rtcMinPort: 40000,
|
|
82
|
+
rtcMaxPort: 40100,
|
|
83
|
+
announceIp: '127.0.0.1',
|
|
84
|
+
},
|
|
85
|
+
ai: {
|
|
86
|
+
serverUrl: 'http://localhost:8000',
|
|
87
|
+
timeoutMs: 60_000,
|
|
88
|
+
defaultProvider: 'auto',
|
|
89
|
+
schemaModel: 'gpt-4o-mini',
|
|
90
|
+
providers: [
|
|
91
|
+
{ id: 'ollama', label: 'Ollama (local)', baseUrl: 'http://localhost:11434', enabled: true, defaultModel: 'llama3:latest' },
|
|
92
|
+
{ id: 'openai', label: 'OpenAI', baseUrl: 'https://api.openai.com/v1', enabled: false, defaultModel: 'gpt-4o-mini' },
|
|
93
|
+
{ id: 'anthropic', label: 'Anthropic Claude', baseUrl: 'https://api.anthropic.com/v1', enabled: false, defaultModel: 'claude-sonnet-4-20250514' },
|
|
94
|
+
{ id: 'google', label: 'Google Gemini', baseUrl: 'https://generativelanguage.googleapis.com/v1', enabled: false, defaultModel: 'gemini-2.0-flash' },
|
|
95
|
+
{ id: 'groq', label: 'Groq', baseUrl: 'https://api.groq.com/openai/v1', enabled: false, defaultModel: 'llama-3.3-70b-versatile' },
|
|
96
|
+
{ id: 'mistral', label: 'Mistral AI', baseUrl: 'https://api.mistral.ai/v1', enabled: false, defaultModel: 'mistral-large-latest' },
|
|
97
|
+
{ id: 'cohere', label: 'Cohere', baseUrl: 'https://api.cohere.ai/v1', enabled: false, defaultModel: 'command-r-plus' },
|
|
98
|
+
{ id: 'together', label: 'Together AI', baseUrl: 'https://api.together.xyz/v1', enabled: false, defaultModel: 'meta-llama/Llama-3-70b-chat-hf' },
|
|
99
|
+
{ id: 'fireworks', label: 'Fireworks AI', baseUrl: 'https://api.fireworks.ai/inference/v1', enabled: false, defaultModel: 'accounts/fireworks/models/llama-v3-70b-instruct' },
|
|
100
|
+
{ id: 'deepseek', label: 'DeepSeek', baseUrl: 'https://api.deepseek.com/v1', enabled: false, defaultModel: 'deepseek-chat' },
|
|
101
|
+
{ id: 'perplexity', label: 'Perplexity', baseUrl: 'https://api.perplexity.ai', enabled: false, defaultModel: 'llama-3.1-sonar-large-128k-online' },
|
|
102
|
+
{ id: 'xai', label: 'xAI (Grok)', baseUrl: 'https://api.x.ai/v1', enabled: false, defaultModel: 'grok-2-latest' },
|
|
103
|
+
{ id: 'replicate', label: 'Replicate', baseUrl: 'https://api.replicate.com/v1', enabled: false, defaultModel: 'meta/llama-3-70b-instruct' },
|
|
104
|
+
{ id: 'huggingface', label: 'Hugging Face', baseUrl: 'https://api-inference.huggingface.co/models', enabled: false, defaultModel: 'meta-llama/Llama-3-70b-chat-hf' },
|
|
105
|
+
{ id: 'nvidia', label: 'NVIDIA NIM', baseUrl: 'https://integrate.api.nvidia.com/v1', enabled: false, defaultModel: 'meta/llama-3.1-70b-instruct' },
|
|
106
|
+
{ id: 'openrouter', label: 'OpenRouter', baseUrl: 'https://openrouter.ai/api/v1', enabled: false, defaultModel: 'openai/gpt-4o-mini' },
|
|
107
|
+
{ id: 'lmstudio', label: 'LM Studio (local)', baseUrl: 'http://localhost:1234/v1', enabled: false, defaultModel: 'local-model' },
|
|
108
|
+
{ id: 'alephalpha', label: 'Aleph Alpha', baseUrl: 'https://api.aleph-alpha.com/v1', enabled: false, defaultModel: 'luminous-supreme-control' },
|
|
109
|
+
{ id: 'stability', label: 'Stability AI', baseUrl: 'https://api.stability.ai/v1', enabled: false, defaultModel: 'stable-diffusion-xl' },
|
|
110
|
+
{ id: 'azure', label: 'Azure OpenAI', baseUrl: 'https://your-resource.openai.azure.com', enabled: false, defaultModel: 'gpt-4o-mini' },
|
|
111
|
+
],
|
|
112
|
+
},
|
|
113
|
+
logging: {
|
|
114
|
+
level: 'info',
|
|
115
|
+
format: 'pretty',
|
|
116
|
+
console: true,
|
|
117
|
+
dir: 'logs',
|
|
118
|
+
maxFileSize: 10 * 1024 * 1024, // 10 MiB
|
|
119
|
+
maxFiles: 7,
|
|
120
|
+
},
|
|
121
|
+
plugins: {
|
|
122
|
+
dir: 'plugins',
|
|
123
|
+
entries: [],
|
|
124
|
+
},
|
|
125
|
+
frontend: {
|
|
126
|
+
port: 3000,
|
|
127
|
+
host: 'localhost',
|
|
128
|
+
enabled: true,
|
|
129
|
+
},
|
|
130
|
+
admin: {
|
|
131
|
+
port: 3001,
|
|
132
|
+
host: 'localhost',
|
|
133
|
+
enabled: true,
|
|
134
|
+
},
|
|
135
|
+
cluster: {
|
|
136
|
+
enabled: false,
|
|
137
|
+
failOpenSingleNode: true,
|
|
138
|
+
lbHost: '127.0.0.1',
|
|
139
|
+
lbPort: 8080,
|
|
140
|
+
nodeAgentHost: '127.0.0.1',
|
|
141
|
+
nodeAgentPort: 7575,
|
|
142
|
+
registryFile: 'cluster.runtime.json',
|
|
143
|
+
token: '',
|
|
144
|
+
autoscale: {
|
|
145
|
+
enabled: true,
|
|
146
|
+
mode: 'auto',
|
|
147
|
+
minNodes: 1,
|
|
148
|
+
maxNodes: 4,
|
|
149
|
+
cooldownMs: 60_000,
|
|
150
|
+
cpuHigh: 80,
|
|
151
|
+
rpsPerNodeHigh: 15,
|
|
152
|
+
rpsPerNodeLow: 5,
|
|
153
|
+
},
|
|
154
|
+
},
|
|
155
|
+
};
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
import type { DeepPartial, NexusConfig } from './types.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Load `<root>/.env` into `env` WITHOUT overriding variables that are already
|
|
7
|
+
* set (a real environment value always wins). Dependency-free: `KEY=VALUE`
|
|
8
|
+
* (optional `export ` prefix), `#` comments, blank lines, optional quotes.
|
|
9
|
+
*
|
|
10
|
+
* The CLI used to be the only `.env` loader, so a backend started directly
|
|
11
|
+
* (tsx, plain node) never saw `.env` secrets. Every config consumer now loads
|
|
12
|
+
* the project `.env` itself; double-loading is harmless (idempotent).
|
|
13
|
+
*/
|
|
14
|
+
export function loadEnvFile(root: string, env: NodeJS.ProcessEnv): void {
|
|
15
|
+
const file = resolve(root, '.env');
|
|
16
|
+
if (!existsSync(file)) return;
|
|
17
|
+
let content: string;
|
|
18
|
+
try {
|
|
19
|
+
content = readFileSync(file, 'utf8');
|
|
20
|
+
} catch {
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
for (const rawLine of content.split(/\r?\n/)) {
|
|
24
|
+
const line = rawLine.trim();
|
|
25
|
+
if (!line || line.startsWith('#')) continue;
|
|
26
|
+
const m = /^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/.exec(line);
|
|
27
|
+
if (!m) continue;
|
|
28
|
+
const key = m[1]!;
|
|
29
|
+
if (env[key] !== undefined) continue; // real env always wins
|
|
30
|
+
let value = m[2]!.trim();
|
|
31
|
+
if (value.length >= 2 && ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'")))) {
|
|
32
|
+
value = value.slice(1, -1);
|
|
33
|
+
}
|
|
34
|
+
env[key] = value;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Build a config-override object from `NEXUS_*` environment variables.
|
|
40
|
+
* Nested keys use `_` as a separator, e.g. `NEXUS_SERVER_PORT` -> { server: { port } }.
|
|
41
|
+
*
|
|
42
|
+
* Type coercion: integers for numeric fields, booleans for true/false,
|
|
43
|
+
* comma lists for arrays where applicable (heuristic by key).
|
|
44
|
+
*/
|
|
45
|
+
function coerce(value: string): string | number | boolean {
|
|
46
|
+
const v = value.trim();
|
|
47
|
+
if (/^-?\d+$/.test(v)) return Number.parseInt(v, 10);
|
|
48
|
+
if (v.toLowerCase() === 'true') return true;
|
|
49
|
+
if (v.toLowerCase() === 'false') return false;
|
|
50
|
+
return v;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Two `_`-separated env segments that form ONE camelCase config field.
|
|
55
|
+
* Generic splitting would turn `KEY_ID` into `key.id` (a nested object) —
|
|
56
|
+
* these known compounds collapse into a single tree key:
|
|
57
|
+
* NEXUS_PAYMENTS_RAZORPAY_KEY_ID -> payments.razorpay.keyId
|
|
58
|
+
* NEXUS_AUTH_GOOGLE_CLIENT_SECRET -> auth.google.clientSecret
|
|
59
|
+
*/
|
|
60
|
+
const PAIR_FIELDS: Record<string, string> = {
|
|
61
|
+
'key_id': 'keyId',
|
|
62
|
+
'key_secret': 'keySecret',
|
|
63
|
+
'client_id': 'clientId',
|
|
64
|
+
'client_secret': 'clientSecret',
|
|
65
|
+
'merchant_key': 'merchantKey',
|
|
66
|
+
'merchant_email': 'merchantEmail',
|
|
67
|
+
'program_id': 'programId',
|
|
68
|
+
'api_key': 'apiKey',
|
|
69
|
+
'webhook_secret': 'webhookSecret',
|
|
70
|
+
'customer_id': 'customerId',
|
|
71
|
+
'developer_token': 'developerToken',
|
|
72
|
+
'refresh_token': 'refreshToken',
|
|
73
|
+
'access_ttl': 'accessTtl',
|
|
74
|
+
'refresh_ttl': 'refreshTtl',
|
|
75
|
+
'callback_path': 'callbackPath',
|
|
76
|
+
'lb_port': 'lbPort',
|
|
77
|
+
'lb_host': 'lbHost',
|
|
78
|
+
'node_agent_port': 'nodeAgentPort',
|
|
79
|
+
'node_agent_host': 'nodeAgentHost',
|
|
80
|
+
'registry_file': 'registryFile',
|
|
81
|
+
'min_nodes': 'minNodes',
|
|
82
|
+
'max_nodes': 'maxNodes',
|
|
83
|
+
'cooldown_ms': 'cooldownMs',
|
|
84
|
+
'cpu_high': 'cpuHigh',
|
|
85
|
+
'rps_per_node_high': 'rpsPerNodeHigh',
|
|
86
|
+
'rps_per_node_low': 'rpsPerNodeLow',
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
export function configFromEnv(env: NodeJS.ProcessEnv = process.env): DeepPartial<NexusConfig> {
|
|
90
|
+
const out: Record<string, unknown> = {};
|
|
91
|
+
for (const [rawKey, rawValue] of Object.entries(env)) {
|
|
92
|
+
if (!rawKey.startsWith('NEXUS_') || rawValue === undefined) continue;
|
|
93
|
+
const path = rawKey.slice('NEXUS_'.length).toLowerCase().split('_');
|
|
94
|
+
const last = path.length - 1;
|
|
95
|
+
if (last >= 1) {
|
|
96
|
+
const compound = PAIR_FIELDS[`${path[last - 1]}_${path[last]}`];
|
|
97
|
+
if (compound) {
|
|
98
|
+
path.splice(last - 1, 2, compound);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
let node: Record<string, unknown> = out;
|
|
102
|
+
for (let i = 0; i < path.length; i++) {
|
|
103
|
+
const segment = path[i]!;
|
|
104
|
+
if (i === path.length - 1) {
|
|
105
|
+
node[segment] = coerce(rawValue);
|
|
106
|
+
} else {
|
|
107
|
+
node[segment] = (node[segment] as Record<string, unknown>) ?? {};
|
|
108
|
+
node = node[segment] as Record<string, unknown>;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return out as DeepPartial<NexusConfig>;
|
|
113
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deep merge of plain objects. Later sources win. Arrays are replaced, not
|
|
3
|
+
* concatenated (config arrays are declarative). Non-plain objects (e.g. Date,
|
|
4
|
+
* RegExp) are assigned by reference.
|
|
5
|
+
*/
|
|
6
|
+
export function isPlainObject(v: unknown): v is Record<string, unknown> {
|
|
7
|
+
if (v === null || typeof v !== 'object') return false;
|
|
8
|
+
const proto = Object.getPrototypeOf(v) as unknown;
|
|
9
|
+
return proto === Object.prototype || proto === null;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function deepMerge<T>(base: T, ...sources: unknown[]): T {
|
|
13
|
+
if (!isPlainObject(base)) return (sources.at(-1) as T) ?? base;
|
|
14
|
+
const out: Record<string, unknown> = { ...(base as Record<string, unknown>) };
|
|
15
|
+
for (const source of sources) {
|
|
16
|
+
if (!isPlainObject(source)) continue;
|
|
17
|
+
for (const [key, value] of Object.entries(source)) {
|
|
18
|
+
if (value === undefined) continue;
|
|
19
|
+
const current = out[key];
|
|
20
|
+
if (isPlainObject(current) && isPlainObject(value)) {
|
|
21
|
+
out[key] = deepMerge(current, value);
|
|
22
|
+
} else {
|
|
23
|
+
out[key] = value;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return out as T;
|
|
28
|
+
}
|