@bhooai/nexus-cli 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/PLAN.md +141 -0
- package/README.md +34 -0
- package/package.json +25 -0
- package/src/commands/cluster.ts +133 -0
- package/src/commands/dev.ts +133 -0
- package/src/commands/doctor.ts +199 -0
- package/src/commands/init.ts +960 -0
- package/src/commands/node.ts +101 -0
- package/src/commands/pysetup.ts +136 -0
- package/src/commands/sync.ts +116 -0
- package/src/commands/uninstall.ts +287 -0
- package/src/config-sync.ts +384 -0
- package/src/dotenv.ts +39 -0
- package/src/index.ts +94 -0
- package/src/supervisor.ts +384 -0
- package/src/util.ts +123 -0
- package/src/wizard.ts +149 -0
- package/templates/Dockerfile +60 -0
- package/templates/README.md +69 -0
- package/templates/apps/admin/index.html +12 -0
- package/templates/apps/admin/package.json +24 -0
- package/templates/apps/admin/postcss.config.js +6 -0
- package/templates/apps/admin/src/main.tsx +10 -0
- package/templates/apps/admin/src/vite-env.d.ts +18 -0
- package/templates/apps/admin/tailwind.config.js +9 -0
- package/templates/apps/admin/tsconfig.json +17 -0
- package/templates/apps/admin/vite.config.ts +64 -0
- package/templates/apps/ai-server/main.py +43 -0
- package/templates/apps/ai-server/providers/__init__.py +3 -0
- package/templates/apps/ai-server/providers/base.py +111 -0
- package/templates/apps/ai-server/requirements.txt +3 -0
- package/templates/apps/ai-server/routers/__init__.py +3 -0
- package/templates/apps/ai-server/routers/chat.py +47 -0
- package/templates/apps/ai-server/routers/embeddings.py +30 -0
- package/templates/apps/ai-server/routers/lint.py +167 -0
- package/templates/apps/ai-server/routers/models.py +23 -0
- package/templates/apps/ai-server/routers/preflight.py +169 -0
- package/templates/apps/ai-server/settings.py +48 -0
- package/templates/apps/backend/package.json +33 -0
- package/templates/apps/backend/src/main.ts +375 -0
- package/templates/apps/backend/src/modules/admin/adminRoutes.ts +732 -0
- package/templates/apps/backend/src/modules/admin/clusterRoutes.ts +391 -0
- package/templates/apps/backend/src/modules/admin/databaseRoutes.ts +161 -0
- package/templates/apps/backend/src/modules/admin/lintProxy.ts +89 -0
- package/templates/apps/backend/src/modules/admin/preflightProxy.ts +242 -0
- package/templates/apps/backend/src/modules/admin/roleCatalog.ts +78 -0
- package/templates/apps/backend/src/modules/admin/schemaRoutes.ts +449 -0
- package/templates/apps/backend/src/modules/ai/aiProxy.ts +265 -0
- package/templates/apps/backend/src/modules/auth/authRoutes.ts +220 -0
- package/templates/apps/backend/src/modules/payments/paymentRoutes.ts +100 -0
- package/templates/apps/backend/src/modules/payments/paymentStore.ts +172 -0
- package/templates/apps/backend/src/modules/requests/requestLog.ts +175 -0
- package/templates/apps/backend/src/modules/users/userGraph.ts +83 -0
- package/templates/apps/backend/src/modules/users/userModel.ts +88 -0
- package/templates/apps/backend/src/plugins/CronScheduler.ts +69 -0
- package/templates/apps/backend/src/plugins/loadPlugins.ts +107 -0
- package/templates/apps/backend/tsconfig.json +14 -0
- package/templates/apps/frontend/index.html +12 -0
- package/templates/apps/frontend/package.json +19 -0
- package/templates/apps/frontend/src/main.tsx +64 -0
- package/templates/apps/frontend/vite.config.ts +63 -0
- package/templates/bin/nexus.js +35 -0
- package/templates/bin/serve-all.mjs +45 -0
- package/templates/dockerignore +15 -0
- package/templates/gitignore +12 -0
- package/templates/nexus.config.ts +69 -0
- package/templates/package.json +47 -0
- package/templates/tsconfig.json +17 -0
- package/templates/uploads/.gitkeep +0 -0
- package/tests/cli.test.ts +45 -0
- package/tests/config-sync.test.ts +201 -0
- package/tests/dotenv.test.ts +51 -0
- package/tsconfig.json +9 -0
- package/vitest.config.ts +9 -0
- package/vitest.config.ts.timestamp-1786095205351-444061f6ff5c58.mjs +13 -0
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
import { dirname, join, resolve } from 'node:path';
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { loadConfig, loadConfigAuto, Router, NexusServer, bodyParser, serveStatic, registerUploadRoutes } from '@bhooai/nexus-core';
|
|
5
|
+
import { cors, csrf, securityHeaders, rateLimit, issueCsrfToken, AuthService, MemorySessionStore } from '@bhooai/nexus-auth';
|
|
6
|
+
import { Logger, MetricsRegistry } from '@bhooai/nexus-telemetry';
|
|
7
|
+
import {
|
|
8
|
+
connect,
|
|
9
|
+
getConnection,
|
|
10
|
+
resolveProjectInfo,
|
|
11
|
+
connectProjectInfo,
|
|
12
|
+
getProjectInfo,
|
|
13
|
+
listProjectInfo,
|
|
14
|
+
upsertProjectInfo,
|
|
15
|
+
closeProjectInfo,
|
|
16
|
+
type ProjectInfo,
|
|
17
|
+
} from '@bhooai/nexus-data';
|
|
18
|
+
import { RealtimeServer } from '@bhooai/nexus-realtime';
|
|
19
|
+
import { createGateway, graphqlHttpHandler, SubscriptionServer, PubSub, type GraphQLContext } from '@bhooai/nexus-graphql';
|
|
20
|
+
import { Cache } from '@bhooai/nexus-cache';
|
|
21
|
+
import { createEmail, TemplateEngine } from '@bhooai/nexus-email';
|
|
22
|
+
import { createPayments } from '@bhooai/nexus-payments';
|
|
23
|
+
import { generateKeyPair, createSelfSignedCertificate, createCsr } from '@bhooai/nexus-crypto';
|
|
24
|
+
import { ClusterManager } from '@bhooai/nexus-cluster';
|
|
25
|
+
import { registerAuthRoutes } from './modules/auth/authRoutes.js';
|
|
26
|
+
import { buildUsersSubgraph } from './modules/users/userGraph.js';
|
|
27
|
+
import { loadPlugins } from './plugins/loadPlugins.js';
|
|
28
|
+
import { registerAiRoutes } from './modules/ai/aiProxy.js';
|
|
29
|
+
import { registerAdminRoutes } from './modules/admin/adminRoutes.js';
|
|
30
|
+
import { registerPaymentRoutes } from './modules/payments/paymentRoutes.js';
|
|
31
|
+
import { recordTransaction } from './modules/payments/paymentStore.js';
|
|
32
|
+
import { appendRequestLog, closeRequestLog } from './modules/requests/requestLog.js';
|
|
33
|
+
|
|
34
|
+
// Keep project-relative config and runtime directories tied to this app tree,
|
|
35
|
+
// not to the directory from which the backend command was launched.
|
|
36
|
+
const PROJECT_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..', '..');
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* BhooAI Nexus backend bootstrap (Phase 5 checkpoint).
|
|
40
|
+
*
|
|
41
|
+
* Boots an inbuilt HTTP server with the security stack applied:
|
|
42
|
+
* security headers → CORS → body parsing → CSRF → rate limiting.
|
|
43
|
+
* Connects to MongoDB, registers the User model, mounts /auth/* routes,
|
|
44
|
+
* and serves a single-subgraph GraphQL gateway over HTTP (/graphql) and
|
|
45
|
+
* WebSocket subscriptions (/graphql/ws). Realtime (/ws) runs alongside.
|
|
46
|
+
*/
|
|
47
|
+
async function main(): Promise<void> {
|
|
48
|
+
const config = await loadConfigAuto({ root: PROJECT_ROOT });
|
|
49
|
+
const log = new Logger({
|
|
50
|
+
level: config.logging.level,
|
|
51
|
+
format: config.logging.format,
|
|
52
|
+
console: config.logging.console,
|
|
53
|
+
redact: ['password', 'secret', 'authorization'],
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
// Each project owns its own database (named from its package.json). The
|
|
57
|
+
// default connection targets that database; a second connection targets the
|
|
58
|
+
// shared project-info store (nexus_projects) for name + settings.
|
|
59
|
+
const project: ProjectInfo = await resolveProjectInfo(PROJECT_ROOT);
|
|
60
|
+
const dbName = config.db.name ?? project.dbName;
|
|
61
|
+
|
|
62
|
+
connect(config.db.uri, { name: dbName, autoIndex: config.db.autoIndex });
|
|
63
|
+
await getConnection().db;
|
|
64
|
+
connectProjectInfo(config.db.uri, { autoIndex: config.db.autoIndex });
|
|
65
|
+
const allProjects = await listProjectInfo();
|
|
66
|
+
// Prefer the record matched by path so a project renamed via the admin panel
|
|
67
|
+
// keeps its identity (and settings) across restarts.
|
|
68
|
+
const storedProject = allProjects.find((p) => p.path === PROJECT_ROOT) ?? (await getProjectInfo(project.name).catch(() => null));
|
|
69
|
+
const configuredAiProviders = (config.ai.providers ?? []).map((provider) => ({
|
|
70
|
+
id: provider.id,
|
|
71
|
+
label: provider.label,
|
|
72
|
+
baseUrl: provider.baseUrl,
|
|
73
|
+
enabled: provider.enabled,
|
|
74
|
+
...(provider.defaultModel ? { defaultModel: provider.defaultModel } : {}),
|
|
75
|
+
}));
|
|
76
|
+
await upsertProjectInfo({
|
|
77
|
+
...project,
|
|
78
|
+
dbName,
|
|
79
|
+
status: 'running',
|
|
80
|
+
startedAt: new Date().toISOString(),
|
|
81
|
+
version: storedProject?.version ?? await readPackageVersion(PROJECT_ROOT),
|
|
82
|
+
settings: {
|
|
83
|
+
...(storedProject?.settings ?? {}),
|
|
84
|
+
env: config.env,
|
|
85
|
+
host: config.server.host,
|
|
86
|
+
port: config.server.port,
|
|
87
|
+
graphqlPath: config.graphql.path,
|
|
88
|
+
websocketPath: config.ws.path,
|
|
89
|
+
database: dbName,
|
|
90
|
+
...(!storedProject?.settings?.aiProviders && configuredAiProviders.length > 0
|
|
91
|
+
? { aiProviders: configuredAiProviders }
|
|
92
|
+
: {}),
|
|
93
|
+
},
|
|
94
|
+
});
|
|
95
|
+
log.info('connected to MongoDB', { project: project.name, db: dbName });
|
|
96
|
+
|
|
97
|
+
// One AuthService shared by realtime + GraphQL context (token verification
|
|
98
|
+
// is stateless; the session store is only used for login/refresh flows).
|
|
99
|
+
const authService = new AuthService(
|
|
100
|
+
{ secret: config.auth.jwt.secret, algorithm: 'HS256', issuer: config.auth.jwt.issuer, audience: config.auth.jwt.audience, accessTtl: config.auth.jwt.accessTtl, refreshTtl: config.auth.jwt.refreshTtl },
|
|
101
|
+
new MemorySessionStore(),
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
const router = new Router();
|
|
105
|
+
const uploadsDir = join(PROJECT_ROOT, config.uploads.dir);
|
|
106
|
+
|
|
107
|
+
// Multipart uploads are persisted beneath the project-local /uploads folder.
|
|
108
|
+
// Filenames are generated by the framework; the public path is read-only.
|
|
109
|
+
registerUploadRoutes(router, {
|
|
110
|
+
directory: uploadsDir,
|
|
111
|
+
path: config.uploads.path,
|
|
112
|
+
publicPath: config.uploads.path,
|
|
113
|
+
maxFileSize: config.uploads.maxFileSize,
|
|
114
|
+
maxFiles: config.uploads.maxFiles,
|
|
115
|
+
allowedTypes: config.uploads.allowedTypes,
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
// Health check (no CSRF, no rate limit — always available).
|
|
119
|
+
router.get('/health', (ctx) => {
|
|
120
|
+
ctx.json({ status: 'ok', env: config.env, time: Date.now() });
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
// CSRF token endpoint — mints a double-submit token cookie + returns it.
|
|
124
|
+
router.get('/csrf-token', (ctx) => {
|
|
125
|
+
const token = issueCsrfToken(ctx, { trustedOrigins: trustedOrigins(config) });
|
|
126
|
+
ctx.json({ token });
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
// A sample unsafe route to demonstrate CSRF enforcement.
|
|
130
|
+
router.post('/echo', (ctx) => {
|
|
131
|
+
ctx.json({ received: ctx.body });
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
// Auth routes (register/login/refresh/logout/me + OAuth). Requires DB.
|
|
135
|
+
registerAuthRoutes(router, config, () => pubsub.publish('USER_COUNT', 1));
|
|
136
|
+
|
|
137
|
+
// Create a mutable copy of the AI providers array so the admin provider
|
|
138
|
+
// management endpoints can mutate API keys / enabled state in-place, and
|
|
139
|
+
// the AI proxy sees the updates without restarting. The config is frozen
|
|
140
|
+
// (Object.freeze) so we must clone before passing to both routes.
|
|
141
|
+
const aiProviders = (config.ai.providers ?? []).map((p) => ({ ...p }));
|
|
142
|
+
|
|
143
|
+
// AI proxy — mounts /ai/* OpenAI-compatible routes that re-emit SSE from the
|
|
144
|
+
// Python AI server. The browser never calls Python directly.
|
|
145
|
+
registerAiRoutes(router, {
|
|
146
|
+
serverUrl: config.ai.serverUrl,
|
|
147
|
+
timeoutMs: config.ai.timeoutMs,
|
|
148
|
+
// Pass the mutable array — same reference the admin endpoints mutate.
|
|
149
|
+
providers: aiProviders,
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
// GraphQL: single-subgraph gateway over the users subgraph. The context
|
|
153
|
+
// builder verifies the optional Bearer access token so `me` can resolve.
|
|
154
|
+
const usersSubgraph = buildUsersSubgraph();
|
|
155
|
+
const gateway = createGateway({ subgraph: usersSubgraph });
|
|
156
|
+
// PubSub feeds the GraphQL `userCount` subscription (published on register).
|
|
157
|
+
const pubsub = new PubSub();
|
|
158
|
+
const graphContext = async (ctx: import('@bhooai/nexus-core').RequestContext): Promise<GraphQLContext> => {
|
|
159
|
+
const auth = ctx.headers['authorization'];
|
|
160
|
+
const header = Array.isArray(auth) ? auth[0] : auth;
|
|
161
|
+
const token = header && header.startsWith('Bearer ') ? header.slice(7) : undefined;
|
|
162
|
+
if (token) {
|
|
163
|
+
try {
|
|
164
|
+
const claims = await authService.verifyAccessToken(token);
|
|
165
|
+
return { request: ctx, user: { sub: claims.sub, roles: claims.roles ?? [], sid: claims.sid }, pubsub };
|
|
166
|
+
} catch { /* invalid token → anonymous */ }
|
|
167
|
+
}
|
|
168
|
+
return { request: ctx, pubsub };
|
|
169
|
+
};
|
|
170
|
+
router.post(config.graphql.path, graphqlHttpHandler({ gateway, introspection: config.graphql.introspection, context: graphContext }));
|
|
171
|
+
router.get(config.graphql.path, graphqlHttpHandler({ gateway, introspection: config.graphql.introspection, context: graphContext }));
|
|
172
|
+
|
|
173
|
+
// Phase 7 services — cache (lazy Redis), email (log provider in dev), payments
|
|
174
|
+
// webhook router (no providers enabled unless keys are configured), and a
|
|
175
|
+
// cert/CSR generation endpoint backed by nexus-crypto (hand-rolled DER for CSRs).
|
|
176
|
+
const cache = new Cache({ url: config.redis.url, keyPrefix: config.redis.keyPrefix });
|
|
177
|
+
|
|
178
|
+
const templates = new TemplateEngine();
|
|
179
|
+
templates.register('welcome', '<h1>Welcome, {{name}}!</h1><p>Verify your email to get started.</p>');
|
|
180
|
+
const email = createEmail(config.email, { templates });
|
|
181
|
+
|
|
182
|
+
const payments = createPayments(config.payments);
|
|
183
|
+
const webhookRouter = payments.webhookRouter((ev) => {
|
|
184
|
+
log.info('payment webhook event', { provider: ev.provider, event: ev.event, verified: ev.verified });
|
|
185
|
+
// Persist every verified webhook event as a transaction (admin dashboard).
|
|
186
|
+
if (ev.provider && ev.event) {
|
|
187
|
+
recordTransaction(ev.provider, ev).catch((err) => log.warn('failed to record payment transaction', { msg: err.message }));
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
if (config.payments.webhookPath) {
|
|
191
|
+
router.post(config.payments.webhookPath, webhookRouter.handler);
|
|
192
|
+
}
|
|
193
|
+
// Phase 11 — browser-facing checkout routes (auth-guarded). Webhooks stay separate.
|
|
194
|
+
registerPaymentRoutes(router, config, payments, authService);
|
|
195
|
+
|
|
196
|
+
// Generate a self-signed cert + keypair (and optionally a CSR) on demand.
|
|
197
|
+
router.post('/certs/self-signed', (ctx) => {
|
|
198
|
+
const body = (ctx.body ?? {}) as { commonName?: string; organization?: string; country?: string; csr?: boolean };
|
|
199
|
+
const kp = generateKeyPair(config.certs.keyType, {
|
|
200
|
+
modulusLength: config.certs.rsaModulus,
|
|
201
|
+
namedCurve: config.certs.ecCurve,
|
|
202
|
+
});
|
|
203
|
+
const cert = createSelfSignedCertificate({
|
|
204
|
+
keyPair: { publicKey: kp.publicKey, privateKey: kp.privateKey },
|
|
205
|
+
commonName: body.commonName ?? 'localhost',
|
|
206
|
+
organization: body.organization,
|
|
207
|
+
country: body.country,
|
|
208
|
+
});
|
|
209
|
+
const out: Record<string, string> = { cert: cert.pem, privateKey: kp.pem.private, publicKey: kp.pem.public };
|
|
210
|
+
if (body.csr) {
|
|
211
|
+
const csr = createCsr({
|
|
212
|
+
keyPair: { publicKey: kp.publicKey, privateKey: kp.privateKey },
|
|
213
|
+
commonName: body.commonName ?? 'localhost',
|
|
214
|
+
organization: body.organization,
|
|
215
|
+
country: body.country,
|
|
216
|
+
});
|
|
217
|
+
out.csr = csr.pem;
|
|
218
|
+
}
|
|
219
|
+
ctx.json(out);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
const docsDir = join(PROJECT_ROOT, 'docs');
|
|
223
|
+
|
|
224
|
+
const server = new NexusServer({
|
|
225
|
+
router,
|
|
226
|
+
bodyLimit: config.server.bodyLimit,
|
|
227
|
+
middleware: [
|
|
228
|
+
securityHeaders(),
|
|
229
|
+
cors({ origin: true, credentials: true }),
|
|
230
|
+
serveStatic(uploadsDir, { prefix: config.uploads.path }),
|
|
231
|
+
serveStatic(docsDir, { prefix: '/docs' }),
|
|
232
|
+
bodyParser(config.server.bodyLimit),
|
|
233
|
+
csrf({ trustedOrigins: trustedOrigins(config) }),
|
|
234
|
+
rateLimit({ windowMs: 60_000, max: 300 }),
|
|
235
|
+
],
|
|
236
|
+
onError: (err, ctx) => {
|
|
237
|
+
log.warn('request error', { code: err.code, path: ctx.path, method: ctx.method, msg: err.message });
|
|
238
|
+
return { error: { code: err.code, message: err.message } };
|
|
239
|
+
},
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
// Realtime (WebSocket) server: auth via access token on upgrade, optional
|
|
243
|
+
// CSRF origin/double-submit check. Reuses the shared authService.
|
|
244
|
+
const realtime = new RealtimeServer({
|
|
245
|
+
httpServer: server.httpServer,
|
|
246
|
+
path: config.ws.path,
|
|
247
|
+
authService,
|
|
248
|
+
csrfOptions: config.ws.requireCsrf ? { trustedOrigins: trustedOrigins(config) } : undefined,
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
// GraphQL subscriptions over WebSocket (graphql-transport-ws), on a path
|
|
252
|
+
// under the GraphQL route. Auth via the shared authService.
|
|
253
|
+
const subscriptions = new SubscriptionServer({
|
|
254
|
+
httpServer: server.httpServer,
|
|
255
|
+
gateway,
|
|
256
|
+
path: `${config.graphql.path}/ws`,
|
|
257
|
+
authService,
|
|
258
|
+
context: (_init, user) => ({ user, pubsub }),
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
// Phase 8 — plugins: build real HostBindings from the running services,
|
|
262
|
+
// discover plugins from the configured dir, and run their lifecycle in
|
|
263
|
+
// dependency order before the server accepts traffic. Exposes the admin
|
|
264
|
+
// extensions (pages/slots contributed by plugins) at /plugins/extensions.
|
|
265
|
+
const pluginsDir = join(PROJECT_ROOT, config.plugins.dir);
|
|
266
|
+
const configByPlugin: Record<string, unknown> = {};
|
|
267
|
+
for (const entry of config.plugins.entries) {
|
|
268
|
+
if (entry.enabled) configByPlugin[entry.path] = entry.config ?? {};
|
|
269
|
+
}
|
|
270
|
+
const plugins = await loadPlugins({
|
|
271
|
+
router,
|
|
272
|
+
log,
|
|
273
|
+
realtime,
|
|
274
|
+
pluginsDir,
|
|
275
|
+
configByPlugin,
|
|
276
|
+
});
|
|
277
|
+
router.get('/plugins/extensions', (ctx) => {
|
|
278
|
+
ctx.json(plugins.adminExtensions.toJSON());
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
// Admin endpoints — bearer-token auth + 'admin' role (first registered user
|
|
282
|
+
// is bootstrapped admin). Edits write to nexus.runtime.json (gitignored).
|
|
283
|
+
const metrics = new MetricsRegistry();
|
|
284
|
+
metrics.counter('http_requests_total', 'Total HTTP requests');
|
|
285
|
+
const requestLogDir = join(PROJECT_ROOT, config.logging.dir);
|
|
286
|
+
// Count every HTTP request so the admin overview "HTTP REQUESTS" stat reflects
|
|
287
|
+
// live traffic, and append each completed request (with IP, url, referer etc.)
|
|
288
|
+
// to a datewise JSON log file under the project's logging dir.
|
|
289
|
+
server.use((ctx, next) => {
|
|
290
|
+
metrics.inc('http_requests_total', 1, { method: ctx.method });
|
|
291
|
+
const started = Date.now();
|
|
292
|
+
ctx.res.on('finish', () => {
|
|
293
|
+
const headers = ctx.headers as Record<string, string | string[] | undefined>;
|
|
294
|
+
const first = (v: string | string[] | undefined): string | undefined => (Array.isArray(v) ? v[0] : v);
|
|
295
|
+
const forwarded = first(headers['x-forwarded-for'])?.split(',')[0]?.trim();
|
|
296
|
+
appendRequestLog(requestLogDir, {
|
|
297
|
+
time: started,
|
|
298
|
+
method: ctx.method,
|
|
299
|
+
path: ctx.path,
|
|
300
|
+
url: ctx.req.url,
|
|
301
|
+
status: ctx.res.statusCode,
|
|
302
|
+
durationMs: Date.now() - started,
|
|
303
|
+
ip: forwarded || ctx.req.socket.remoteAddress,
|
|
304
|
+
referer: first(headers['referer']),
|
|
305
|
+
userAgent: first(headers['user-agent']),
|
|
306
|
+
origin: first(headers['origin']),
|
|
307
|
+
requestId: ctx.requestId,
|
|
308
|
+
route: ctx.routePattern,
|
|
309
|
+
});
|
|
310
|
+
});
|
|
311
|
+
return next();
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
// Cluster manager — auto-starts the LB + autoscaler when config says enabled.
|
|
315
|
+
const cluster = new ClusterManager({ config: config.cluster, root: PROJECT_ROOT, aiServerUrl: config.ai.serverUrl });
|
|
316
|
+
if (config.cluster.enabled) {
|
|
317
|
+
try {
|
|
318
|
+
await cluster.listenLb(config.cluster.lbHost);
|
|
319
|
+
cluster.autoscaler.start(10_000);
|
|
320
|
+
log.info(`cluster LB listening on ${config.cluster.lbHost}:${config.cluster.lbPort} (auto-started)`);
|
|
321
|
+
} catch (err) {
|
|
322
|
+
log.warn(`cluster failed to auto-start: ${(err as Error).message}`);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
registerAdminRoutes(router, config, { root: PROJECT_ROOT, project, adminExtensions: plugins.adminExtensions, metrics: () => metrics.toJSON(), payments, cluster, aiProviders });
|
|
327
|
+
|
|
328
|
+
await server.listen(config.server.port, config.server.host);
|
|
329
|
+
log.info(`Nexus backend listening on http://${config.server.host}:${config.server.port}`, {
|
|
330
|
+
graphql: config.graphql.path,
|
|
331
|
+
ws: config.ws.path,
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
const shutdown = async (signal: string) => {
|
|
335
|
+
log.info(`received ${signal}, shutting down`);
|
|
336
|
+
await plugins.host.stop();
|
|
337
|
+
plugins.scheduler.close();
|
|
338
|
+
await subscriptions.close();
|
|
339
|
+
await realtime.close();
|
|
340
|
+
await server.close();
|
|
341
|
+
await cluster.close();
|
|
342
|
+
await closeProjectInfo();
|
|
343
|
+
await getConnection().close();
|
|
344
|
+
await cache.close();
|
|
345
|
+
closeRequestLog();
|
|
346
|
+
process.exit(0);
|
|
347
|
+
};
|
|
348
|
+
process.on('SIGINT', () => void shutdown('SIGINT'));
|
|
349
|
+
process.on('SIGTERM', () => void shutdown('SIGTERM'));
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function trustedOrigins(config: Awaited<ReturnType<typeof loadConfig>>): string[] {
|
|
353
|
+
const loopback = ['localhost', '127.0.0.1'];
|
|
354
|
+
const origins: string[] = [];
|
|
355
|
+
for (const host of loopback) {
|
|
356
|
+
for (const port of [config.server.port, config.frontend.port, config.admin.port]) {
|
|
357
|
+
origins.push(`http://${host}:${port}`);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
return origins;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
async function readPackageVersion(root: string): Promise<string | undefined> {
|
|
364
|
+
try {
|
|
365
|
+
const pkg = JSON.parse(await readFile(join(root, 'package.json'), 'utf8')) as { version?: string };
|
|
366
|
+
return typeof pkg.version === 'string' ? pkg.version : undefined;
|
|
367
|
+
} catch {
|
|
368
|
+
return undefined;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
main().catch((err) => {
|
|
373
|
+
console.error(err);
|
|
374
|
+
process.exit(1);
|
|
375
|
+
});
|