@bhooai/nexus-core 2.0.18 → 2.0.21
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 +78 -12
- 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,12 +15,12 @@
|
|
|
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';
|
|
22
22
|
import { bodyParser } from '../http/bodyParser.js';
|
|
23
|
-
import type { Handler, Middleware } from '../http/context.js';
|
|
23
|
+
import type { Handler, Middleware, RequestContext } from '../http/context.js';
|
|
24
24
|
import { discoverBackend, importDefault, type DiscoveryResult } from './discover.js';
|
|
25
25
|
import type { RoutesFile, RouteDef } from './defineRoutes.js';
|
|
26
26
|
import { DefaultErrorHandler, ErrorHandler } from './ErrorHandler.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';
|
|
@@ -41,9 +42,36 @@ import { registerAuthRoutes } from './authModule.js';
|
|
|
41
42
|
import { issueCsrfToken, getCsrfToken, csrf, authToken, requireRole } from '@bhooai/nexus-auth';
|
|
42
43
|
import { connect } from '@bhooai/nexus-data';
|
|
43
44
|
import { ensureLicense } from '@bhooai/nexus-crypto';
|
|
44
|
-
import { createGateway, createFederatedGateway, graphqlHttpHandler, getExplorerHtml, helloSubgraph, SubscriptionServer } from '@bhooai/nexus-graphql';
|
|
45
45
|
import type { Subgraph, GraphQLContext } from '@bhooai/nexus-graphql';
|
|
46
46
|
|
|
47
|
+
/**
|
|
48
|
+
* Lazily loaded `@bhooai/nexus-graphql` — an optional peer, not a hard
|
|
49
|
+
* dependency. Static-importing it here would make merely *loading*
|
|
50
|
+
* `@bhooai/nexus-core` (e.g. `nexus init`, which never serves GraphQL)
|
|
51
|
+
* crash when the peer isn't installed. Call sites `await graphqlApi()`
|
|
52
|
+
* instead; null means "not installed" and the gateway mount is skipped
|
|
53
|
+
* with a warning.
|
|
54
|
+
*
|
|
55
|
+
* Typed as `any` (not `typeof import(...)`) so the emitted declarations
|
|
56
|
+
* don't require the peer's types to be present for consumers.
|
|
57
|
+
*/
|
|
58
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
59
|
+
let _graphqlApi: any = undefined;
|
|
60
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
61
|
+
async function graphqlApi(): Promise<any> {
|
|
62
|
+
if (_graphqlApi !== undefined) return _graphqlApi;
|
|
63
|
+
try {
|
|
64
|
+
_graphqlApi = await import('@bhooai/nexus-graphql');
|
|
65
|
+
} catch (e) {
|
|
66
|
+
if ((e as { code?: string })?.code === 'ERR_MODULE_NOT_FOUND') {
|
|
67
|
+
_graphqlApi = null;
|
|
68
|
+
} else {
|
|
69
|
+
throw e;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return _graphqlApi;
|
|
73
|
+
}
|
|
74
|
+
|
|
47
75
|
export interface CreateNexusAppOptions {
|
|
48
76
|
/** Identifier for this backend, used in admin, telemetry, logs. */
|
|
49
77
|
name?: string;
|
|
@@ -51,8 +79,19 @@ export interface CreateNexusAppOptions {
|
|
|
51
79
|
tech?: 'react' | 'future';
|
|
52
80
|
/** Path to the backend src/ folder. Defaults to `<cwd>/src`. */
|
|
53
81
|
srcRoot?: string;
|
|
54
|
-
/** Project root
|
|
82
|
+
/** Project root — anchors DB data, cache, logs and certs (`storage/fusion`,
|
|
83
|
+
* `storage/logs`, `certs/`, .nexus-down). Defaults to srcRoot/.. */
|
|
55
84
|
projectRoot?: string;
|
|
85
|
+
/**
|
|
86
|
+
* Root for file storage (uploads + private). Resolves to
|
|
87
|
+
* `<storageRoot>/storage/{uploads,private}` and is served at `/uploads`.
|
|
88
|
+
*
|
|
89
|
+
* Split from `projectRoot` so a monorepo can keep durable DB data at the
|
|
90
|
+
* workspace root while file uploads live next to the backend
|
|
91
|
+
* (e.g. projectRoot = repo root, storageRoot = apps/backend).
|
|
92
|
+
* Defaults to `projectRoot`.
|
|
93
|
+
*/
|
|
94
|
+
storageRoot?: string;
|
|
56
95
|
/** Override the auto-discovered config (for tests). */
|
|
57
96
|
config?: NexusConfig;
|
|
58
97
|
/** Additional global middleware applied after built-ins. */
|
|
@@ -102,6 +141,9 @@ export async function createNexusApp(opts: CreateNexusAppOptions = {}): Promise<
|
|
|
102
141
|
const name = opts.name ?? 'backend';
|
|
103
142
|
const srcRoot = resolve(opts.srcRoot ?? resolve(process.cwd(), 'src'));
|
|
104
143
|
const projectRoot = resolve(opts.projectRoot ?? dirname(srcRoot));
|
|
144
|
+
// File storage (uploads/private) can be anchored separately from projectRoot
|
|
145
|
+
// so DB data stays at the workspace root while files live with the backend.
|
|
146
|
+
const storageRoot = resolve(opts.storageRoot ?? projectRoot);
|
|
105
147
|
let config = opts.config ?? (await loadConfigAuto({ root: projectRoot }));
|
|
106
148
|
|
|
107
149
|
// License check at project start — verify the token (signature via fetched
|
|
@@ -137,6 +179,10 @@ export async function createNexusApp(opts: CreateNexusAppOptions = {}): Promise<
|
|
|
137
179
|
config = Object.freeze({ ...config, server: { ...config.server, port: finalPort } }) as NexusConfig;
|
|
138
180
|
}
|
|
139
181
|
|
|
182
|
+
// Publish the resolved config process-wide so subgraphs/services without a
|
|
183
|
+
// request context can read identity/namespace values (see getNexusConfig).
|
|
184
|
+
setNexusConfig(config);
|
|
185
|
+
|
|
140
186
|
const container = new Container();
|
|
141
187
|
const router = new Router();
|
|
142
188
|
|
|
@@ -171,6 +217,16 @@ export async function createNexusApp(opts: CreateNexusAppOptions = {}): Promise<
|
|
|
171
217
|
}
|
|
172
218
|
const fusionDb = await openAppFusion(projectRoot, config);
|
|
173
219
|
initUserStore(new FusionUserStore(fusionDb));
|
|
220
|
+
// Dev-only: expose this same engine over HTTP so `nexus dev`'s Fusion View
|
|
221
|
+
// and `nexus db:view` read/write the SAME state the backend serves from —
|
|
222
|
+
// no second engine, no stale reads, no refresh needed.
|
|
223
|
+
//
|
|
224
|
+
// Skipped in remote mode: the standalone Fusion server already speaks HTTP,
|
|
225
|
+
// so tooling talks to it directly (the backend owns no engine to expose).
|
|
226
|
+
if (config.env === 'development' && !fusionRemote(config)) {
|
|
227
|
+
registerFusionDevRoutes(router);
|
|
228
|
+
console.log(`[${name}] Fusion dev API mounted (dev only)`);
|
|
229
|
+
}
|
|
174
230
|
} else {
|
|
175
231
|
if (mongoEnabled(config)) initUserModel();
|
|
176
232
|
initUserStore(new MongoUserStore());
|
|
@@ -179,7 +235,7 @@ export async function createNexusApp(opts: CreateNexusAppOptions = {}): Promise<
|
|
|
179
235
|
// ------------------------------------------------------------------
|
|
180
236
|
// Storage facade + queue + mail — always configured, even if minimal
|
|
181
237
|
// ------------------------------------------------------------------
|
|
182
|
-
configureStorageFromEnv(
|
|
238
|
+
configureStorageFromEnv(storageRoot, config.auth?.jwt?.secret ?? 'nexus-insecure');
|
|
183
239
|
configureQueue(opts.queueAdapter ?? new InMemoryQueueAdapter());
|
|
184
240
|
configureMailDriver(logMailDriver);
|
|
185
241
|
configureMailRenderer(async (templateName, data) => renderMailTemplate(projectRoot, templateName, data));
|
|
@@ -339,8 +395,16 @@ export async function createNexusApp(opts: CreateNexusAppOptions = {}): Promise<
|
|
|
339
395
|
let graphqlMounted = false;
|
|
340
396
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
341
397
|
let graphqlGateway: any = null;
|
|
398
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
399
|
+
let GraphqlSubscriptionServer: any = null;
|
|
342
400
|
if (tech === 'react') {
|
|
343
401
|
{
|
|
402
|
+
const gql = await graphqlApi();
|
|
403
|
+
if (!gql) {
|
|
404
|
+
console.warn(`[${name}] @bhooai/nexus-graphql is not installed — GraphQL gateway skipped (npm i @bhooai/nexus-graphql to enable).`);
|
|
405
|
+
} else {
|
|
406
|
+
const { createGateway, createFederatedGateway, graphqlHttpHandler, getExplorerHtml, helloSubgraph, SubscriptionServer } = gql;
|
|
407
|
+
GraphqlSubscriptionServer = SubscriptionServer;
|
|
344
408
|
const explorerEnabled = (config.graphql as unknown as { explorer?: boolean })?.explorer ?? true;
|
|
345
409
|
const helloEnabled = (config.graphql as unknown as { hello?: boolean })?.hello !== false;
|
|
346
410
|
try {
|
|
@@ -386,7 +450,7 @@ export async function createNexusApp(opts: CreateNexusAppOptions = {}): Promise<
|
|
|
386
450
|
gateway,
|
|
387
451
|
introspection: config.graphql?.introspection ?? true,
|
|
388
452
|
requireMutationCsrf: config.graphql?.requireMutationCsrf ?? true,
|
|
389
|
-
context: (ctx) => ({ request: ctx, user: (ctx.state.user as GraphQLContext['user']), ...opts.graphqlContext }),
|
|
453
|
+
context: (ctx: RequestContext) => ({ request: ctx, user: (ctx.state.user as GraphQLContext['user']), config: ctx.config ?? config, ...opts.graphqlContext }),
|
|
390
454
|
});
|
|
391
455
|
if (explorerEnabled) {
|
|
392
456
|
const explorerHtml = getExplorerHtml({
|
|
@@ -433,7 +497,7 @@ export async function createNexusApp(opts: CreateNexusAppOptions = {}): Promise<
|
|
|
433
497
|
if (helloEnabled) {
|
|
434
498
|
// hello is available even in fallback — route through its gateway
|
|
435
499
|
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 }) });
|
|
500
|
+
const h = graphqlHttpHandler({ gateway: gw, introspection: config.graphql?.introspection ?? true, requireMutationCsrf: config.graphql?.requireMutationCsrf ?? true, context: (ctx: RequestContext) => ({ request: ctx, user: (ctx.state.user as GraphQLContext['user']), config: ctx.config ?? config, ...opts.graphqlContext }) });
|
|
437
501
|
return h(ctx);
|
|
438
502
|
}
|
|
439
503
|
ctx.json({ errors: [{ message: 'No GraphQL subgraph found. Add one with: npx nexus make:subgraph <name> (then restart)' }] }, 404);
|
|
@@ -443,7 +507,7 @@ export async function createNexusApp(opts: CreateNexusAppOptions = {}): Promise<
|
|
|
443
507
|
try {
|
|
444
508
|
if (helloEnabled) {
|
|
445
509
|
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 }) }));
|
|
510
|
+
router.add('POST', graphqlPath, graphqlHttpHandler({ gateway: gw, introspection: config.graphql?.introspection ?? true, requireMutationCsrf: config.graphql?.requireMutationCsrf ?? true, context: (ctx: RequestContext) => ({ request: ctx, user: (ctx.state.user as GraphQLContext['user']), config: ctx.config ?? config, ...opts.graphqlContext }) }));
|
|
447
511
|
} else {
|
|
448
512
|
router.add('POST', graphqlPath, async (ctx) => ctx.json({ errors: [{ message: 'No GraphQL subgraph found. Add one with: npx nexus make:subgraph <name>' }] }, 404));
|
|
449
513
|
}
|
|
@@ -453,6 +517,7 @@ export async function createNexusApp(opts: CreateNexusAppOptions = {}): Promise<
|
|
|
453
517
|
}
|
|
454
518
|
}
|
|
455
519
|
}
|
|
520
|
+
}
|
|
456
521
|
|
|
457
522
|
// ------------------------------------------------------------------
|
|
458
523
|
// Admin module — request log buffer, lazy DB, admin + AI proxy routes
|
|
@@ -510,6 +575,7 @@ export async function createNexusApp(opts: CreateNexusAppOptions = {}): Promise<
|
|
|
510
575
|
// ------------------------------------------------------------------
|
|
511
576
|
const server = new NexusServer({
|
|
512
577
|
router,
|
|
578
|
+
config,
|
|
513
579
|
bodyLimit: config.server.bodyLimit,
|
|
514
580
|
trustProxy: config.server.trustProxy,
|
|
515
581
|
...(config.server.https && config.server.certFile && config.server.keyFile
|
|
@@ -707,19 +773,19 @@ export async function createNexusApp(opts: CreateNexusAppOptions = {}): Promise<
|
|
|
707
773
|
// and config.graphql.subscriptions is enabled.
|
|
708
774
|
// ------------------------------------------------------------------
|
|
709
775
|
const graphqlSubscriptions = (config.graphql as unknown as { subscriptions?: boolean })?.subscriptions ?? true;
|
|
710
|
-
if (graphqlGateway && graphqlSubscriptions && typeof graphqlGateway.subscribe === 'function') {
|
|
776
|
+
if (graphqlGateway && GraphqlSubscriptionServer && graphqlSubscriptions && typeof graphqlGateway.subscribe === 'function') {
|
|
711
777
|
try {
|
|
712
778
|
const subPath = `${config.graphql?.path ?? '/graphql'}/ws`;
|
|
713
|
-
new
|
|
779
|
+
new GraphqlSubscriptionServer({ httpServer: server.httpServer, gateway: graphqlGateway, path: subPath });
|
|
714
780
|
console.log(`[${name}] GraphQL subscriptions mounted at ${subPath}`);
|
|
715
781
|
} catch (err) {
|
|
716
782
|
console.warn(`[${name}] failed to mount GraphQL subscriptions:`, err);
|
|
717
783
|
}
|
|
718
784
|
}
|
|
719
785
|
|
|
720
|
-
// Public uploads: serve GET /uploads/* from storage/uploads
|
|
786
|
+
// Public uploads: serve GET /uploads/* from <storageRoot>/storage/uploads.
|
|
721
787
|
// This was missing — 404 `No route for GET /uploads/media/...`.
|
|
722
|
-
server.use(serveStatic(join(
|
|
788
|
+
server.use(serveStatic(join(storageRoot, 'storage', 'uploads'), { prefix: '/uploads' }));
|
|
723
789
|
|
|
724
790
|
// Signed private files: GET /files/<encodedPath>?expires=&sig=
|
|
725
791
|
// 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