@bhooai/nexus-core 2.0.18 → 2.0.19
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/package.json +1 -1
- package/src/app/adminModule.ts +5 -3
- package/src/app/authModule.ts +44 -1
- package/src/app/createNexusApp.ts +38 -8
- package/src/app/databaseAdminModule.ts +6 -6
- package/src/app/fusionDevApi.ts +210 -0
- package/src/app/fusionEngine.ts +42 -8
- package/src/app/index.ts +1 -0
- package/src/app/preflightModule.ts +4 -2
- package/src/app/userStore.ts +8 -8
- package/src/config/ConfigLoader.ts +86 -12
- package/src/config/current.ts +28 -0
- package/src/config/dbAccess.ts +21 -0
- package/src/config/defaults.ts +10 -10
- package/src/config/env.ts +47 -2
- package/src/config/index.ts +2 -1
- package/src/config/schema.ts +17 -13
- package/src/config/types.ts +32 -18
- package/src/http/Server.ts +4 -0
- package/src/http/context.ts +6 -0
- package/tests/config.test.ts +51 -0
package/package.json
CHANGED
package/src/app/adminModule.ts
CHANGED
|
@@ -40,7 +40,7 @@ import type { Router } from '../http/Router.js';
|
|
|
40
40
|
import type { Middleware, RequestContext } from '../http/context.js';
|
|
41
41
|
import { AiClient } from '@bhooai/nexus-ai-client';
|
|
42
42
|
import { discoverUserConfigPath, frameworkDefaultConfigPath, mergeConfig } from '../config/ConfigLoader.js';
|
|
43
|
-
import { mongoEnabled, mongoUri } from '../config/dbAccess.js';
|
|
43
|
+
import { mongoEnabled, mongoUri, cacheActive } from '../config/dbAccess.js';
|
|
44
44
|
import { readRuntimeJson, writeRuntimeJson, mergeRuntimeJson } from '../config/runtimeJson.js';
|
|
45
45
|
import { readEnvFile, writeEnvEntries, writeEnvKey, deleteEnvKey } from '../config/envFile.js';
|
|
46
46
|
import {
|
|
@@ -173,17 +173,19 @@ export function registerAdminRoutes(router: Router, opts: AdminModuleOptions, lo
|
|
|
173
173
|
const redisHost = hostOf(config.redis.url, 'localhost');
|
|
174
174
|
const redisPort = portOf(config.redis.url, 6379);
|
|
175
175
|
const aiPort = portOf(config.ai.serverUrl, 8000);
|
|
176
|
+
const useRedis = cacheActive(config) === 'redis';
|
|
176
177
|
|
|
177
178
|
const [mongo, redis, ai] = await Promise.all([
|
|
178
179
|
tcpReachable(mongoHost, mongoPort),
|
|
179
|
-
|
|
180
|
+
// No Redis server is required when fusion is the active cache backend.
|
|
181
|
+
useRedis ? tcpReachable(redisHost, redisPort) : Promise.resolve(true),
|
|
180
182
|
tcpReachable('127.0.0.1', aiPort),
|
|
181
183
|
]);
|
|
182
184
|
|
|
183
185
|
ctx.json({
|
|
184
186
|
services: [
|
|
185
187
|
{ id: 'mongo', label: 'MongoDB', ok: mongo, detail: `${mongoHost}:${mongoPort}` },
|
|
186
|
-
{ id: 'redis', label: 'Redis', ok: redis, detail: `${redisHost}:${redisPort}` },
|
|
188
|
+
{ id: 'redis', label: 'Redis', ok: redis, detail: useRedis ? `${redisHost}:${redisPort}` : 'bypassed — fusion cache active' },
|
|
187
189
|
{ id: 'ai', label: 'AI server', ok: ai, detail: `127.0.0.1:${aiPort}` },
|
|
188
190
|
{ id: 'storage', label: 'Storage', ok: true, detail: 'local disks configured' },
|
|
189
191
|
],
|
package/src/app/authModule.ts
CHANGED
|
@@ -21,6 +21,8 @@
|
|
|
21
21
|
*/
|
|
22
22
|
import type { NexusConfig } from '../config/types.js';
|
|
23
23
|
import type { Router } from '../http/Router.js';
|
|
24
|
+
import type { Middleware } from '../http/context.js';
|
|
25
|
+
import { getNexusConfig } from '../config/current.js';
|
|
24
26
|
import {
|
|
25
27
|
AuthService,
|
|
26
28
|
MemorySessionStore,
|
|
@@ -73,10 +75,51 @@ function jwtOptions(config: NexusConfig) {
|
|
|
73
75
|
};
|
|
74
76
|
}
|
|
75
77
|
|
|
76
|
-
|
|
78
|
+
/**
|
|
79
|
+
* Build the framework AuthService for a config. Exported so app routes and
|
|
80
|
+
* subgraphs can verify the same session JWTs (issuer/audience/secret) without
|
|
81
|
+
* re-declaring identity values — e.g. the license routes.
|
|
82
|
+
*/
|
|
83
|
+
export function createAuthService(config: NexusConfig): AuthService {
|
|
77
84
|
return new AuthService(jwtOptions(config), getSessions());
|
|
78
85
|
}
|
|
79
86
|
|
|
87
|
+
function authService(config: NexusConfig): AuthService {
|
|
88
|
+
return createAuthService(config);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
let cachedAuth: { config: NexusConfig; service: AuthService } | null = null;
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Memoized AuthService for a config — safe to call per request. Rebuilt only
|
|
95
|
+
* when the config object identity changes.
|
|
96
|
+
*/
|
|
97
|
+
export function getAuthService(config: NexusConfig): AuthService {
|
|
98
|
+
if (cachedAuth?.config === config) return cachedAuth.service;
|
|
99
|
+
const service = createAuthService(config);
|
|
100
|
+
cachedAuth = { config, service };
|
|
101
|
+
return service;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Session-auth middleware that reads its identity from the request's config
|
|
106
|
+
* (`ctx.config`, falling back to the process config) instead of hardcoding a
|
|
107
|
+
* cookie name. Use before `requireAuth()`:
|
|
108
|
+
*
|
|
109
|
+
* middleware: [sessionAuth(), requireAuth()]
|
|
110
|
+
*/
|
|
111
|
+
export function sessionAuth(options: { required?: boolean } = {}): Middleware {
|
|
112
|
+
return async (ctx, next) => {
|
|
113
|
+
const config = (ctx.config ?? getNexusConfig()) as NexusConfig | null;
|
|
114
|
+
if (!config) throw new Error('sessionAuth: no config available (ctx.config / setNexusConfig)');
|
|
115
|
+
const verify = authToken(getAuthService(config), {
|
|
116
|
+
required: options.required ?? false,
|
|
117
|
+
cookieName: config.auth.cookieName,
|
|
118
|
+
});
|
|
119
|
+
await verify(ctx, next);
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
80
123
|
/** Register /auth/* routes onto the given router. The user store is initialized
|
|
81
124
|
* by `createNexusApp()` (or lazily falls back to Mongo on first use). */
|
|
82
125
|
export function registerAuthRoutes(router: Router, config: NexusConfig, onUserCreated?: () => void): AuthService {
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
import { existsSync } from 'node:fs';
|
|
16
16
|
import { resolve, dirname, join } from 'node:path';
|
|
17
17
|
import { readFile } from 'node:fs/promises';
|
|
18
|
-
import { loadConfigAuto, activeDatabase, mongoEnabled, mongoUri, fusionEnabled, type NexusConfig } from '../config/index.js';
|
|
18
|
+
import { loadConfigAuto, activeDatabase, mongoEnabled, mongoUri, fusionEnabled, fusionRemote, setNexusConfig, type NexusConfig } from '../config/index.js';
|
|
19
19
|
import { Container } from '../di/Container.js';
|
|
20
20
|
import { Router } from '../http/Router.js';
|
|
21
21
|
import { NexusServer } from '../http/Server.js';
|
|
@@ -29,6 +29,7 @@ import { ConfigError, NotFoundError, toNexusError } from '../errors.js';
|
|
|
29
29
|
import { initUserModel } from './userModel.js';
|
|
30
30
|
import { initUserStore, MongoUserStore, FusionUserStore } from './userStore.js';
|
|
31
31
|
import { openAppFusion, closeAppFusion } from './fusionEngine.js';
|
|
32
|
+
import { registerFusionDevRoutes } from './fusionDevApi.js';
|
|
32
33
|
import { eventBus, type EventBus, type Listener } from './events.js';
|
|
33
34
|
import { configureQueue, InMemoryQueueAdapter, type JobQueueAdapter } from './Job.js';
|
|
34
35
|
import { configureMailDriver, configureMailRenderer, logMailDriver } from './Mailable.js';
|
|
@@ -51,8 +52,19 @@ export interface CreateNexusAppOptions {
|
|
|
51
52
|
tech?: 'react' | 'future';
|
|
52
53
|
/** Path to the backend src/ folder. Defaults to `<cwd>/src`. */
|
|
53
54
|
srcRoot?: string;
|
|
54
|
-
/** Project root
|
|
55
|
+
/** Project root — anchors DB data, cache, logs and certs (`storage/fusion`,
|
|
56
|
+
* `storage/logs`, `certs/`, .nexus-down). Defaults to srcRoot/.. */
|
|
55
57
|
projectRoot?: string;
|
|
58
|
+
/**
|
|
59
|
+
* Root for file storage (uploads + private). Resolves to
|
|
60
|
+
* `<storageRoot>/storage/{uploads,private}` and is served at `/uploads`.
|
|
61
|
+
*
|
|
62
|
+
* Split from `projectRoot` so a monorepo can keep durable DB data at the
|
|
63
|
+
* workspace root while file uploads live next to the backend
|
|
64
|
+
* (e.g. projectRoot = repo root, storageRoot = apps/backend).
|
|
65
|
+
* Defaults to `projectRoot`.
|
|
66
|
+
*/
|
|
67
|
+
storageRoot?: string;
|
|
56
68
|
/** Override the auto-discovered config (for tests). */
|
|
57
69
|
config?: NexusConfig;
|
|
58
70
|
/** Additional global middleware applied after built-ins. */
|
|
@@ -102,6 +114,9 @@ export async function createNexusApp(opts: CreateNexusAppOptions = {}): Promise<
|
|
|
102
114
|
const name = opts.name ?? 'backend';
|
|
103
115
|
const srcRoot = resolve(opts.srcRoot ?? resolve(process.cwd(), 'src'));
|
|
104
116
|
const projectRoot = resolve(opts.projectRoot ?? dirname(srcRoot));
|
|
117
|
+
// File storage (uploads/private) can be anchored separately from projectRoot
|
|
118
|
+
// so DB data stays at the workspace root while files live with the backend.
|
|
119
|
+
const storageRoot = resolve(opts.storageRoot ?? projectRoot);
|
|
105
120
|
let config = opts.config ?? (await loadConfigAuto({ root: projectRoot }));
|
|
106
121
|
|
|
107
122
|
// License check at project start — verify the token (signature via fetched
|
|
@@ -137,6 +152,10 @@ export async function createNexusApp(opts: CreateNexusAppOptions = {}): Promise<
|
|
|
137
152
|
config = Object.freeze({ ...config, server: { ...config.server, port: finalPort } }) as NexusConfig;
|
|
138
153
|
}
|
|
139
154
|
|
|
155
|
+
// Publish the resolved config process-wide so subgraphs/services without a
|
|
156
|
+
// request context can read identity/namespace values (see getNexusConfig).
|
|
157
|
+
setNexusConfig(config);
|
|
158
|
+
|
|
140
159
|
const container = new Container();
|
|
141
160
|
const router = new Router();
|
|
142
161
|
|
|
@@ -171,6 +190,16 @@ export async function createNexusApp(opts: CreateNexusAppOptions = {}): Promise<
|
|
|
171
190
|
}
|
|
172
191
|
const fusionDb = await openAppFusion(projectRoot, config);
|
|
173
192
|
initUserStore(new FusionUserStore(fusionDb));
|
|
193
|
+
// Dev-only: expose this same engine over HTTP so `nexus dev`'s Fusion View
|
|
194
|
+
// and `nexus db:view` read/write the SAME state the backend serves from —
|
|
195
|
+
// no second engine, no stale reads, no refresh needed.
|
|
196
|
+
//
|
|
197
|
+
// Skipped in remote mode: the standalone Fusion server already speaks HTTP,
|
|
198
|
+
// so tooling talks to it directly (the backend owns no engine to expose).
|
|
199
|
+
if (config.env === 'development' && !fusionRemote(config)) {
|
|
200
|
+
registerFusionDevRoutes(router);
|
|
201
|
+
console.log(`[${name}] Fusion dev API mounted (dev only)`);
|
|
202
|
+
}
|
|
174
203
|
} else {
|
|
175
204
|
if (mongoEnabled(config)) initUserModel();
|
|
176
205
|
initUserStore(new MongoUserStore());
|
|
@@ -179,7 +208,7 @@ export async function createNexusApp(opts: CreateNexusAppOptions = {}): Promise<
|
|
|
179
208
|
// ------------------------------------------------------------------
|
|
180
209
|
// Storage facade + queue + mail — always configured, even if minimal
|
|
181
210
|
// ------------------------------------------------------------------
|
|
182
|
-
configureStorageFromEnv(
|
|
211
|
+
configureStorageFromEnv(storageRoot, config.auth?.jwt?.secret ?? 'nexus-insecure');
|
|
183
212
|
configureQueue(opts.queueAdapter ?? new InMemoryQueueAdapter());
|
|
184
213
|
configureMailDriver(logMailDriver);
|
|
185
214
|
configureMailRenderer(async (templateName, data) => renderMailTemplate(projectRoot, templateName, data));
|
|
@@ -386,7 +415,7 @@ export async function createNexusApp(opts: CreateNexusAppOptions = {}): Promise<
|
|
|
386
415
|
gateway,
|
|
387
416
|
introspection: config.graphql?.introspection ?? true,
|
|
388
417
|
requireMutationCsrf: config.graphql?.requireMutationCsrf ?? true,
|
|
389
|
-
context: (ctx) => ({ request: ctx, user: (ctx.state.user as GraphQLContext['user']), ...opts.graphqlContext }),
|
|
418
|
+
context: (ctx) => ({ request: ctx, user: (ctx.state.user as GraphQLContext['user']), config: ctx.config ?? config, ...opts.graphqlContext }),
|
|
390
419
|
});
|
|
391
420
|
if (explorerEnabled) {
|
|
392
421
|
const explorerHtml = getExplorerHtml({
|
|
@@ -433,7 +462,7 @@ export async function createNexusApp(opts: CreateNexusAppOptions = {}): Promise<
|
|
|
433
462
|
if (helloEnabled) {
|
|
434
463
|
// hello is available even in fallback — route through its gateway
|
|
435
464
|
const gw = createGateway({ subgraph: helloSubgraph });
|
|
436
|
-
const h = graphqlHttpHandler({ gateway: gw, introspection: config.graphql?.introspection ?? true, requireMutationCsrf: config.graphql?.requireMutationCsrf ?? true, context: (ctx) => ({ request: ctx, user: (ctx.state.user as GraphQLContext['user']), ...opts.graphqlContext }) });
|
|
465
|
+
const h = graphqlHttpHandler({ gateway: gw, introspection: config.graphql?.introspection ?? true, requireMutationCsrf: config.graphql?.requireMutationCsrf ?? true, context: (ctx) => ({ request: ctx, user: (ctx.state.user as GraphQLContext['user']), config: ctx.config ?? config, ...opts.graphqlContext }) });
|
|
437
466
|
return h(ctx);
|
|
438
467
|
}
|
|
439
468
|
ctx.json({ errors: [{ message: 'No GraphQL subgraph found. Add one with: npx nexus make:subgraph <name> (then restart)' }] }, 404);
|
|
@@ -443,7 +472,7 @@ export async function createNexusApp(opts: CreateNexusAppOptions = {}): Promise<
|
|
|
443
472
|
try {
|
|
444
473
|
if (helloEnabled) {
|
|
445
474
|
const gw = createGateway({ subgraph: helloSubgraph });
|
|
446
|
-
router.add('POST', graphqlPath, graphqlHttpHandler({ gateway: gw, introspection: config.graphql?.introspection ?? true, requireMutationCsrf: config.graphql?.requireMutationCsrf ?? true, context: (ctx) => ({ request: ctx, user: (ctx.state.user as GraphQLContext['user']), ...opts.graphqlContext }) }));
|
|
475
|
+
router.add('POST', graphqlPath, graphqlHttpHandler({ gateway: gw, introspection: config.graphql?.introspection ?? true, requireMutationCsrf: config.graphql?.requireMutationCsrf ?? true, context: (ctx) => ({ request: ctx, user: (ctx.state.user as GraphQLContext['user']), config: ctx.config ?? config, ...opts.graphqlContext }) }));
|
|
447
476
|
} else {
|
|
448
477
|
router.add('POST', graphqlPath, async (ctx) => ctx.json({ errors: [{ message: 'No GraphQL subgraph found. Add one with: npx nexus make:subgraph <name>' }] }, 404));
|
|
449
478
|
}
|
|
@@ -510,6 +539,7 @@ export async function createNexusApp(opts: CreateNexusAppOptions = {}): Promise<
|
|
|
510
539
|
// ------------------------------------------------------------------
|
|
511
540
|
const server = new NexusServer({
|
|
512
541
|
router,
|
|
542
|
+
config,
|
|
513
543
|
bodyLimit: config.server.bodyLimit,
|
|
514
544
|
trustProxy: config.server.trustProxy,
|
|
515
545
|
...(config.server.https && config.server.certFile && config.server.keyFile
|
|
@@ -717,9 +747,9 @@ export async function createNexusApp(opts: CreateNexusAppOptions = {}): Promise<
|
|
|
717
747
|
}
|
|
718
748
|
}
|
|
719
749
|
|
|
720
|
-
// Public uploads: serve GET /uploads/* from storage/uploads
|
|
750
|
+
// Public uploads: serve GET /uploads/* from <storageRoot>/storage/uploads.
|
|
721
751
|
// This was missing — 404 `No route for GET /uploads/media/...`.
|
|
722
|
-
server.use(serveStatic(join(
|
|
752
|
+
server.use(serveStatic(join(storageRoot, 'storage', 'uploads'), { prefix: '/uploads' }));
|
|
723
753
|
|
|
724
754
|
// Signed private files: GET /files/<encodedPath>?expires=&sig=
|
|
725
755
|
// Handles LocalDisk.signedUrl() URLs (HMAC, 5min TTL). Streams from whichever local disk holds the file.
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { Router } from '../index.js';
|
|
2
2
|
import type { ActiveDatabase } from '../config/types.js';
|
|
3
3
|
import { getAppFusion } from './fusionEngine.js';
|
|
4
|
-
import type {
|
|
4
|
+
import type { FusionLike } from '@bhooai/nexus-fusion';
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* Admin routes for database / collection administration.
|
|
@@ -213,14 +213,14 @@ function registerMongoDatabaseAdminRoutes(router: Router, lazy: LazyDb): void {
|
|
|
213
213
|
|
|
214
214
|
function registerFusionDatabaseAdminRoutes(router: Router): void {
|
|
215
215
|
// Branches stand in for databases; the engine's fixed db is 'main'.
|
|
216
|
-
const view = (branch: string):
|
|
216
|
+
const view = (branch: string): FusionLike => getAppFusion().branchView(branch);
|
|
217
217
|
|
|
218
218
|
router.get('/admin/databases', async (ctx) => {
|
|
219
219
|
try {
|
|
220
220
|
const fusion = getAppFusion();
|
|
221
221
|
const databases = [];
|
|
222
|
-
for (const branch of fusion.listBranches()) {
|
|
223
|
-
const bcols = view(branch).listCollections().filter((c) => c !== BOOTSTRAP_COLLECTION);
|
|
222
|
+
for (const branch of await fusion.listBranches()) {
|
|
223
|
+
const bcols = (await view(branch).listCollections()).filter((c) => c !== BOOTSTRAP_COLLECTION);
|
|
224
224
|
const collections = [];
|
|
225
225
|
for (const c of bcols) {
|
|
226
226
|
let count = 0;
|
|
@@ -242,7 +242,7 @@ function registerFusionDatabaseAdminRoutes(router: Router): void {
|
|
|
242
242
|
const name = assertDbName((ctx.body as { name?: unknown } | undefined)?.name, ctx);
|
|
243
243
|
if (!name) return;
|
|
244
244
|
try {
|
|
245
|
-
getAppFusion().createBranch(name);
|
|
245
|
+
await getAppFusion().createBranch(name);
|
|
246
246
|
ctx.json({ ok: true, name });
|
|
247
247
|
} catch (err) {
|
|
248
248
|
ctx.json({ error: (err as Error).message }, 500);
|
|
@@ -253,7 +253,7 @@ function registerFusionDatabaseAdminRoutes(router: Router): void {
|
|
|
253
253
|
const name = assertDbName(ctx.params.db, ctx);
|
|
254
254
|
if (!name) return;
|
|
255
255
|
try {
|
|
256
|
-
getAppFusion().dropBranch(name);
|
|
256
|
+
await getAppFusion().dropBranch(name);
|
|
257
257
|
ctx.json({ ok: true, dropped: name });
|
|
258
258
|
} catch (err) {
|
|
259
259
|
ctx.json({ error: (err as Error).message }, 500);
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fusion dev API — exposes the backend's **own** in-process Fusion engine over
|
|
3
|
+
* HTTP so the CLI tools (`nexus dev` Fusion View, `nexus db:view`) read and
|
|
4
|
+
* write the SAME engine the app serves from.
|
|
5
|
+
*
|
|
6
|
+
* Before this existed, the standalone `fusion` service opened a *second*
|
|
7
|
+
* engine on the same `storage/fusion/fusion.wal`. Edits made in the browser
|
|
8
|
+
* landed in engine B, so the running backend (engine A) kept serving stale
|
|
9
|
+
* data until it restarted and replayed the WAL.
|
|
10
|
+
*
|
|
11
|
+
* Mounted only in development (`config.env === 'development'`) and only when
|
|
12
|
+
* `db.active === 'fusion'`. Routes mirror the standalone server's surface
|
|
13
|
+
* (see `nexus-cli/src/fusion/server.ts`) so one browser client works against
|
|
14
|
+
* either backend:
|
|
15
|
+
*
|
|
16
|
+
* GET <prefix>/health
|
|
17
|
+
* GET <prefix>/tree
|
|
18
|
+
* GET <prefix>/docs?collection=&branch=&where=&orderBy=&limit=&skip=
|
|
19
|
+
* POST <prefix>/docs { branch, collection, id?, data }
|
|
20
|
+
* PATCH <prefix>/docs/:id?collection=&branch= (body = patch)
|
|
21
|
+
* DELETE <prefix>/docs/:id?collection=&branch=
|
|
22
|
+
* POST <prefix>/branches { branch }
|
|
23
|
+
* DELETE <prefix>/branches/:name
|
|
24
|
+
*/
|
|
25
|
+
import type { Router } from '../http/Router.js';
|
|
26
|
+
import type { RequestContext } from '../http/context.js';
|
|
27
|
+
import { getAppFusion } from './fusionEngine.js';
|
|
28
|
+
|
|
29
|
+
/** Reserved mount path — double underscore marks it framework-internal. */
|
|
30
|
+
export const FUSION_DEV_PATH = '/__nexus/fusion';
|
|
31
|
+
|
|
32
|
+
/** Map a Fusion engine error (`.code`) to an HTTP status. */
|
|
33
|
+
function httpStatus(err: unknown): number {
|
|
34
|
+
const code = (err as { code?: string })?.code;
|
|
35
|
+
switch (code) {
|
|
36
|
+
case 'NOT_FOUND':
|
|
37
|
+
case 'PATH_NOT_FOUND':
|
|
38
|
+
return 404;
|
|
39
|
+
case 'VERSION_CONFLICT':
|
|
40
|
+
return 409;
|
|
41
|
+
case 'INVALID_NAME':
|
|
42
|
+
case 'INVALID_ID':
|
|
43
|
+
case 'QUERY_ERROR':
|
|
44
|
+
return 400;
|
|
45
|
+
default:
|
|
46
|
+
return 500;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function fail(ctx: RequestContext, err: unknown): void {
|
|
51
|
+
ctx.json({ error: err instanceof Error ? err.message : String(err) }, httpStatus(err));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Register the Fusion dev routes on `router` under `prefix`. Safe to call for
|
|
56
|
+
* non-fusion backends? No — the caller must gate on db.active === 'fusion'
|
|
57
|
+
* (getAppFusion throws otherwise), which `createNexusApp` does.
|
|
58
|
+
*/
|
|
59
|
+
export function registerFusionDevRoutes(router: Router, prefix: string = FUSION_DEV_PATH): void {
|
|
60
|
+
const at = (path: string): string => `${prefix.replace(/\/+$/, '')}${path}`;
|
|
61
|
+
|
|
62
|
+
router.get(at('/health'), (ctx) => {
|
|
63
|
+
ctx.json({ ok: true, db: getAppFusion().db });
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// Tree — branches → collections with counts (same shape as the standalone server).
|
|
67
|
+
router.get(at('/tree'), async (ctx) => {
|
|
68
|
+
try {
|
|
69
|
+
const db = getAppFusion();
|
|
70
|
+
const tree: {
|
|
71
|
+
db: string;
|
|
72
|
+
branches: Array<{ name: string; collections: Array<{ name: string; count: number }> }>;
|
|
73
|
+
} = { db: db.db, branches: [] };
|
|
74
|
+
for (const name of await db.listBranches()) {
|
|
75
|
+
const view = db.branchView(name);
|
|
76
|
+
const collections: Array<{ name: string; count: number }> = [];
|
|
77
|
+
for (const coll of await view.listCollections()) {
|
|
78
|
+
collections.push({ name: coll, count: await view.collection(coll).count() });
|
|
79
|
+
}
|
|
80
|
+
tree.branches.push({ name, collections });
|
|
81
|
+
}
|
|
82
|
+
ctx.json({ tree });
|
|
83
|
+
} catch (err) {
|
|
84
|
+
fail(ctx, err);
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
// Docs list.
|
|
89
|
+
router.get(at('/docs'), async (ctx) => {
|
|
90
|
+
try {
|
|
91
|
+
const q = ctx.query as Record<string, string>;
|
|
92
|
+
const collection = q.collection;
|
|
93
|
+
if (!collection) return ctx.json({ error: 'collection query param required' }, 400);
|
|
94
|
+
const view = getAppFusion().branchView(q.branch || 'prod');
|
|
95
|
+
let query = view.collection(collection).query();
|
|
96
|
+
if (q.where) {
|
|
97
|
+
const parsed = JSON.parse(q.where) as
|
|
98
|
+
| { field: string; op: string; value: unknown }
|
|
99
|
+
| Array<{ field: string; op: string; value: unknown }>;
|
|
100
|
+
const conds = Array.isArray(parsed) ? parsed : [parsed];
|
|
101
|
+
for (const c of conds) query = query.where(c.field, c.op as never, c.value);
|
|
102
|
+
}
|
|
103
|
+
if (q.orderBy) {
|
|
104
|
+
const [field, dir] = q.orderBy.split(':');
|
|
105
|
+
if (field) query = query.orderByField(field, dir === 'desc' ? 'desc' : 'asc');
|
|
106
|
+
}
|
|
107
|
+
if (q.skip) query = query.offset(Number(q.skip) || 0);
|
|
108
|
+
if (q.limit) query = query.limit(Number(q.limit) || 50);
|
|
109
|
+
const snap = await query.get();
|
|
110
|
+
ctx.json({
|
|
111
|
+
docs: snap.docs.map((d) => ({
|
|
112
|
+
id: d.id,
|
|
113
|
+
version: d.__version,
|
|
114
|
+
updatedAt: d.__updatedAt,
|
|
115
|
+
data: d.data,
|
|
116
|
+
})),
|
|
117
|
+
size: snap.size,
|
|
118
|
+
});
|
|
119
|
+
} catch (err) {
|
|
120
|
+
fail(ctx, err);
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
// Create doc.
|
|
125
|
+
router.post(at('/docs'), async (ctx) => {
|
|
126
|
+
try {
|
|
127
|
+
const body = (ctx.body ?? {}) as { branch?: string; collection?: string; id?: string; data?: Record<string, unknown> };
|
|
128
|
+
const collection = body.collection;
|
|
129
|
+
const data = body.data;
|
|
130
|
+
if (!collection || typeof data !== 'object' || data === null) {
|
|
131
|
+
return ctx.json({ error: 'collection and data required' }, 400);
|
|
132
|
+
}
|
|
133
|
+
const view = getAppFusion().branchView(body.branch || 'prod');
|
|
134
|
+
const doc = body.id
|
|
135
|
+
? await view.collection(collection).doc(body.id).set(data)
|
|
136
|
+
: await view.collection(collection).add(data);
|
|
137
|
+
const id = body.id ?? (doc as { id: string }).id;
|
|
138
|
+
const saved = await view.collection(collection).doc(id).get();
|
|
139
|
+
ctx.json(
|
|
140
|
+
{
|
|
141
|
+
doc: saved
|
|
142
|
+
? { id: saved.id, version: saved.__version, updatedAt: saved.__updatedAt, data: saved.data }
|
|
143
|
+
: null,
|
|
144
|
+
},
|
|
145
|
+
201,
|
|
146
|
+
);
|
|
147
|
+
} catch (err) {
|
|
148
|
+
fail(ctx, err);
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
// Patch doc.
|
|
153
|
+
router.patch(at('/docs/:id'), async (ctx) => {
|
|
154
|
+
try {
|
|
155
|
+
const q = ctx.query as Record<string, string>;
|
|
156
|
+
const collection = q.collection;
|
|
157
|
+
if (!collection) return ctx.json({ error: 'collection query param required' }, 400);
|
|
158
|
+
const patch = (ctx.body ?? {}) as Record<string, unknown>;
|
|
159
|
+
const updated = await getAppFusion()
|
|
160
|
+
.branchView(q.branch || 'prod')
|
|
161
|
+
.collection(collection)
|
|
162
|
+
.doc(ctx.params.id ?? '')
|
|
163
|
+
.update(patch);
|
|
164
|
+
ctx.json({
|
|
165
|
+
doc: { id: updated.id, version: updated.__version, updatedAt: updated.__updatedAt, data: updated.data },
|
|
166
|
+
});
|
|
167
|
+
} catch (err) {
|
|
168
|
+
fail(ctx, err);
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
// Delete doc.
|
|
173
|
+
router.delete(at('/docs/:id'), async (ctx) => {
|
|
174
|
+
try {
|
|
175
|
+
const q = ctx.query as Record<string, string>;
|
|
176
|
+
const collection = q.collection;
|
|
177
|
+
if (!collection) return ctx.json({ error: 'collection query param required' }, 400);
|
|
178
|
+
await getAppFusion()
|
|
179
|
+
.branchView(q.branch || 'prod')
|
|
180
|
+
.collection(collection)
|
|
181
|
+
.doc(ctx.params.id ?? '')
|
|
182
|
+
.delete();
|
|
183
|
+
ctx.json({ ok: true });
|
|
184
|
+
} catch (err) {
|
|
185
|
+
fail(ctx, err);
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
// Create branch.
|
|
190
|
+
router.post(at('/branches'), async (ctx) => {
|
|
191
|
+
try {
|
|
192
|
+
const body = (ctx.body ?? {}) as { branch?: string };
|
|
193
|
+
if (!body.branch) return ctx.json({ error: 'branch required' }, 400);
|
|
194
|
+
await getAppFusion().createBranch(body.branch);
|
|
195
|
+
ctx.json({ ok: true, branch: body.branch }, 201);
|
|
196
|
+
} catch (err) {
|
|
197
|
+
fail(ctx, err);
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
// Drop branch.
|
|
202
|
+
router.delete(at('/branches/:name'), async (ctx) => {
|
|
203
|
+
try {
|
|
204
|
+
await getAppFusion().dropBranch(ctx.params.name ?? '');
|
|
205
|
+
ctx.json({ ok: true });
|
|
206
|
+
} catch (err) {
|
|
207
|
+
fail(ctx, err);
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
}
|
package/src/app/fusionEngine.ts
CHANGED
|
@@ -1,20 +1,31 @@
|
|
|
1
1
|
import { mkdir } from 'node:fs/promises';
|
|
2
2
|
import { resolve } from 'node:path';
|
|
3
|
-
import type {
|
|
3
|
+
import type { FusionLike } from '@bhooai/nexus-fusion';
|
|
4
4
|
import type { NexusConfig } from '../config/types.js';
|
|
5
|
-
import { fusionDir } from '../config/dbAccess.js';
|
|
5
|
+
import { fusionDir, fusionRemote, fusionUrl } from '../config/dbAccess.js';
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
|
-
* App Fusion engine lifecycle — one
|
|
9
|
-
*
|
|
8
|
+
* App Fusion engine lifecycle — one per backend process.
|
|
9
|
+
*
|
|
10
|
+
* Two modes, selected by config:
|
|
11
|
+
*
|
|
12
|
+
* - **Embedded** (default): opens the native `@bhooai/nexus-fusion` engine at
|
|
13
|
+
* boot when `db.fusion.enabled` and closes it on shutdown. The backend owns
|
|
14
|
+
* `storage/fusion/fusion.wal`.
|
|
15
|
+
*
|
|
16
|
+
* - **Remote**: when `db.fusion.remote` is true (or `db.fusion.url` is set) the
|
|
17
|
+
* backend opens **no** engine. All reads/writes go over HTTP to the
|
|
18
|
+
* standalone `fusion` service started by `nexus dev`, which is the sole
|
|
19
|
+
* owner of the WAL. This lets several apps share one Fusion database and
|
|
20
|
+
* keeps the backend process free of the Rust engine.
|
|
10
21
|
*
|
|
11
22
|
* `@bhooai/nexus-fusion` is dynamically imported so mongo-only backends never
|
|
12
23
|
* need the package (or its Rust core) installed.
|
|
13
24
|
*/
|
|
14
25
|
|
|
15
|
-
let engine:
|
|
26
|
+
let engine: FusionLike | null = null;
|
|
16
27
|
|
|
17
|
-
export async function openAppFusion(projectRoot: string, config: NexusConfig): Promise<
|
|
28
|
+
export async function openAppFusion(projectRoot: string, config: NexusConfig): Promise<FusionLike> {
|
|
18
29
|
if (engine) return engine;
|
|
19
30
|
let mod: typeof import('@bhooai/nexus-fusion');
|
|
20
31
|
try {
|
|
@@ -25,6 +36,29 @@ export async function openAppFusion(projectRoot: string, config: NexusConfig): P
|
|
|
25
36
|
'add it as a dependency (file:../path/to/packages/nexus-fusion) and install.',
|
|
26
37
|
);
|
|
27
38
|
}
|
|
39
|
+
|
|
40
|
+
if (fusionRemote(config)) {
|
|
41
|
+
const url = fusionUrl(config);
|
|
42
|
+
if (!url) {
|
|
43
|
+
throw new Error(
|
|
44
|
+
"[db] db.fusion.remote is true but db.fusion.url is empty — set it to the standalone " +
|
|
45
|
+
'Fusion server (e.g. http://127.0.0.1:5000).',
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
engine = mod.fusionHttp({ url });
|
|
49
|
+
// Confirm the standalone server answers before the backend serves traffic.
|
|
50
|
+
try {
|
|
51
|
+
await (engine as { listBranches(): Promise<string[]> }).listBranches();
|
|
52
|
+
console.log(`[db] fusion remote client → ${url}`);
|
|
53
|
+
} catch (err) {
|
|
54
|
+
console.warn(
|
|
55
|
+
`[db] fusion server at ${url} is not reachable yet (${(err as Error).message}); ` +
|
|
56
|
+
'requests will retry once it is up.',
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
return engine;
|
|
60
|
+
}
|
|
61
|
+
|
|
28
62
|
const fz = config.db.fusion;
|
|
29
63
|
const dir = fz.persist ? resolve(projectRoot, fusionDir(config)) : undefined;
|
|
30
64
|
if (dir) await mkdir(dir, { recursive: true });
|
|
@@ -38,7 +72,7 @@ export async function openAppFusion(projectRoot: string, config: NexusConfig): P
|
|
|
38
72
|
return engine;
|
|
39
73
|
}
|
|
40
74
|
|
|
41
|
-
export function getAppFusion():
|
|
75
|
+
export function getAppFusion(): FusionLike {
|
|
42
76
|
if (!engine) throw new Error('[db] Fusion engine is not open — enable db.fusion first.');
|
|
43
77
|
return engine;
|
|
44
78
|
}
|
|
@@ -46,7 +80,7 @@ export function getAppFusion(): FusionDatabase {
|
|
|
46
80
|
export async function closeAppFusion(): Promise<void> {
|
|
47
81
|
if (!engine) return;
|
|
48
82
|
try {
|
|
49
|
-
engine.close();
|
|
83
|
+
await engine.close();
|
|
50
84
|
} finally {
|
|
51
85
|
engine = null;
|
|
52
86
|
}
|
package/src/app/index.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createConnection } from 'node:net';
|
|
2
2
|
import type { Router, NexusConfig } from '../index.js';
|
|
3
|
-
import { mongoEnabled, mongoUri } from '../config/dbAccess.js';
|
|
3
|
+
import { mongoEnabled, mongoUri, redisCacheEnabled } from '../config/dbAccess.js';
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* Preflight diagnostics — native Node connectivity/latency probes.
|
|
@@ -235,7 +235,9 @@ export function registerPreflightRoutes(router: Router, config: NexusConfig, opt
|
|
|
235
235
|
}
|
|
236
236
|
|
|
237
237
|
const redisUrl = config.redis?.url ?? '';
|
|
238
|
-
|
|
238
|
+
// Probed only when redis is the active cache backend — fusion-active
|
|
239
|
+
// backends don't need a Redis server at all.
|
|
240
|
+
if (redisCacheEnabled(config) && redisUrl) {
|
|
239
241
|
const redis = endpointFromUrl(redisUrl, 6379);
|
|
240
242
|
targets.push({ name: 'Redis', kind: 'tcp', host: redis.host, port: redis.port, timeout: 2000 });
|
|
241
243
|
}
|
package/src/app/userStore.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
|
-
import type {
|
|
2
|
+
import type { FusionLike } from '@bhooai/nexus-fusion';
|
|
3
3
|
import { ObjectId } from '@bhooai/nexus-data';
|
|
4
4
|
import { ConflictError } from '../errors.js';
|
|
5
5
|
import { initUserModel, getUserModel, findUserForLogin, type UserInstance } from './userModel.js';
|
|
@@ -172,16 +172,16 @@ async function getDocOrNull<T>(ref: { get(): Promise<{ id: string; data: T } | n
|
|
|
172
172
|
}
|
|
173
173
|
|
|
174
174
|
/** Query paths throw on missing collections too — treat as empty. */
|
|
175
|
-
function hasCollection(db:
|
|
175
|
+
async function hasCollection(db: FusionLike, name: string): Promise<boolean> {
|
|
176
176
|
try {
|
|
177
|
-
return db.listCollections().includes(name);
|
|
177
|
+
return (await db.listCollections()).includes(name);
|
|
178
178
|
} catch {
|
|
179
179
|
return false;
|
|
180
180
|
}
|
|
181
181
|
}
|
|
182
182
|
|
|
183
183
|
export class FusionUserStore implements UserStore {
|
|
184
|
-
constructor(private db:
|
|
184
|
+
constructor(private db: FusionLike) {}
|
|
185
185
|
|
|
186
186
|
private toStored(data: FusionUserData): StoredUser {
|
|
187
187
|
return { _id: data.id, ...data };
|
|
@@ -197,7 +197,7 @@ export class FusionUserStore implements UserStore {
|
|
|
197
197
|
}
|
|
198
198
|
|
|
199
199
|
async findById(id: string): Promise<StoredUser | null> {
|
|
200
|
-
if (!hasCollection(this.db, USERS)) return null;
|
|
200
|
+
if (!(await hasCollection(this.db, USERS))) return null;
|
|
201
201
|
const snap = await this.db.collection<FusionUserData>(USERS).where('id', '==', id).limit(1).get();
|
|
202
202
|
const hit = snap.docs[0];
|
|
203
203
|
return hit ? this.toStored(hit.data) : null;
|
|
@@ -255,20 +255,20 @@ export class FusionUserStore implements UserStore {
|
|
|
255
255
|
}
|
|
256
256
|
|
|
257
257
|
async count(): Promise<number> {
|
|
258
|
-
if (!hasCollection(this.db, USERS)) return 0;
|
|
258
|
+
if (!(await hasCollection(this.db, USERS))) return 0;
|
|
259
259
|
const snap = await this.db.collection(USERS).get();
|
|
260
260
|
return snap.size;
|
|
261
261
|
}
|
|
262
262
|
|
|
263
263
|
async countAdmins(): Promise<number> {
|
|
264
264
|
// No array-contains op in the query compiler — filter the (small) users table.
|
|
265
|
-
if (!hasCollection(this.db, USERS)) return 0;
|
|
265
|
+
if (!(await hasCollection(this.db, USERS))) return 0;
|
|
266
266
|
const snap = await this.db.collection<FusionUserData>(USERS).get();
|
|
267
267
|
return snap.docs.filter((d) => (d.data.roles ?? []).includes('admin')).length;
|
|
268
268
|
}
|
|
269
269
|
|
|
270
270
|
async list(limit: number): Promise<StoredUser[]> {
|
|
271
|
-
if (!hasCollection(this.db, USERS)) return [];
|
|
271
|
+
if (!(await hasCollection(this.db, USERS))) return [];
|
|
272
272
|
const snap = await this.db
|
|
273
273
|
.collection<FusionUserData>(USERS)
|
|
274
274
|
.orderByField('createdAt', 'desc')
|
|
@@ -47,6 +47,23 @@ export async function loadConfig(opts: LoadOptions = {}): Promise<NexusConfig> {
|
|
|
47
47
|
merged = deepMerge(merged, defaultConfig);
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
// 1c. project identity — resolve `app.name` from every higher layer, then
|
|
51
|
+
// derive auth identity, redis prefix and mail `from` from it. This layer
|
|
52
|
+
// sits above the framework default but below the user config, so an
|
|
53
|
+
// explicit value anywhere in nexus.config.ts / runtime.json / env still
|
|
54
|
+
// wins while a project only has to declare its name once.
|
|
55
|
+
const runtimePath = opts.runtimePath ?? resolve(root, 'nexus.runtime.json');
|
|
56
|
+
const runtime = readRuntimeObject(runtimePath);
|
|
57
|
+
const envLayer = configFromEnv(env);
|
|
58
|
+
const appName = resolveAppName({
|
|
59
|
+
userConfig: opts.userConfig,
|
|
60
|
+
runtime,
|
|
61
|
+
env: envLayer,
|
|
62
|
+
cli: opts.cli,
|
|
63
|
+
root,
|
|
64
|
+
});
|
|
65
|
+
merged = deepMerge(merged, deriveAppIdentity(appName));
|
|
66
|
+
|
|
50
67
|
// 2. user config (nexus.config.ts / nexus.config.js)
|
|
51
68
|
let userConfig = opts.userConfig;
|
|
52
69
|
if (!userConfig && opts.userConfigPath) {
|
|
@@ -55,18 +72,10 @@ export async function loadConfig(opts: LoadOptions = {}): Promise<NexusConfig> {
|
|
|
55
72
|
if (userConfig) merged = deepMerge(merged, userConfig);
|
|
56
73
|
|
|
57
74
|
// 3. runtime.json (admin write-back, gitignored)
|
|
58
|
-
|
|
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
|
-
}
|
|
75
|
+
if (runtime) merged = deepMerge(merged, runtime);
|
|
67
76
|
|
|
68
77
|
// 4. env
|
|
69
|
-
merged = deepMerge(merged,
|
|
78
|
+
merged = deepMerge(merged, envLayer);
|
|
70
79
|
|
|
71
80
|
// 5. cli flags (highest)
|
|
72
81
|
if (opts.cli) merged = deepMerge(merged, opts.cli);
|
|
@@ -76,6 +85,65 @@ export async function loadConfig(opts: LoadOptions = {}): Promise<NexusConfig> {
|
|
|
76
85
|
return Object.freeze(parsed) as NexusConfig;
|
|
77
86
|
}
|
|
78
87
|
|
|
88
|
+
/** Read nexus.runtime.json (DeepPartial) or an empty object when absent/corrupt. */
|
|
89
|
+
function readRuntimeObject(runtimePath: string): DeepPartial<NexusConfig> {
|
|
90
|
+
if (!existsSync(runtimePath)) return {};
|
|
91
|
+
try {
|
|
92
|
+
return JSON.parse(readFileSync(runtimePath, 'utf8')) as DeepPartial<NexusConfig>;
|
|
93
|
+
} catch {
|
|
94
|
+
// A corrupt runtime file must not crash boot; ignore it.
|
|
95
|
+
return {};
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Resolve the project slug used to derive identity/namespaces: an explicit
|
|
101
|
+
* `app.name` from the highest-precedence layer wins, else package.json `name`
|
|
102
|
+
* (scope stripped), else the project folder name.
|
|
103
|
+
*/
|
|
104
|
+
function resolveAppName(layers: {
|
|
105
|
+
userConfig?: UserNexusConfig;
|
|
106
|
+
runtime?: DeepPartial<NexusConfig>;
|
|
107
|
+
env?: DeepPartial<NexusConfig>;
|
|
108
|
+
cli?: DeepPartial<NexusConfig>;
|
|
109
|
+
root: string;
|
|
110
|
+
}): string {
|
|
111
|
+
const explicit =
|
|
112
|
+
layers.cli?.app?.name ??
|
|
113
|
+
layers.env?.app?.name ??
|
|
114
|
+
layers.runtime?.app?.name ??
|
|
115
|
+
layers.userConfig?.app?.name;
|
|
116
|
+
if (typeof explicit === 'string' && explicit.trim()) return explicit.trim();
|
|
117
|
+
|
|
118
|
+
try {
|
|
119
|
+
const pkg = JSON.parse(readFileSync(join(layers.root, 'package.json'), 'utf8')) as { name?: string };
|
|
120
|
+
if (pkg.name) return pkg.name.replace(/^@[^/]+\//, '').trim();
|
|
121
|
+
} catch {
|
|
122
|
+
// no package.json
|
|
123
|
+
}
|
|
124
|
+
return layers.root.split(/[\\/]/).filter(Boolean).pop() ?? 'nexus';
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Derive the per-project identity fields from `app.name`. Values are only
|
|
129
|
+
* applied as a base layer — an explicit value in a higher config layer wins
|
|
130
|
+
* (including `NEXUS_DB_URI`, which maps to `db.mongodb.uri` via the legacy
|
|
131
|
+
* flat shape).
|
|
132
|
+
*/
|
|
133
|
+
function deriveAppIdentity(appName: string): DeepPartial<NexusConfig> {
|
|
134
|
+
return {
|
|
135
|
+
app: { name: appName },
|
|
136
|
+
auth: {
|
|
137
|
+
jwt: { issuer: appName, audience: `${appName}-client` },
|
|
138
|
+
cookieName: `${appName}_sid`,
|
|
139
|
+
refreshCookieName: `${appName}_rid`,
|
|
140
|
+
},
|
|
141
|
+
redis: { keyPrefix: `${appName}:` },
|
|
142
|
+
db: { mongodb: { uri: `mongodb://localhost:27017/${appName}` } },
|
|
143
|
+
email: { from: `no-reply@${appName}.local` },
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
79
147
|
async function importUserConfig(absPath: string): Promise<UserNexusConfig> {
|
|
80
148
|
const url = pathToFileURL(absPath).href;
|
|
81
149
|
const mod = (await import(url)) as Record<string, unknown>;
|
|
@@ -155,7 +223,13 @@ export async function loadConfigAuto(
|
|
|
155
223
|
});
|
|
156
224
|
}
|
|
157
225
|
|
|
158
|
-
/**
|
|
226
|
+
/**
|
|
227
|
+
* Read-only accessor for tests / bootstrap that don't need file loading.
|
|
228
|
+
* When any source declares `app.name`, the derived identity fields are applied
|
|
229
|
+
* as a base layer (an explicit value in a later source still wins).
|
|
230
|
+
*/
|
|
159
231
|
export function mergeConfig(...sources: DeepPartial<NexusConfig>[]): NexusConfig {
|
|
160
|
-
|
|
232
|
+
const explicitName = [...sources].reverse().find((s) => s?.app?.name)?.app?.name;
|
|
233
|
+
const base = explicitName ? deepMerge(defaults, deriveAppIdentity(explicitName)) : defaults;
|
|
234
|
+
return Object.freeze(nexusConfigSchema.parse(deepMerge(base, ...sources))) as NexusConfig;
|
|
161
235
|
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Process-wide accessor for the loaded config.
|
|
3
|
+
*
|
|
4
|
+
* `createNexusApp()` registers the merged config here at boot so code paths
|
|
5
|
+
* that are not handed a `ctx` (subgraphs, services, listeners, jobs) can read
|
|
6
|
+
* the same identity/namespace values instead of hardcoding them.
|
|
7
|
+
*
|
|
8
|
+
* This is a convenience fallback, not a replacement for DI — prefer the
|
|
9
|
+
* `ctx.config` / resolver-context value when a request context is available.
|
|
10
|
+
*/
|
|
11
|
+
import type { NexusConfig } from './types.js';
|
|
12
|
+
|
|
13
|
+
let current: NexusConfig | null = null;
|
|
14
|
+
|
|
15
|
+
/** Register the config for this process (called by createNexusApp at boot). */
|
|
16
|
+
export function setNexusConfig(config: NexusConfig): void {
|
|
17
|
+
current = config;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** The config registered for this process, or null before boot. */
|
|
21
|
+
export function getNexusConfig(): NexusConfig | null {
|
|
22
|
+
return current;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Reset the process config (tests / hot reload). */
|
|
26
|
+
export function resetNexusConfig(): void {
|
|
27
|
+
current = null;
|
|
28
|
+
}
|
package/src/config/dbAccess.ts
CHANGED
|
@@ -24,3 +24,24 @@ export function fusionEnabled(config: NexusConfig): boolean {
|
|
|
24
24
|
export function fusionDir(config: NexusConfig): string {
|
|
25
25
|
return config.db.fusion.dir;
|
|
26
26
|
}
|
|
27
|
+
|
|
28
|
+
/** True when the backend delegates to a standalone Fusion server over HTTP
|
|
29
|
+
* (no in-process engine). Triggered by `db.fusion.remote` or a set `url`. */
|
|
30
|
+
export function fusionRemote(config: NexusConfig): boolean {
|
|
31
|
+
return config.db.fusion.remote === true || !!config.db.fusion.url;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Standalone Fusion server base URL (empty when embedded). */
|
|
35
|
+
export function fusionUrl(config: NexusConfig): string {
|
|
36
|
+
return config.db.fusion.url ?? '';
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Which backend serves cache duties — 'fusion' (default) or 'redis'. */
|
|
40
|
+
export function cacheActive(config: NexusConfig): 'fusion' | 'redis' {
|
|
41
|
+
return config.cache?.active ?? 'fusion';
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** True when the app uses an external Redis server for cache. */
|
|
45
|
+
export function redisCacheEnabled(config: NexusConfig): boolean {
|
|
46
|
+
return cacheActive(config) === 'redis';
|
|
47
|
+
}
|
package/src/config/defaults.ts
CHANGED
|
@@ -6,6 +6,11 @@ import type { NexusConfig } from './types.js';
|
|
|
6
6
|
*/
|
|
7
7
|
export const defaults: NexusConfig = {
|
|
8
8
|
env: 'development',
|
|
9
|
+
app: {
|
|
10
|
+
// Overridden by ConfigLoader with the project slug (package.json name or
|
|
11
|
+
// folder name) when not set explicitly in nexus.config.ts.
|
|
12
|
+
name: 'nexus',
|
|
13
|
+
},
|
|
9
14
|
server: {
|
|
10
15
|
port: 4000,
|
|
11
16
|
host: 'localhost',
|
|
@@ -30,6 +35,8 @@ export const defaults: NexusConfig = {
|
|
|
30
35
|
},
|
|
31
36
|
fusion: {
|
|
32
37
|
enabled: true,
|
|
38
|
+
remote: false,
|
|
39
|
+
url: '',
|
|
33
40
|
dir: 'storage/fusion',
|
|
34
41
|
persist: true,
|
|
35
42
|
cacheCapacity: 10_000,
|
|
@@ -87,11 +94,10 @@ export const defaults: NexusConfig = {
|
|
|
87
94
|
},
|
|
88
95
|
ads: {
|
|
89
96
|
enabled: false,
|
|
90
|
-
|
|
97
|
+
publisherId: '',
|
|
91
98
|
clientId: '',
|
|
92
99
|
clientSecret: '',
|
|
93
100
|
refreshToken: '',
|
|
94
|
-
customerId: '',
|
|
95
101
|
},
|
|
96
102
|
webrtc: {
|
|
97
103
|
rtcMinPort: 40000,
|
|
@@ -131,7 +137,7 @@ export const defaults: NexusConfig = {
|
|
|
131
137
|
level: 'info',
|
|
132
138
|
format: 'pretty',
|
|
133
139
|
console: true,
|
|
134
|
-
dir: 'logs',
|
|
140
|
+
dir: 'storage/logs',
|
|
135
141
|
maxFileSize: 10 * 1024 * 1024, // 10 MiB
|
|
136
142
|
maxFiles: 7,
|
|
137
143
|
},
|
|
@@ -149,14 +155,8 @@ export const defaults: NexusConfig = {
|
|
|
149
155
|
host: 'localhost',
|
|
150
156
|
enabled: true,
|
|
151
157
|
},
|
|
152
|
-
fusion: {
|
|
153
|
-
dir: 'storage/fusion',
|
|
154
|
-
persist: true,
|
|
155
|
-
cacheCapacity: 10_000,
|
|
156
|
-
cacheTtlMs: 5_000,
|
|
157
|
-
walCompactThreshold: 1_000,
|
|
158
|
-
},
|
|
159
158
|
cache: {
|
|
159
|
+
active: 'fusion',
|
|
160
160
|
dir: 'storage/fusion/cache',
|
|
161
161
|
persist: true,
|
|
162
162
|
cacheCapacity: 10_000,
|
package/src/config/env.ts
CHANGED
|
@@ -50,6 +50,26 @@ function coerce(value: string): string | number | boolean {
|
|
|
50
50
|
return v;
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
+
/**
|
|
54
|
+
* Runtime-only `NEXUS_*` variables that are NOT config keys.
|
|
55
|
+
*
|
|
56
|
+
* These are process controls injected by the CLI / supervisor (`nexus dev` sets
|
|
57
|
+
* NEXUS_PORT and NEXUS_PORT_STEP on every child) or read directly by the
|
|
58
|
+
* framework at boot. Mapping them into the config tree corrupts it — e.g.
|
|
59
|
+
* NEXUS_PORT creates `{ port: 4000 }` and NEXUS_PORT_STEP then tries to write
|
|
60
|
+
* `port.step` onto that number, throwing `Cannot create property 'step' on
|
|
61
|
+
* number` and killing backend boot whenever ports step up.
|
|
62
|
+
*/
|
|
63
|
+
const RUNTIME_ENV_VARS = new Set([
|
|
64
|
+
'NEXUS_PORT', // per-service resolved port (read by createNexusApp)
|
|
65
|
+
'NEXUS_PORT_STEP', // coordinated step-up delta (read by the CLI)
|
|
66
|
+
'NEXUS_HOST', // bind host injected by the supervisor
|
|
67
|
+
'NEXUS_PROJECT_ROOT', // license/config resolution root injected by the CLI
|
|
68
|
+
'NEXUS_TUI_NO_MOUSE', // dev panel control
|
|
69
|
+
'NEXUS_TUI_DEBUG', // dev panel control
|
|
70
|
+
'NEXUS_AI_FIX', // `nexus dev --ai-fix` toggle
|
|
71
|
+
]);
|
|
72
|
+
|
|
53
73
|
/**
|
|
54
74
|
* Two `_`-separated env segments that form ONE camelCase config field.
|
|
55
75
|
* Generic splitting would turn `KEY_ID` into `key.id` (a nested object) —
|
|
@@ -69,6 +89,8 @@ const PAIR_FIELDS: Record<string, string> = {
|
|
|
69
89
|
'webhook_secret': 'webhookSecret',
|
|
70
90
|
'customer_id': 'customerId',
|
|
71
91
|
'developer_token': 'developerToken',
|
|
92
|
+
'publisher_id': 'publisherId',
|
|
93
|
+
'account_id': 'accountId',
|
|
72
94
|
'refresh_token': 'refreshToken',
|
|
73
95
|
'access_ttl': 'accessTtl',
|
|
74
96
|
'refresh_ttl': 'refreshTtl',
|
|
@@ -91,6 +113,7 @@ export function configFromEnv(env: NodeJS.ProcessEnv = process.env): DeepPartial
|
|
|
91
113
|
const out: Record<string, unknown> = {};
|
|
92
114
|
for (const [rawKey, rawValue] of Object.entries(env)) {
|
|
93
115
|
if (!rawKey.startsWith('NEXUS_') || rawValue === undefined) continue;
|
|
116
|
+
if (RUNTIME_ENV_VARS.has(rawKey)) continue;
|
|
94
117
|
const path = rawKey.slice('NEXUS_'.length).toLowerCase().split('_');
|
|
95
118
|
const last = path.length - 1;
|
|
96
119
|
if (last >= 1) {
|
|
@@ -100,15 +123,37 @@ export function configFromEnv(env: NodeJS.ProcessEnv = process.env): DeepPartial
|
|
|
100
123
|
}
|
|
101
124
|
}
|
|
102
125
|
let node: Record<string, unknown> = out;
|
|
126
|
+
let conflict = false;
|
|
103
127
|
for (let i = 0; i < path.length; i++) {
|
|
104
128
|
const segment = path[i]!;
|
|
105
129
|
if (i === path.length - 1) {
|
|
106
130
|
node[segment] = coerce(rawValue);
|
|
107
131
|
} else {
|
|
108
|
-
|
|
109
|
-
|
|
132
|
+
const existing = node[segment];
|
|
133
|
+
// A scalar here means two env vars disagree about the shape (e.g.
|
|
134
|
+
// NEXUS_SERVER_PORT=4000 plus NEXUS_SERVER_PORT_X). Overwriting would
|
|
135
|
+
// either crash (writing a property onto a primitive) or silently drop
|
|
136
|
+
// config, so skip the offending var and keep the first value.
|
|
137
|
+
if (existing !== undefined && (typeof existing !== 'object' || existing === null)) {
|
|
138
|
+
conflict = true;
|
|
139
|
+
break;
|
|
140
|
+
}
|
|
141
|
+
const child = existing ?? {};
|
|
142
|
+
node[segment] = child;
|
|
143
|
+
node = child as Record<string, unknown>;
|
|
110
144
|
}
|
|
111
145
|
}
|
|
146
|
+
if (conflict && !isKnownRuntimeVar(rawKey)) {
|
|
147
|
+
// Real config vars would silently vanish — make that visible.
|
|
148
|
+
console.warn(
|
|
149
|
+
`[config] ignoring ${rawKey}: its path collides with a scalar already set by another NEXUS_* var`,
|
|
150
|
+
);
|
|
151
|
+
}
|
|
112
152
|
}
|
|
113
153
|
return out as DeepPartial<NexusConfig>;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** True for vars the framework consumes at runtime rather than as config. */
|
|
157
|
+
function isKnownRuntimeVar(key: string): boolean {
|
|
158
|
+
return RUNTIME_ENV_VARS.has(key);
|
|
114
159
|
}
|
package/src/config/index.ts
CHANGED
package/src/config/schema.ts
CHANGED
|
@@ -11,6 +11,9 @@ const providerConfig = z
|
|
|
11
11
|
|
|
12
12
|
export const nexusConfigSchema = z.object({
|
|
13
13
|
env: z.enum(['development', 'production', 'test']).default('development'),
|
|
14
|
+
app: z.object({
|
|
15
|
+
name: z.string().min(1).default('nexus'),
|
|
16
|
+
}).default({ name: 'nexus' }),
|
|
14
17
|
server: z.object({
|
|
15
18
|
port: z.number().int().min(1).max(65535),
|
|
16
19
|
host: z.string().min(1),
|
|
@@ -47,6 +50,8 @@ export const nexusConfigSchema = z.object({
|
|
|
47
50
|
fusion: z
|
|
48
51
|
.object({
|
|
49
52
|
enabled: z.boolean().default(true),
|
|
53
|
+
remote: z.boolean().default(false),
|
|
54
|
+
url: z.string().default(''),
|
|
50
55
|
dir: z.string().min(1),
|
|
51
56
|
persist: z.boolean().default(true),
|
|
52
57
|
cacheCapacity: z.number().int().positive(),
|
|
@@ -55,6 +60,8 @@ export const nexusConfigSchema = z.object({
|
|
|
55
60
|
})
|
|
56
61
|
.default({
|
|
57
62
|
enabled: true,
|
|
63
|
+
remote: false,
|
|
64
|
+
url: '',
|
|
58
65
|
dir: 'storage/fusion',
|
|
59
66
|
persist: true,
|
|
60
67
|
cacheCapacity: 10_000,
|
|
@@ -102,18 +109,21 @@ export const nexusConfigSchema = z.object({
|
|
|
102
109
|
}),
|
|
103
110
|
cookieName: z.string(),
|
|
104
111
|
refreshCookieName: z.string(),
|
|
112
|
+
// NOTE: clientId/clientSecret use z.coerce.string() — env coercion turns
|
|
113
|
+
// all-digit values (e.g. numeric Meta App IDs) into numbers, and OAuth
|
|
114
|
+
// credential fields must accept them rather than crash the boot.
|
|
105
115
|
google: z
|
|
106
116
|
.object({
|
|
107
|
-
clientId: z.string(),
|
|
108
|
-
clientSecret: z.string(),
|
|
117
|
+
clientId: z.coerce.string(),
|
|
118
|
+
clientSecret: z.coerce.string(),
|
|
109
119
|
callbackPath: z.string(),
|
|
110
120
|
scope: z.string(),
|
|
111
121
|
})
|
|
112
122
|
.optional(),
|
|
113
123
|
facebook: z
|
|
114
124
|
.object({
|
|
115
|
-
clientId: z.string(),
|
|
116
|
-
clientSecret: z.string(),
|
|
125
|
+
clientId: z.coerce.string(),
|
|
126
|
+
clientSecret: z.coerce.string(),
|
|
117
127
|
callbackPath: z.string(),
|
|
118
128
|
scope: z.string(),
|
|
119
129
|
})
|
|
@@ -151,11 +161,11 @@ export const nexusConfigSchema = z.object({
|
|
|
151
161
|
}),
|
|
152
162
|
ads: z.object({
|
|
153
163
|
enabled: z.boolean(),
|
|
154
|
-
|
|
164
|
+
publisherId: z.string(),
|
|
155
165
|
clientId: z.string(),
|
|
156
166
|
clientSecret: z.string(),
|
|
157
167
|
refreshToken: z.string(),
|
|
158
|
-
|
|
168
|
+
accountId: z.string().optional(),
|
|
159
169
|
}),
|
|
160
170
|
webrtc: z.object({
|
|
161
171
|
rtcMinPort: z.number().int().min(1).max(65535),
|
|
@@ -201,14 +211,8 @@ export const nexusConfigSchema = z.object({
|
|
|
201
211
|
host: z.string().min(1),
|
|
202
212
|
enabled: z.boolean(),
|
|
203
213
|
}),
|
|
204
|
-
fusion: z.object({
|
|
205
|
-
dir: z.string().min(1).default('storage/fusion'),
|
|
206
|
-
persist: z.boolean().default(true),
|
|
207
|
-
cacheCapacity: z.number().int().positive().default(10_000),
|
|
208
|
-
cacheTtlMs: z.number().int().positive().default(5_000),
|
|
209
|
-
walCompactThreshold: z.number().int().positive().default(1_000),
|
|
210
|
-
}),
|
|
211
214
|
cache: z.object({
|
|
215
|
+
active: z.enum(['fusion', 'redis']).default('fusion'),
|
|
212
216
|
dir: z.string().min(1).default('storage/fusion/cache'),
|
|
213
217
|
persist: z.boolean().default(true),
|
|
214
218
|
cacheCapacity: z.number().int().positive().default(10_000),
|
package/src/config/types.ts
CHANGED
|
@@ -8,6 +8,16 @@
|
|
|
8
8
|
|
|
9
9
|
export type Env = 'development' | 'production' | 'test';
|
|
10
10
|
|
|
11
|
+
/**
|
|
12
|
+
* Project identity. `app.name` is the single source of truth for the project
|
|
13
|
+
* slug; cookie names, JWT issuer/audience, redis key prefix and mail `from`
|
|
14
|
+
* are derived from it when not set explicitly.
|
|
15
|
+
*/
|
|
16
|
+
export interface AppConfig {
|
|
17
|
+
/** Project slug, e.g. 'nexus-bhooai-com'. Derives auth identity + namespaces. */
|
|
18
|
+
name: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
11
21
|
export interface ServerConfig {
|
|
12
22
|
port: number;
|
|
13
23
|
host: string;
|
|
@@ -23,7 +33,8 @@ export interface ServerConfig {
|
|
|
23
33
|
}
|
|
24
34
|
|
|
25
35
|
export interface UploadsConfig {
|
|
26
|
-
/** Directory for persisted files, relative to the
|
|
36
|
+
/** Directory for persisted files, relative to the storage root
|
|
37
|
+
* (`createNexusApp`'s `storageRoot`, default = project root). */
|
|
27
38
|
dir: string;
|
|
28
39
|
/** Public URL path for upload and download requests. */
|
|
29
40
|
path: string;
|
|
@@ -52,6 +63,17 @@ export interface MongoDbConfig {
|
|
|
52
63
|
export interface FusionDbConfig {
|
|
53
64
|
/** Open the Fusion engine at boot when true. Default true. */
|
|
54
65
|
enabled: boolean;
|
|
66
|
+
/**
|
|
67
|
+
* Delegate to a **standalone Fusion server** instead of opening the engine
|
|
68
|
+
* in-process. When true (or when `url` is non-empty) the backend no longer
|
|
69
|
+
* owns `storage/fusion/fusion.wal`; every read/write goes over HTTP to the
|
|
70
|
+
* `fusion` service started by `nexus dev`. This lets several apps share one
|
|
71
|
+
* Fusion database and keeps the backend process free of the Rust engine.
|
|
72
|
+
*/
|
|
73
|
+
remote: boolean;
|
|
74
|
+
/** Standalone Fusion server base URL, e.g. `http://127.0.0.1:5000`.
|
|
75
|
+
* Empty (and `remote: false`) keeps the embedded in-process engine. */
|
|
76
|
+
url: string;
|
|
55
77
|
/** Durable storage dir, relative to the project root. Omit/empty for in-memory. */
|
|
56
78
|
dir: string;
|
|
57
79
|
/** Persist the WAL to `dir` (persist=false runs purely in-memory). */
|
|
@@ -165,11 +187,11 @@ export interface CertsConfig {
|
|
|
165
187
|
|
|
166
188
|
export interface AdsConfig {
|
|
167
189
|
enabled: boolean;
|
|
168
|
-
|
|
190
|
+
publisherId: string;
|
|
169
191
|
clientId: string;
|
|
170
192
|
clientSecret: string;
|
|
171
193
|
refreshToken: string;
|
|
172
|
-
|
|
194
|
+
accountId?: string;
|
|
173
195
|
}
|
|
174
196
|
|
|
175
197
|
export interface WebRtcConfig {
|
|
@@ -303,22 +325,14 @@ export interface AppPortConfig {
|
|
|
303
325
|
enabled?: boolean;
|
|
304
326
|
}
|
|
305
327
|
|
|
306
|
-
export interface FusionConfig {
|
|
307
|
-
/** Durable storage dir, resolved against the backend's projectRoot. Empty or
|
|
308
|
-
* persist=false runs the engine purely in-memory. Default 'storage/fusion'. */
|
|
309
|
-
dir: string;
|
|
310
|
-
/** When false, ignore `dir` and run purely in-memory. Default true. */
|
|
311
|
-
persist: boolean;
|
|
312
|
-
/** L1 cache capacity (entries). Default 10_000. */
|
|
313
|
-
cacheCapacity: number;
|
|
314
|
-
/** L1 cache TTL in ms. Default 5_000. */
|
|
315
|
-
cacheTtlMs: number;
|
|
316
|
-
/** WAL compaction threshold (records). Default 1_000. */
|
|
317
|
-
walCompactThreshold: number;
|
|
318
|
-
}
|
|
319
|
-
|
|
320
328
|
/** Redis-like FusionCache settings (`@bhooai/nexus-fusion/cache`). */
|
|
321
329
|
export interface CacheConfig {
|
|
330
|
+
/** Which backend serves cache duties — 'fusion' (embedded, zero-dep,
|
|
331
|
+
* default) or 'redis' (external server at `redis.url`). Override with
|
|
332
|
+
* NEXUS_CACHE_ACTIVE. Single-instance apps are fully served by fusion
|
|
333
|
+
* (strings/hashes/lists/sets + TTL); pick 'redis' for multi-instance
|
|
334
|
+
* pub/sub fan-out. */
|
|
335
|
+
active: 'fusion' | 'redis';
|
|
322
336
|
/** Durable storage dir, resolved against the backend's projectRoot. Cache
|
|
323
337
|
* lives in its own subdir (default 'storage/fusion/cache') so it never
|
|
324
338
|
* shares a WAL with the main fusion engine. */
|
|
@@ -339,6 +353,7 @@ export interface CacheConfig {
|
|
|
339
353
|
|
|
340
354
|
export interface NexusConfig {
|
|
341
355
|
env: Env;
|
|
356
|
+
app: AppConfig;
|
|
342
357
|
server: ServerConfig;
|
|
343
358
|
uploads: UploadsConfig;
|
|
344
359
|
db: DbConfig;
|
|
@@ -356,7 +371,6 @@ export interface NexusConfig {
|
|
|
356
371
|
plugins: PluginsConfig;
|
|
357
372
|
frontend: FrontendConfig;
|
|
358
373
|
admin: AdminConfig;
|
|
359
|
-
fusion: FusionConfig;
|
|
360
374
|
cache: CacheConfig;
|
|
361
375
|
/** Multi-app port layout. Empty = every service uses its section default. */
|
|
362
376
|
apps: AppPortConfig[];
|
package/src/http/Server.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { randomUUID } from 'node:crypto';
|
|
|
5
5
|
import type { Middleware, RequestContext } from './context.js';
|
|
6
6
|
import { createContext } from './context.js';
|
|
7
7
|
import type { Router } from './Router.js';
|
|
8
|
+
import type { NexusConfig } from '../config/types.js';
|
|
8
9
|
import { NexusError, toNexusError } from '../errors.js';
|
|
9
10
|
|
|
10
11
|
/** Generate a request id (overridable for tests/injection). */
|
|
@@ -33,6 +34,8 @@ export interface ServerOptions {
|
|
|
33
34
|
renderError?: (err: unknown, ctx: RequestContext) => Promise<void> | void;
|
|
34
35
|
/** Custom 404 renderer for unmatched routes. Falls back to JSON. */
|
|
35
36
|
renderNotFound?: (ctx: RequestContext) => Promise<void> | void;
|
|
37
|
+
/** Loaded project config, exposed to handlers as `ctx.config`. */
|
|
38
|
+
config?: NexusConfig;
|
|
36
39
|
}
|
|
37
40
|
|
|
38
41
|
/**
|
|
@@ -87,6 +90,7 @@ export class NexusServer {
|
|
|
87
90
|
const requestId = (req.headers['x-request-id'] as string) ?? newRequestId();
|
|
88
91
|
res.setHeader('x-request-id', requestId);
|
|
89
92
|
const ctx = createContext(req, res, requestId);
|
|
93
|
+
ctx.config = this.opts.config ?? null;
|
|
90
94
|
|
|
91
95
|
try {
|
|
92
96
|
await this.runPipeline(ctx);
|
package/src/http/context.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
2
|
import type { Socket } from 'node:net';
|
|
3
|
+
import type { NexusConfig } from '../config/types.js';
|
|
3
4
|
|
|
4
5
|
/** Route path parameters extracted from the URL. */
|
|
5
6
|
export type Params = Record<string, string>;
|
|
@@ -26,6 +27,11 @@ export interface RequestContext {
|
|
|
26
27
|
body: unknown;
|
|
27
28
|
/** Per-request state. */
|
|
28
29
|
state: State;
|
|
30
|
+
/**
|
|
31
|
+
* The loaded project config. Injected by NexusServer at request time; may be
|
|
32
|
+
* null when a context is built standalone (e.g. in tests).
|
|
33
|
+
*/
|
|
34
|
+
config?: NexusConfig | null;
|
|
29
35
|
/** Request id (also in headers as x-request-id). */
|
|
30
36
|
requestId: string;
|
|
31
37
|
/** The matched route pattern, e.g. "/users/:id". */
|
package/tests/config.test.ts
CHANGED
|
@@ -25,6 +25,57 @@ describe('configFromEnv', () => {
|
|
|
25
25
|
expect((cfg as any).ai?.serverUrl).toBe('http://ai:8000');
|
|
26
26
|
expect((cfg as any).ai?.server?.url).toBeUndefined();
|
|
27
27
|
});
|
|
28
|
+
|
|
29
|
+
it('accepts a numeric Meta App ID for auth.facebook.clientId', () => {
|
|
30
|
+
// Env coercion turns all-digit values into numbers; the schema must
|
|
31
|
+
// coerce them back so boot does not crash (ZodError on auth.facebook.clientId).
|
|
32
|
+
const partial = configFromEnv({ NEXUS_AUTH_FACEBOOK_CLIENT_ID: '123456789012345' });
|
|
33
|
+
expect((partial as any).auth?.facebook?.clientId).toBe(123456789012345);
|
|
34
|
+
const cfg = mergeConfig({
|
|
35
|
+
auth: {
|
|
36
|
+
facebook: {
|
|
37
|
+
clientId: (partial as any).auth.facebook.clientId,
|
|
38
|
+
clientSecret: 's3cret',
|
|
39
|
+
callbackPath: '/auth/facebook/callback',
|
|
40
|
+
scope: 'email',
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
});
|
|
44
|
+
expect(cfg.auth.facebook?.clientId).toBe('123456789012345');
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe('app identity derivation', () => {
|
|
49
|
+
it('derives auth/redis/mongo/email identity from app.name', () => {
|
|
50
|
+
const cfg = mergeConfig({ app: { name: 'my-app' } });
|
|
51
|
+
expect(cfg.app.name).toBe('my-app');
|
|
52
|
+
expect(cfg.auth.jwt.issuer).toBe('my-app');
|
|
53
|
+
expect(cfg.auth.jwt.audience).toBe('my-app-client');
|
|
54
|
+
expect(cfg.auth.cookieName).toBe('my-app_sid');
|
|
55
|
+
expect(cfg.auth.refreshCookieName).toBe('my-app_rid');
|
|
56
|
+
expect(cfg.redis.keyPrefix).toBe('my-app:');
|
|
57
|
+
expect(cfg.db.mongodb.uri).toBe('mongodb://localhost:27017/my-app');
|
|
58
|
+
expect(cfg.email.from).toBe('no-reply@my-app.local');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('lets an explicit mongo uri override the derived one', () => {
|
|
62
|
+
const cfg = mergeConfig({
|
|
63
|
+
app: { name: 'my-app' },
|
|
64
|
+
db: { mongodb: { uri: 'mongodb://remote:27017/custom' } },
|
|
65
|
+
});
|
|
66
|
+
expect(cfg.db.mongodb.uri).toBe('mongodb://remote:27017/custom');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('lets an explicit value override the derived default', () => {
|
|
70
|
+
const cfg = mergeConfig({ app: { name: 'my-app' }, auth: { cookieName: 'custom_sid' } });
|
|
71
|
+
expect(cfg.auth.cookieName).toBe('custom_sid');
|
|
72
|
+
expect(cfg.auth.jwt.issuer).toBe('my-app');
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('defaults app.name to nexus when unset', () => {
|
|
76
|
+
const cfg = mergeConfig({});
|
|
77
|
+
expect(cfg.app.name).toBe('nexus');
|
|
78
|
+
});
|
|
28
79
|
});
|
|
29
80
|
|
|
30
81
|
describe('mergeConfig', () => {
|