bhooai-nexus 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +184 -0
- package/apps/admin/index.html +12 -0
- package/apps/admin/package.json +25 -0
- package/apps/admin/postcss.config.js +6 -0
- package/apps/admin/public/anime-girl.svg +84 -0
- package/apps/admin/public/bhooai-nexus-logo.png +0 -0
- package/apps/admin/public/bhooai-nexus-logo.svg +135 -0
- package/apps/admin/public/favicon.ico +0 -0
- package/apps/admin/src/main.tsx +10 -0
- package/apps/admin/src/vite-env.d.ts +18 -0
- package/apps/admin/tailwind.config.js +10 -0
- package/apps/admin/tsconfig.json +17 -0
- package/apps/admin/vite.config.ts +68 -0
- package/bin/nexus.js +52 -0
- package/package.json +108 -0
- package/packages/nexus-admin/package.json +32 -0
- package/packages/nexus-admin/src/App.tsx +4878 -0
- package/packages/nexus-admin/src/alertCenter.tsx +150 -0
- package/packages/nexus-admin/src/api.ts +601 -0
- package/packages/nexus-admin/src/assets/bhooai-nexus-logo.svg +25 -0
- package/packages/nexus-admin/src/index.css +3635 -0
- package/packages/nexus-admin/src/vite-env.d.ts +19 -0
- package/packages/nexus-ads/package.json +20 -0
- package/packages/nexus-ads/src/GaqlBuilder.ts +72 -0
- package/packages/nexus-ads/src/GoogleAdsClient.ts +121 -0
- package/packages/nexus-ads/src/fetchTransport.ts +10 -0
- package/packages/nexus-ads/src/index.ts +20 -0
- package/packages/nexus-ads/src/types.ts +41 -0
- package/packages/nexus-ai-client/package.json +18 -0
- package/packages/nexus-ai-client/src/client.ts +159 -0
- package/packages/nexus-ai-client/src/errors.ts +21 -0
- package/packages/nexus-ai-client/src/index.ts +4 -0
- package/packages/nexus-ai-client/src/sse.ts +53 -0
- package/packages/nexus-ai-client/src/types.ts +174 -0
- package/packages/nexus-auth/package.json +22 -0
- package/packages/nexus-auth/src/auth.ts +116 -0
- package/packages/nexus-auth/src/bcryptjs.d.ts +10 -0
- package/packages/nexus-auth/src/cors.ts +91 -0
- package/packages/nexus-auth/src/csrf.ts +158 -0
- package/packages/nexus-auth/src/headers.ts +55 -0
- package/packages/nexus-auth/src/index.ts +11 -0
- package/packages/nexus-auth/src/jwt.ts +65 -0
- package/packages/nexus-auth/src/middleware.ts +94 -0
- package/packages/nexus-auth/src/oauth.ts +199 -0
- package/packages/nexus-auth/src/password.ts +33 -0
- package/packages/nexus-auth/src/rateLimit.ts +59 -0
- package/packages/nexus-auth/src/rbac.ts +94 -0
- package/packages/nexus-auth/src/session.ts +91 -0
- package/packages/nexus-cache/package.json +21 -0
- package/packages/nexus-cache/src/Cache.ts +155 -0
- package/packages/nexus-cache/src/PubSub.ts +98 -0
- package/packages/nexus-cache/src/RateLimiter.ts +43 -0
- package/packages/nexus-cache/src/index.ts +9 -0
- package/packages/nexus-cache/src/types.ts +47 -0
- package/packages/nexus-cli/package.json +25 -0
- package/packages/nexus-cli/src/commands/cluster.ts +133 -0
- package/packages/nexus-cli/src/commands/dev.ts +200 -0
- package/packages/nexus-cli/src/commands/doctor.ts +199 -0
- package/packages/nexus-cli/src/commands/init.ts +1009 -0
- package/packages/nexus-cli/src/commands/node.ts +106 -0
- package/packages/nexus-cli/src/commands/pysetup.ts +142 -0
- package/packages/nexus-cli/src/commands/sync.ts +116 -0
- package/packages/nexus-cli/src/commands/uninstall.ts +287 -0
- package/packages/nexus-cli/src/config-sync.ts +384 -0
- package/packages/nexus-cli/src/dotenv.ts +39 -0
- package/packages/nexus-cli/src/index.ts +94 -0
- package/packages/nexus-cli/src/supervisor.ts +394 -0
- package/packages/nexus-cli/src/util.ts +132 -0
- package/packages/nexus-cli/src/wizard.ts +149 -0
- package/packages/nexus-cli/templates/Dockerfile +60 -0
- package/packages/nexus-cli/templates/README.md +69 -0
- package/packages/nexus-cli/templates/apps/admin/index.html +12 -0
- package/packages/nexus-cli/templates/apps/admin/package.json +24 -0
- package/packages/nexus-cli/templates/apps/admin/postcss.config.js +6 -0
- package/packages/nexus-cli/templates/apps/admin/src/main.tsx +10 -0
- package/packages/nexus-cli/templates/apps/admin/src/vite-env.d.ts +18 -0
- package/packages/nexus-cli/templates/apps/admin/tailwind.config.js +9 -0
- package/packages/nexus-cli/templates/apps/admin/tsconfig.json +17 -0
- package/packages/nexus-cli/templates/apps/admin/vite.config.ts +64 -0
- package/packages/nexus-cli/templates/apps/ai-server/main.py +43 -0
- package/packages/nexus-cli/templates/apps/ai-server/providers/__init__.py +3 -0
- package/packages/nexus-cli/templates/apps/ai-server/providers/base.py +111 -0
- package/packages/nexus-cli/templates/apps/ai-server/requirements.txt +3 -0
- package/packages/nexus-cli/templates/apps/ai-server/routers/__init__.py +3 -0
- package/packages/nexus-cli/templates/apps/ai-server/routers/chat.py +47 -0
- package/packages/nexus-cli/templates/apps/ai-server/routers/embeddings.py +30 -0
- package/packages/nexus-cli/templates/apps/ai-server/routers/lint.py +167 -0
- package/packages/nexus-cli/templates/apps/ai-server/routers/models.py +23 -0
- package/packages/nexus-cli/templates/apps/ai-server/routers/preflight.py +169 -0
- package/packages/nexus-cli/templates/apps/ai-server/settings.py +48 -0
- package/packages/nexus-cli/templates/apps/backend/package.json +34 -0
- package/packages/nexus-cli/templates/apps/backend/src/main.ts +375 -0
- package/packages/nexus-cli/templates/apps/backend/src/modules/admin/adminRoutes.ts +732 -0
- package/packages/nexus-cli/templates/apps/backend/src/modules/admin/clusterRoutes.ts +391 -0
- package/packages/nexus-cli/templates/apps/backend/src/modules/admin/databaseRoutes.ts +161 -0
- package/packages/nexus-cli/templates/apps/backend/src/modules/admin/lintProxy.ts +89 -0
- package/packages/nexus-cli/templates/apps/backend/src/modules/admin/preflightProxy.ts +242 -0
- package/packages/nexus-cli/templates/apps/backend/src/modules/admin/roleCatalog.ts +78 -0
- package/packages/nexus-cli/templates/apps/backend/src/modules/admin/schemaRoutes.ts +449 -0
- package/packages/nexus-cli/templates/apps/backend/src/modules/ai/aiProxy.ts +265 -0
- package/packages/nexus-cli/templates/apps/backend/src/modules/auth/authRoutes.ts +220 -0
- package/packages/nexus-cli/templates/apps/backend/src/modules/payments/paymentRoutes.ts +100 -0
- package/packages/nexus-cli/templates/apps/backend/src/modules/payments/paymentStore.ts +172 -0
- package/packages/nexus-cli/templates/apps/backend/src/modules/requests/requestLog.ts +175 -0
- package/packages/nexus-cli/templates/apps/backend/src/modules/users/userGraph.ts +83 -0
- package/packages/nexus-cli/templates/apps/backend/src/modules/users/userModel.ts +88 -0
- package/packages/nexus-cli/templates/apps/backend/src/plugins/CronScheduler.ts +69 -0
- package/packages/nexus-cli/templates/apps/backend/src/plugins/loadPlugins.ts +107 -0
- package/packages/nexus-cli/templates/apps/backend/tsconfig.json +14 -0
- package/packages/nexus-cli/templates/apps/frontend/index.html +12 -0
- package/packages/nexus-cli/templates/apps/frontend/package.json +19 -0
- package/packages/nexus-cli/templates/apps/frontend/src/main.tsx +64 -0
- package/packages/nexus-cli/templates/apps/frontend/vite.config.ts +72 -0
- package/packages/nexus-cli/templates/bin/nexus.js +35 -0
- package/packages/nexus-cli/templates/bin/serve-all.mjs +45 -0
- package/packages/nexus-cli/templates/dockerignore +15 -0
- package/packages/nexus-cli/templates/gitignore +12 -0
- package/packages/nexus-cli/templates/nexus.config.ts +69 -0
- package/packages/nexus-cli/templates/package.json +47 -0
- package/packages/nexus-cli/templates/tsconfig.json +17 -0
- package/packages/nexus-cli/templates/uploads/.gitkeep +0 -0
- package/packages/nexus-core/package.json +38 -0
- package/packages/nexus-core/src/config/ConfigLoader.ts +161 -0
- package/packages/nexus-core/src/config/defaults.ts +155 -0
- package/packages/nexus-core/src/config/env.ts +113 -0
- package/packages/nexus-core/src/config/index.ts +6 -0
- package/packages/nexus-core/src/config/merge.ts +28 -0
- package/packages/nexus-core/src/config/schema.ts +184 -0
- package/packages/nexus-core/src/config/types.ts +352 -0
- package/packages/nexus-core/src/di/Container.ts +98 -0
- package/packages/nexus-core/src/di/index.ts +1 -0
- package/packages/nexus-core/src/errors.ts +62 -0
- package/packages/nexus-core/src/http/Router.ts +149 -0
- package/packages/nexus-core/src/http/Server.ts +145 -0
- package/packages/nexus-core/src/http/bodyParser.ts +129 -0
- package/packages/nexus-core/src/http/context.ts +112 -0
- package/packages/nexus-core/src/http/index.ts +6 -0
- package/packages/nexus-core/src/http/static.ts +85 -0
- package/packages/nexus-core/src/http/uploads.ts +94 -0
- package/packages/nexus-core/src/index.ts +4 -0
- package/packages/nexus-crypto/package.json +18 -0
- package/packages/nexus-crypto/src/csr.ts +165 -0
- package/packages/nexus-crypto/src/der.ts +262 -0
- package/packages/nexus-crypto/src/https.ts +36 -0
- package/packages/nexus-crypto/src/index.ts +5 -0
- package/packages/nexus-crypto/src/keypair.ts +59 -0
- package/packages/nexus-crypto/src/x509.ts +156 -0
- package/packages/nexus-data/package.json +27 -0
- package/packages/nexus-data/src/connection/Connection.ts +57 -0
- package/packages/nexus-data/src/connection/ConnectionManager.ts +37 -0
- package/packages/nexus-data/src/errors.ts +17 -0
- package/packages/nexus-data/src/index.ts +66 -0
- package/packages/nexus-data/src/model/Document.ts +126 -0
- package/packages/nexus-data/src/model/Model.ts +189 -0
- package/packages/nexus-data/src/model/hydrate.ts +12 -0
- package/packages/nexus-data/src/populate/populate.ts +114 -0
- package/packages/nexus-data/src/projects.ts +185 -0
- package/packages/nexus-data/src/query/Query.ts +158 -0
- package/packages/nexus-data/src/schema/Schema.ts +173 -0
- package/packages/nexus-data/src/schema/SchemaType.ts +80 -0
- package/packages/nexus-data/src/schema/validators.ts +88 -0
- package/packages/nexus-email/package.json +22 -0
- package/packages/nexus-email/src/EmailService.ts +76 -0
- package/packages/nexus-email/src/TemplateEngine.ts +62 -0
- package/packages/nexus-email/src/index.ts +37 -0
- package/packages/nexus-email/src/providers.ts +63 -0
- package/packages/nexus-email/src/queue.ts +103 -0
- package/packages/nexus-email/src/types.ts +49 -0
- package/packages/nexus-graphql/package.json +24 -0
- package/packages/nexus-graphql/src/federation/composeSupergraph.ts +153 -0
- package/packages/nexus-graphql/src/federation/createFederatedGateway.ts +36 -0
- package/packages/nexus-graphql/src/federation/executor.ts +305 -0
- package/packages/nexus-graphql/src/federation/parseMetadata.ts +87 -0
- package/packages/nexus-graphql/src/gateway/createGateway.ts +77 -0
- package/packages/nexus-graphql/src/gateway/fieldResolver.ts +45 -0
- package/packages/nexus-graphql/src/gateway/graphqlHttpHandler.ts +90 -0
- package/packages/nexus-graphql/src/gateway/publicSchema.ts +26 -0
- package/packages/nexus-graphql/src/index.ts +14 -0
- package/packages/nexus-graphql/src/subgraph/defineSubgraph.ts +115 -0
- package/packages/nexus-graphql/src/subgraph/federationDirectives.ts +47 -0
- package/packages/nexus-graphql/src/subgraph/parseKeys.ts +25 -0
- package/packages/nexus-graphql/src/subscriptions/PubSub.ts +64 -0
- package/packages/nexus-graphql/src/subscriptions/SubscriptionServer.ts +203 -0
- package/packages/nexus-graphql/src/types.ts +77 -0
- package/packages/nexus-payments/package.json +20 -0
- package/packages/nexus-payments/src/errors.ts +15 -0
- package/packages/nexus-payments/src/http.ts +53 -0
- package/packages/nexus-payments/src/index.ts +106 -0
- package/packages/nexus-payments/src/providers/PayPalProvider.ts +192 -0
- package/packages/nexus-payments/src/providers/PayUProvider.ts +164 -0
- package/packages/nexus-payments/src/providers/PayoneerProvider.ts +122 -0
- package/packages/nexus-payments/src/providers/RazorpayProvider.ts +184 -0
- package/packages/nexus-payments/src/providers/SkrillProvider.ts +117 -0
- package/packages/nexus-payments/src/signature.ts +24 -0
- package/packages/nexus-payments/src/types.ts +150 -0
- package/packages/nexus-payments/src/webhooks.ts +58 -0
- package/packages/nexus-plugins/package.json +20 -0
- package/packages/nexus-plugins/src/HookBus.ts +42 -0
- package/packages/nexus-plugins/src/admin/adminExtensions.ts +53 -0
- package/packages/nexus-plugins/src/host.ts +105 -0
- package/packages/nexus-plugins/src/index.ts +8 -0
- package/packages/nexus-plugins/src/manifest.ts +73 -0
- package/packages/nexus-plugins/src/registry.ts +123 -0
- package/packages/nexus-plugins/src/runtime/TrustedPlugin.ts +134 -0
- package/packages/nexus-plugins/src/sandbox/SandboxedPlugin.ts +279 -0
- package/packages/nexus-plugins/src/sandbox/capabilityPolicy.ts +68 -0
- package/packages/nexus-plugins/src/sandbox/workerBootstrap.mjs +211 -0
- package/packages/nexus-realtime/package.json +26 -0
- package/packages/nexus-realtime/src/adapter/PubSubAdapter.ts +42 -0
- package/packages/nexus-realtime/src/adapter/RedisPubSubAdapter.ts +59 -0
- package/packages/nexus-realtime/src/index.ts +5 -0
- package/packages/nexus-realtime/src/mediasoup/MediasoupAdapter.ts +176 -0
- package/packages/nexus-realtime/src/server/RealtimeServer.ts +252 -0
- package/packages/nexus-realtime/src/types.ts +52 -0
- package/packages/nexus-telemetry/package.json +20 -0
- package/packages/nexus-telemetry/src/index.ts +3 -0
- package/packages/nexus-telemetry/src/logger/Logger.ts +178 -0
- package/packages/nexus-telemetry/src/metrics/Metrics.ts +123 -0
- package/packages/nexus-telemetry/src/trace.ts +28 -0
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import type { Connection } from '../types.js';
|
|
2
|
+
import type { MediaAction } from '../types.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* mediasoup SFU adapter. Lazily imports `mediasoup` (an optional native
|
|
6
|
+
* dependency) so the realtime package loads even when mediasoup is not
|
|
7
|
+
* installed; media actions return a clear error in that case.
|
|
8
|
+
*
|
|
9
|
+
* One mediasoup worker is created per adapter; one router per room (with a
|
|
10
|
+
* default Opus audio + VP8 video RTP capability set). Each connection gets a
|
|
11
|
+
* send WebRtcTransport and a recv WebRtcTransport; producers/consumers are
|
|
12
|
+
* tracked per connection. This is a functional single-room SFU demo; full
|
|
13
|
+
* multi-room capacity scaling is wired but tuned in a later pass.
|
|
14
|
+
*/
|
|
15
|
+
export interface MediasoupAdapterOptions {
|
|
16
|
+
/** RTC listen IP announced to clients (default 127.0.0.1). */
|
|
17
|
+
announceIp?: string;
|
|
18
|
+
/** RTC port range (defaults let mediasoup choose). */
|
|
19
|
+
rtcMinPort?: number;
|
|
20
|
+
rtcMaxPort?: number;
|
|
21
|
+
/** Number of mediasoup workers (default 1). */
|
|
22
|
+
workerCount?: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Default router media codecs: Opus audio + VP8 video. (RTX omitted for portability —
|
|
26
|
+
// adding it requires a correctly-bound `apt` referencing the VP8 payload type; not needed for the demo.)
|
|
27
|
+
const ROUTER_MEDIA_CODECS = [
|
|
28
|
+
{ kind: 'audio', mimeType: 'audio/opus', clockRate: 48000, channels: 2 },
|
|
29
|
+
{ kind: 'video', mimeType: 'video/VP8', clockRate: 90000, rtcpFeedback: [{ type: 'nack' }, { type: 'nack', parameter: 'pli' }, { type: 'ccm', parameter: 'fir' }, { type: 'goog-remb' }] },
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
interface ConnState {
|
|
33
|
+
sendTransport?: unknown;
|
|
34
|
+
recvTransport?: unknown;
|
|
35
|
+
producers: Map<string, unknown>;
|
|
36
|
+
consumers: Map<string, unknown>;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export class MediasoupAdapter {
|
|
40
|
+
private workerPromise?: Promise<unknown>;
|
|
41
|
+
private routers = new Map<string, Promise<unknown>>();
|
|
42
|
+
private states = new WeakMap<Connection, ConnState>();
|
|
43
|
+
private opts: MediasoupAdapterOptions;
|
|
44
|
+
|
|
45
|
+
constructor(opts: MediasoupAdapterOptions = {}) {
|
|
46
|
+
this.opts = opts;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
private state(conn: Connection): ConnState {
|
|
50
|
+
let s = this.states.get(conn);
|
|
51
|
+
if (!s) { s = { producers: new Map(), consumers: new Map() }; this.states.set(conn, s); }
|
|
52
|
+
return s;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
private async worker(): Promise<unknown> {
|
|
56
|
+
if (!this.workerPromise) {
|
|
57
|
+
const mod = await import('mediasoup');
|
|
58
|
+
this.workerPromise = mod.createWorker({
|
|
59
|
+
logLevel: 'warn',
|
|
60
|
+
rtcMinPort: this.opts.rtcMinPort ?? 40000,
|
|
61
|
+
rtcMaxPort: this.opts.rtcMaxPort ?? 40100,
|
|
62
|
+
}) as Promise<unknown>;
|
|
63
|
+
}
|
|
64
|
+
return this.workerPromise;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
private async router(room: string): Promise<unknown> {
|
|
68
|
+
let p = this.routers.get(room);
|
|
69
|
+
if (!p) {
|
|
70
|
+
p = (async () => {
|
|
71
|
+
const w = await this.worker();
|
|
72
|
+
const r = await (w as any).createRouter({ mediaCodecs: ROUTER_MEDIA_CODECS });
|
|
73
|
+
return r;
|
|
74
|
+
})();
|
|
75
|
+
this.routers.set(room, p);
|
|
76
|
+
}
|
|
77
|
+
return p;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async handle(conn: Connection, action: MediaAction, payload: Record<string, unknown>): Promise<Record<string, unknown>> {
|
|
81
|
+
const room = String(payload.room ?? 'default');
|
|
82
|
+
const router = await this.router(room);
|
|
83
|
+
const s = this.state(conn);
|
|
84
|
+
|
|
85
|
+
switch (action) {
|
|
86
|
+
case 'getRouterRtpCapabilities':
|
|
87
|
+
return { rtpCapabilities: (router as any).rtpCapabilities };
|
|
88
|
+
|
|
89
|
+
case 'createWebRtcTransport': {
|
|
90
|
+
const dir = (payload.direction ?? 'send') as 'send' | 'recv';
|
|
91
|
+
const t = await (router as any).createWebRtcTransport({
|
|
92
|
+
listenIps: [{ ip: '0.0.0.0', announcedIp: this.opts.announceIp ?? '127.0.0.1' }],
|
|
93
|
+
enableUdp: true,
|
|
94
|
+
enableTcp: true,
|
|
95
|
+
preferUdp: true,
|
|
96
|
+
});
|
|
97
|
+
if (dir === 'send') s.sendTransport = t; else s.recvTransport = t;
|
|
98
|
+
return {
|
|
99
|
+
id: t.id,
|
|
100
|
+
iceParameters: t.iceParameters,
|
|
101
|
+
iceCandidates: t.iceCandidates,
|
|
102
|
+
dtlsParameters: t.dtlsParameters,
|
|
103
|
+
direction: dir,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
case 'connectTransport': {
|
|
108
|
+
const dir = (payload.direction ?? 'send') as 'send' | 'recv';
|
|
109
|
+
const t = dir === 'send' ? s.sendTransport : s.recvTransport;
|
|
110
|
+
if (!t) return { error: 'transport not found' };
|
|
111
|
+
await (t as any).connect({ dtlsParameters: payload.dtlsParameters });
|
|
112
|
+
return { connected: true };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
case 'produce': {
|
|
116
|
+
if (!s.sendTransport) return { error: 'send transport not found' };
|
|
117
|
+
const producer = await (s.sendTransport as any).produce({
|
|
118
|
+
kind: payload.kind,
|
|
119
|
+
rtpParameters: payload.rtpParameters,
|
|
120
|
+
paused: false,
|
|
121
|
+
});
|
|
122
|
+
s.producers.set(producer.id, producer);
|
|
123
|
+
return { id: producer.id, kind: producer.kind };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
case 'consume': {
|
|
127
|
+
if (!s.recvTransport) return { error: 'recv transport not found' };
|
|
128
|
+
const canConsume = (router as any).canConsume({ producerId: payload.producerId, rtpCapabilities: payload.rtpCapabilities });
|
|
129
|
+
if (!canConsume) return { error: 'cannot consume' };
|
|
130
|
+
const consumer = await (s.recvTransport as any).consume({
|
|
131
|
+
producerId: payload.producerId,
|
|
132
|
+
rtpCapabilities: payload.rtpCapabilities,
|
|
133
|
+
paused: true,
|
|
134
|
+
});
|
|
135
|
+
s.consumers.set(consumer.id, consumer);
|
|
136
|
+
return {
|
|
137
|
+
id: consumer.id,
|
|
138
|
+
producerId: consumer.producerId,
|
|
139
|
+
kind: consumer.kind,
|
|
140
|
+
rtpParameters: consumer.rtpParameters,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
case 'closeProducer': {
|
|
145
|
+
const p = s.producers.get(String(payload.producerId));
|
|
146
|
+
if (p) { (p as any).close(); s.producers.delete(String(payload.producerId)); }
|
|
147
|
+
return { closed: true };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
case 'closeConsumer': {
|
|
151
|
+
const c = s.consumers.get(String(payload.consumerId));
|
|
152
|
+
if (c) { (c as any).close(); s.consumers.delete(String(payload.consumerId)); }
|
|
153
|
+
return { closed: true };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
default:
|
|
157
|
+
return { error: `unknown media action` };
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Clean up a connection's transports/producers/consumers on disconnect. */
|
|
162
|
+
cleanup(conn: Connection): void {
|
|
163
|
+
const s = this.states.get(conn);
|
|
164
|
+
if (!s) return;
|
|
165
|
+
for (const p of s.producers.values()) try { (p as any).close(); } catch { /* ignore */ }
|
|
166
|
+
for (const c of s.consumers.values()) try { (c as any).close(); } catch { /* ignore */ }
|
|
167
|
+
try { (s.sendTransport as any)?.close(); } catch { /* ignore */ }
|
|
168
|
+
try { (s.recvTransport as any)?.close(); } catch { /* ignore */ }
|
|
169
|
+
this.states.delete(conn);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Whether mediasoup is importable on this process (for capability checks). */
|
|
173
|
+
static async isAvailable(): Promise<boolean> {
|
|
174
|
+
try { await import('mediasoup'); return true; } catch { return false; }
|
|
175
|
+
}
|
|
176
|
+
}
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import { WebSocketServer, WebSocket } from 'ws';
|
|
2
|
+
import type { IncomingMessage, Server as HttpServer } from 'node:http';
|
|
3
|
+
import type { Duplex } from 'node:stream';
|
|
4
|
+
import { randomBytes } from 'node:crypto';
|
|
5
|
+
import type { AuthService, CsrfOptions } from '@bhooai/nexus-auth';
|
|
6
|
+
import { checkWsUpgrade } from '@bhooai/nexus-auth';
|
|
7
|
+
import { AuthenticationError } from '@bhooai/nexus-core';
|
|
8
|
+
import type { ClientMessage, ServerMessage, Connection } from '../types.js';
|
|
9
|
+
import type { PubSubAdapter } from '../adapter/PubSubAdapter.js';
|
|
10
|
+
import { MemoryPubSubAdapter } from '../adapter/PubSubAdapter.js';
|
|
11
|
+
import type { MediasoupAdapter } from '../mediasoup/MediasoupAdapter.js';
|
|
12
|
+
|
|
13
|
+
export interface RealtimeServerOptions {
|
|
14
|
+
/** The HTTP server to attach the WebSocket upgrade handler to. */
|
|
15
|
+
httpServer: HttpServer;
|
|
16
|
+
/** WS path (default '/ws'). */
|
|
17
|
+
path?: string;
|
|
18
|
+
/** Auth service for access-token verification on upgrade (optional → anonymous). */
|
|
19
|
+
authService?: AuthService;
|
|
20
|
+
/** CSRF options for the WS upgrade origin + double-submit check (optional). */
|
|
21
|
+
csrfOptions?: CsrfOptions;
|
|
22
|
+
/** Pub/sub adapter for cross-instance fanout (default in-memory). */
|
|
23
|
+
adapter?: PubSubAdapter;
|
|
24
|
+
/** mediasoup SFU adapter for media routing (optional). */
|
|
25
|
+
mediasoup?: MediasoupAdapter;
|
|
26
|
+
/** Require authentication on upgrade (default true when authService provided). */
|
|
27
|
+
requireAuth?: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const CONN_CHANNEL = (id: string) => `conn:${id}`;
|
|
31
|
+
const ROOM_CHANNEL = (room: string) => `room:${room}`;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* WebSocket realtime server. Attaches to an HTTP server's 'upgrade' event,
|
|
35
|
+
* authenticates the upgrade (access token via `?token=` query, optional CSRF
|
|
36
|
+
* origin/double-submit check), and dispatches client messages: room join/leave,
|
|
37
|
+
* WebRTC offer/answer/ICE-candidate relay, and app broadcasts.
|
|
38
|
+
*
|
|
39
|
+
* Cross-instance fanout uses the pub/sub adapter: direct peer messages are
|
|
40
|
+
* published to `conn:<id>` (delivered by whichever instance hosts the peer);
|
|
41
|
+
* room events are published to `room:<room>` (delivered to every instance with
|
|
42
|
+
* local members).
|
|
43
|
+
*/
|
|
44
|
+
export class RealtimeServer {
|
|
45
|
+
private wss: WebSocketServer;
|
|
46
|
+
private adapter: PubSubAdapter;
|
|
47
|
+
private connections = new Map<string, Connection>();
|
|
48
|
+
/** Local members per room (connIds on THIS instance). */
|
|
49
|
+
private roomMembers = new Map<string, Set<string>>();
|
|
50
|
+
private subscribedRooms = new Set<string>();
|
|
51
|
+
private opts: RealtimeServerOptions;
|
|
52
|
+
|
|
53
|
+
constructor(opts: RealtimeServerOptions) {
|
|
54
|
+
this.opts = opts;
|
|
55
|
+
this.adapter = opts.adapter ?? new MemoryPubSubAdapter();
|
|
56
|
+
this.wss = new WebSocketServer({ noServer: true });
|
|
57
|
+
this.opts.httpServer.on('upgrade', (req, socket, head) => this.handleUpgrade(req, socket, head));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
private async handleUpgrade(req: IncomingMessage, socket: Duplex, head: Buffer): Promise<void> {
|
|
61
|
+
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
62
|
+
// Only handle our WS path; let other upgrade listeners handle the rest.
|
|
63
|
+
if (url.pathname !== (this.opts.path ?? '/ws')) return;
|
|
64
|
+
|
|
65
|
+
try {
|
|
66
|
+
// 1. CSRF + origin check (WS has no CORS preflight).
|
|
67
|
+
if (this.opts.csrfOptions) checkWsUpgrade(req.headers as Record<string, string | string[] | undefined>, this.opts.csrfOptions);
|
|
68
|
+
|
|
69
|
+
// 2. Authenticate via ?token=<accessToken> (or subprotocol).
|
|
70
|
+
const token = (url.searchParams.get('token') ?? req.headers['sec-websocket-protocol']?.split(',').map((s) => s.trim())[0]) ?? '';
|
|
71
|
+
let userId: string | undefined;
|
|
72
|
+
let roles: string[] = [];
|
|
73
|
+
if (this.opts.authService && token) {
|
|
74
|
+
const payload = await this.opts.authService.verifyAccessToken(token);
|
|
75
|
+
userId = payload.sub;
|
|
76
|
+
roles = payload.roles ?? [];
|
|
77
|
+
} else if (this.opts.requireAuth ?? !!this.opts.authService) {
|
|
78
|
+
throw new AuthenticationError('Missing access token');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
this.wss.handleUpgrade(req, socket, head, (ws) => {
|
|
82
|
+
const id = userId ?? randomBytes(9).toString('base64url');
|
|
83
|
+
this.register(ws, id, userId, roles);
|
|
84
|
+
});
|
|
85
|
+
} catch (err) {
|
|
86
|
+
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
|
|
87
|
+
socket.destroy();
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
private register(ws: WebSocket, id: string, userId: string | undefined, roles: string[]): void {
|
|
92
|
+
const conn: Connection = {
|
|
93
|
+
id,
|
|
94
|
+
userId,
|
|
95
|
+
roles,
|
|
96
|
+
rooms: new Set(),
|
|
97
|
+
get isOpen() { return ws.readyState === ws.OPEN; },
|
|
98
|
+
send: (message) => { if (ws.readyState === ws.OPEN) ws.send(JSON.stringify(message)); },
|
|
99
|
+
close: (code, reason) => ws.close(code, reason),
|
|
100
|
+
};
|
|
101
|
+
this.connections.set(id, conn);
|
|
102
|
+
|
|
103
|
+
// Subscribe to this connection's direct channel (cross-instance delivery).
|
|
104
|
+
const direct = (raw: Buffer | string) => {
|
|
105
|
+
const msg = JSON.parse(raw.toString()) as ServerMessage;
|
|
106
|
+
conn.send(msg);
|
|
107
|
+
};
|
|
108
|
+
this.adapter.subscribe(CONN_CHANNEL(id), direct);
|
|
109
|
+
(conn as Connection & { _direct?: unknown })._direct = direct;
|
|
110
|
+
|
|
111
|
+
ws.on('message', (data) => this.onMessage(conn, data.toString('utf8')));
|
|
112
|
+
ws.on('close', () => this.onClose(conn, direct));
|
|
113
|
+
ws.on('error', () => this.onClose(conn, direct));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
private async onMessage(conn: Connection, raw: string): Promise<void> {
|
|
117
|
+
let msg: ClientMessage;
|
|
118
|
+
try {
|
|
119
|
+
msg = JSON.parse(raw) as ClientMessage;
|
|
120
|
+
} catch {
|
|
121
|
+
conn.send({ type: 'error', message: 'Invalid JSON' });
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
switch (msg.type) {
|
|
125
|
+
case 'ping':
|
|
126
|
+
conn.send({ type: 'pong' });
|
|
127
|
+
return;
|
|
128
|
+
case 'join':
|
|
129
|
+
await this.joinRoom(conn, msg.room);
|
|
130
|
+
return;
|
|
131
|
+
case 'leave':
|
|
132
|
+
await this.leaveRoom(conn, msg.room);
|
|
133
|
+
return;
|
|
134
|
+
case 'offer':
|
|
135
|
+
case 'answer':
|
|
136
|
+
case 'candidate': {
|
|
137
|
+
const to = this.connections.get(msg.to);
|
|
138
|
+
if (to) {
|
|
139
|
+
// Same instance: send directly.
|
|
140
|
+
to.send(this.relayFrom(conn, msg));
|
|
141
|
+
} else {
|
|
142
|
+
// Cross-instance: publish to the peer's direct channel (best-effort).
|
|
143
|
+
this.adapter.publish(CONN_CHANNEL(msg.to), JSON.stringify(this.relayFrom(conn, msg)));
|
|
144
|
+
}
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
case 'broadcast':
|
|
148
|
+
this.broadcastRoom(msg.room, { type: 'broadcast', room: msg.room, from: conn.id, event: msg.event, data: msg.data });
|
|
149
|
+
return;
|
|
150
|
+
case 'media':
|
|
151
|
+
await this.handleMedia(conn, msg);
|
|
152
|
+
return;
|
|
153
|
+
default:
|
|
154
|
+
conn.send({ type: 'error', message: `Unknown message type` });
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
private relayFrom(conn: Connection, msg: ClientMessage): ServerMessage {
|
|
159
|
+
if (msg.type === 'offer') return { type: 'offer', from: conn.id, sdp: msg.sdp };
|
|
160
|
+
if (msg.type === 'answer') return { type: 'answer', from: conn.id, sdp: msg.sdp };
|
|
161
|
+
if (msg.type === 'candidate') return { type: 'candidate', from: conn.id, candidate: msg.candidate };
|
|
162
|
+
throw new Error('Cannot relay a non-peer message');
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
private async joinRoom(conn: Connection, room: string): Promise<void> {
|
|
166
|
+
if (conn.rooms.has(room)) return;
|
|
167
|
+
let set = this.roomMembers.get(room);
|
|
168
|
+
const wasEmpty = !set || set.size === 0;
|
|
169
|
+
if (!set) { set = new Set(); this.roomMembers.set(room, set); }
|
|
170
|
+
set.add(conn.id);
|
|
171
|
+
conn.rooms.add(room);
|
|
172
|
+
|
|
173
|
+
if (wasEmpty && !this.subscribedRooms.has(room)) {
|
|
174
|
+
this.adapter.subscribe(ROOM_CHANNEL(room), this.deliverRoom);
|
|
175
|
+
this.subscribedRooms.add(room);
|
|
176
|
+
}
|
|
177
|
+
// Notify everyone (including cross-instance) that a peer joined.
|
|
178
|
+
this.adapter.publish(ROOM_CHANNEL(room), JSON.stringify({ type: 'peer-joined', room, peer: conn.id }));
|
|
179
|
+
// Tell the joiner who's already here (local members).
|
|
180
|
+
const peers = [...set].filter((id) => id !== conn.id);
|
|
181
|
+
conn.send({ type: 'joined', room, peers });
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
private async leaveRoom(conn: Connection, room: string): Promise<void> {
|
|
185
|
+
conn.rooms.delete(room);
|
|
186
|
+
const set = this.roomMembers.get(room);
|
|
187
|
+
if (!set) return;
|
|
188
|
+
set.delete(conn.id);
|
|
189
|
+
this.adapter.publish(ROOM_CHANNEL(room), JSON.stringify({ type: 'peer-left', room, peer: conn.id }));
|
|
190
|
+
conn.send({ type: 'left', room });
|
|
191
|
+
if (set.size === 0) {
|
|
192
|
+
this.roomMembers.delete(room);
|
|
193
|
+
this.subscribedRooms.delete(room);
|
|
194
|
+
this.adapter.unsubscribe(ROOM_CHANNEL(room), this.deliverRoom);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Deliver a room-channel message to all local members of that room, skipping the originator. */
|
|
199
|
+
private deliverRoom = (raw: Buffer | string): void => {
|
|
200
|
+
const msg = JSON.parse(raw.toString()) as ServerMessage & { room?: string };
|
|
201
|
+
const room = msg.room;
|
|
202
|
+
if (!room) return;
|
|
203
|
+
const set = this.roomMembers.get(room);
|
|
204
|
+
if (!set) return;
|
|
205
|
+
// The originator must not receive its own join/leave/broadcast echo.
|
|
206
|
+
const origin =
|
|
207
|
+
msg.type === 'peer-joined' || msg.type === 'peer-left' ? (msg as { peer: string }).peer
|
|
208
|
+
: msg.type === 'broadcast' ? (msg as { from: string }).from
|
|
209
|
+
: undefined;
|
|
210
|
+
for (const id of set) {
|
|
211
|
+
if (id === origin) continue;
|
|
212
|
+
this.connections.get(id)?.send(msg);
|
|
213
|
+
}
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
private broadcastRoom(room: string, msg: ServerMessage): void {
|
|
217
|
+
this.adapter.publish(ROOM_CHANNEL(room), JSON.stringify(msg));
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** Publish an application event to every authenticated connection in a room. */
|
|
221
|
+
broadcast(room: string, event: string, data?: unknown): void {
|
|
222
|
+
this.broadcastRoom(room, { type: 'broadcast', room, from: 'server', event, data });
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
private async handleMedia(conn: Connection, msg: ClientMessage & { type: 'media' }): Promise<void> {
|
|
226
|
+
if (!this.opts.mediasoup) {
|
|
227
|
+
conn.send({ type: 'error', message: 'mediasoup SFU not enabled', code: 'NO_SFU' });
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
const reply = await this.opts.mediasoup.handle(conn, msg.action, msg.payload);
|
|
231
|
+
conn.send({ type: 'media', action: msg.action, payload: reply });
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
private async onClose(conn: Connection, direct: (raw: Buffer | string) => void): Promise<void> {
|
|
235
|
+
this.connections.delete(conn.id);
|
|
236
|
+
this.adapter.unsubscribe(CONN_CHANNEL(conn.id), direct);
|
|
237
|
+
for (const room of [...conn.rooms]) await this.leaveRoom(conn, room);
|
|
238
|
+
this.opts.mediasoup?.cleanup(conn);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** Number of live connections (test/monitoring helper). */
|
|
242
|
+
get connectionCount(): number {
|
|
243
|
+
return this.connections.size;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** Close the WS server and all connections. */
|
|
247
|
+
close(): Promise<void> {
|
|
248
|
+
for (const conn of this.connections.values()) conn.close(1001, 'server shutting down');
|
|
249
|
+
this.wss.close();
|
|
250
|
+
return Promise.resolve();
|
|
251
|
+
}
|
|
252
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/** Messages a client may send over the WebSocket. */
|
|
2
|
+
export type ClientMessage =
|
|
3
|
+
| { type: 'join'; room: string }
|
|
4
|
+
| { type: 'leave'; room: string }
|
|
5
|
+
| { type: 'offer'; to: string; sdp: string; room?: string }
|
|
6
|
+
| { type: 'answer'; to: string; sdp: string; room?: string }
|
|
7
|
+
| { type: 'candidate'; to: string; candidate: unknown; room?: string }
|
|
8
|
+
| { type: 'broadcast'; room: string; event: string; data?: unknown }
|
|
9
|
+
| { type: 'ping' }
|
|
10
|
+
| { type: 'media'; action: MediaAction; payload: Record<string, unknown> };
|
|
11
|
+
|
|
12
|
+
/** Messages the server sends to a client. */
|
|
13
|
+
export type ServerMessage =
|
|
14
|
+
| { type: 'joined'; room: string; peers: string[] }
|
|
15
|
+
| { type: 'left'; room: string }
|
|
16
|
+
| { type: 'peer-joined'; room: string; peer: string }
|
|
17
|
+
| { type: 'peer-left'; room: string; peer: string }
|
|
18
|
+
| { type: 'offer'; from: string; sdp: string }
|
|
19
|
+
| { type: 'answer'; from: string; sdp: string }
|
|
20
|
+
| { type: 'candidate'; from: string; candidate: unknown }
|
|
21
|
+
| { type: 'broadcast'; room: string; from: string; event: string; data?: unknown }
|
|
22
|
+
| { type: 'pong' }
|
|
23
|
+
| { type: 'media'; action: MediaAction; payload: Record<string, unknown> }
|
|
24
|
+
| { type: 'error'; message: string; code?: string };
|
|
25
|
+
|
|
26
|
+
/** mediasoup signaling actions relayed over WS. */
|
|
27
|
+
export type MediaAction =
|
|
28
|
+
| 'getRouterRtpCapabilities'
|
|
29
|
+
| 'createWebRtcTransport'
|
|
30
|
+
| 'connectTransport'
|
|
31
|
+
| 'produce'
|
|
32
|
+
| 'consume'
|
|
33
|
+
| 'closeProducer'
|
|
34
|
+
| 'closeConsumer';
|
|
35
|
+
|
|
36
|
+
/** A connected client's server-side record. */
|
|
37
|
+
export interface Connection {
|
|
38
|
+
/** Stable id for this connection (the user id when authenticated, else random). */
|
|
39
|
+
id: string;
|
|
40
|
+
/** Authenticated user id (undefined for anonymous connections). */
|
|
41
|
+
userId?: string;
|
|
42
|
+
/** Roles from the access token. */
|
|
43
|
+
roles: string[];
|
|
44
|
+
/** Rooms this connection has joined (on this instance). */
|
|
45
|
+
rooms: Set<string>;
|
|
46
|
+
/** Send a JSON message to the client. */
|
|
47
|
+
send(message: ServerMessage): void;
|
|
48
|
+
/** Close the connection. */
|
|
49
|
+
close(code?: number, reason?: string): void;
|
|
50
|
+
/** Whether the underlying socket is still open. */
|
|
51
|
+
readonly isOpen: boolean;
|
|
52
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@bhooai/nexus-telemetry",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"publishConfig": { "access": "public" },
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./src/index.ts",
|
|
7
|
+
"types": "./src/index.ts",
|
|
8
|
+
"scripts": {
|
|
9
|
+
"build": "tsc -p tsconfig.json",
|
|
10
|
+
"test": "vitest run"
|
|
11
|
+
},
|
|
12
|
+
"dependencies": {
|
|
13
|
+
"@bhooai/nexus-core": "^0.1.0"
|
|
14
|
+
},
|
|
15
|
+
"devDependencies": {
|
|
16
|
+
"@types/node": "^22.5.0",
|
|
17
|
+
"typescript": "^5.6.2",
|
|
18
|
+
"vitest": "^2.1.1"
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { createWriteStream, existsSync, mkdirSync, renameSync, statSync } from 'node:fs';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
import { EOL } from 'node:os';
|
|
4
|
+
|
|
5
|
+
export type LogLevel = 'trace' | 'debug' | 'info' | 'warn' | 'error' | 'fatal';
|
|
6
|
+
export const LEVEL_ORDER: Record<LogLevel, number> = {
|
|
7
|
+
trace: 10,
|
|
8
|
+
debug: 20,
|
|
9
|
+
info: 30,
|
|
10
|
+
warn: 40,
|
|
11
|
+
error: 50,
|
|
12
|
+
fatal: 60,
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export interface LogRecord {
|
|
16
|
+
level: LogLevel;
|
|
17
|
+
time: number;
|
|
18
|
+
msg: string;
|
|
19
|
+
[key: string]: unknown;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface LoggerOptions {
|
|
23
|
+
level?: LogLevel;
|
|
24
|
+
format?: 'json' | 'pretty';
|
|
25
|
+
console?: boolean;
|
|
26
|
+
/** File transport settings. */
|
|
27
|
+
file?: { dir: string; maxFileSize: number; maxFiles: number };
|
|
28
|
+
/** Field names to redact (replaced with [REDACTED]). */
|
|
29
|
+
redact?: string[];
|
|
30
|
+
/** Default bindings applied to every record. */
|
|
31
|
+
bindings?: Record<string, unknown>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const COLORS: Record<LogLevel, string> = {
|
|
35
|
+
trace: '\x1b[90m',
|
|
36
|
+
debug: '\x1b[36m',
|
|
37
|
+
info: '\x1b[32m',
|
|
38
|
+
warn: '\x1b[33m',
|
|
39
|
+
error: '\x1b[31m',
|
|
40
|
+
fatal: '\x1b[35m',
|
|
41
|
+
};
|
|
42
|
+
const RESET = '\x1b[0m';
|
|
43
|
+
|
|
44
|
+
/** Inbuilt structured logger with JSON/pretty formatting, redaction, and rotating file transport. */
|
|
45
|
+
export class Logger {
|
|
46
|
+
private opts: Required<Pick<LoggerOptions, 'level' | 'format' | 'console' | 'redact'>> & {
|
|
47
|
+
file?: NonNullable<LoggerOptions['file']>;
|
|
48
|
+
bindings: Record<string, unknown>;
|
|
49
|
+
};
|
|
50
|
+
private stream: NodeJS.WritableStream | null = null;
|
|
51
|
+
private currentSize = 0;
|
|
52
|
+
|
|
53
|
+
constructor(opts: LoggerOptions = {}) {
|
|
54
|
+
this.opts = {
|
|
55
|
+
level: opts.level ?? 'info',
|
|
56
|
+
format: opts.format ?? 'pretty',
|
|
57
|
+
console: opts.console ?? true,
|
|
58
|
+
redact: opts.redact ?? [],
|
|
59
|
+
file: opts.file,
|
|
60
|
+
bindings: opts.bindings ?? {},
|
|
61
|
+
};
|
|
62
|
+
if (this.opts.file) this.openStream(this.opts.file);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
private openStream(file: NonNullable<LoggerOptions['file']>): void {
|
|
66
|
+
if (!existsSync(file.dir)) mkdirSync(file.dir, { recursive: true });
|
|
67
|
+
const target = join(file.dir, 'nexus.log');
|
|
68
|
+
try {
|
|
69
|
+
this.currentSize = existsSync(target) ? statSync(target).size : 0;
|
|
70
|
+
} catch {
|
|
71
|
+
this.currentSize = 0;
|
|
72
|
+
}
|
|
73
|
+
this.stream = createWriteStream(target, { flags: 'a' });
|
|
74
|
+
this.stream.on('error', () => {
|
|
75
|
+
this.stream = null;
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
child(bindings: Record<string, unknown>): Logger {
|
|
80
|
+
return new Logger({
|
|
81
|
+
level: this.opts.level,
|
|
82
|
+
format: this.opts.format,
|
|
83
|
+
console: this.opts.console,
|
|
84
|
+
file: this.opts.file,
|
|
85
|
+
redact: this.opts.redact,
|
|
86
|
+
bindings: { ...this.opts.bindings, ...bindings },
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
setLevel(level: LogLevel): void {
|
|
91
|
+
this.opts.level = level;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
private shouldLog(level: LogLevel): boolean {
|
|
95
|
+
return LEVEL_ORDER[level] >= LEVEL_ORDER[this.opts.level];
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
log(level: LogLevel, msg: string, data?: Record<string, unknown>): void {
|
|
99
|
+
if (!this.shouldLog(level)) return;
|
|
100
|
+
const record: LogRecord = {
|
|
101
|
+
level,
|
|
102
|
+
time: Date.now(),
|
|
103
|
+
msg,
|
|
104
|
+
...this.opts.bindings,
|
|
105
|
+
...(data ?? {}),
|
|
106
|
+
};
|
|
107
|
+
const safe = this.redactFields(record);
|
|
108
|
+
const line = this.opts.format === 'json' ? JSON.stringify(safe) : this.pretty(safe);
|
|
109
|
+
this.write(line + EOL, level);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
private redactFields(record: LogRecord): LogRecord {
|
|
113
|
+
if (this.opts.redact.length === 0) return record;
|
|
114
|
+
const out: LogRecord = { ...record };
|
|
115
|
+
for (const key of this.opts.redact) {
|
|
116
|
+
if (key in out) out[key] = '[REDACTED]';
|
|
117
|
+
}
|
|
118
|
+
return out;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
private pretty(rec: LogRecord): string {
|
|
122
|
+
const time = new Date(rec.time).toISOString();
|
|
123
|
+
const levelTag = `${COLORS[rec.level]}${rec.level.toUpperCase().padEnd(5)}${RESET}`;
|
|
124
|
+
const base = Object.fromEntries(
|
|
125
|
+
Object.entries(rec).filter(([k]) => k !== 'level' && k !== 'time' && k !== 'msg'),
|
|
126
|
+
);
|
|
127
|
+
const payload = Object.keys(base).length ? ` ${JSON.stringify(base)}` : '';
|
|
128
|
+
return `${time} ${levelTag} ${rec.msg}${payload}`;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
private write(line: string, level: LogLevel): void {
|
|
132
|
+
if (this.opts.console) {
|
|
133
|
+
if (level === 'error' || level === 'fatal') process.stderr.write(line);
|
|
134
|
+
else process.stdout.write(line);
|
|
135
|
+
}
|
|
136
|
+
if (this.stream && this.opts.file) {
|
|
137
|
+
this.currentSize += Buffer.byteLength(line);
|
|
138
|
+
this.stream.write(line);
|
|
139
|
+
if (this.currentSize >= this.opts.file.maxFileSize) this.rotate();
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
private rotate(): void {
|
|
144
|
+
const file = this.opts.file!;
|
|
145
|
+
const target = join(file.dir, 'nexus.log');
|
|
146
|
+
if (!existsSync(target)) return;
|
|
147
|
+
this.stream?.end();
|
|
148
|
+
for (let i = file.maxFiles - 1; i > 0; i--) {
|
|
149
|
+
const from = join(file.dir, `nexus.${i - 1}.log`);
|
|
150
|
+
const to = join(file.dir, `nexus.${i}.log`);
|
|
151
|
+
if (existsSync(from)) renameSync(from, to);
|
|
152
|
+
}
|
|
153
|
+
renameSync(target, join(file.dir, 'nexus.0.log'));
|
|
154
|
+
this.currentSize = 0;
|
|
155
|
+
this.stream = createWriteStream(target, { flags: 'a' });
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Convenience level methods
|
|
159
|
+
trace(msg: string, data?: Record<string, unknown>): void { this.log('trace', msg, data); }
|
|
160
|
+
debug(msg: string, data?: Record<string, unknown>): void { this.log('debug', msg, data); }
|
|
161
|
+
info(msg: string, data?: Record<string, unknown>): void { this.log('info', msg, data); }
|
|
162
|
+
warn(msg: string, data?: Record<string, unknown>): void { this.log('warn', msg, data); }
|
|
163
|
+
error(msg: string, data?: Record<string, unknown>): void { this.log('error', msg, data); }
|
|
164
|
+
fatal(msg: string, data?: Record<string, unknown>): void { this.log('fatal', msg, data); }
|
|
165
|
+
|
|
166
|
+
/** Flush file transport. */
|
|
167
|
+
close(): Promise<void> {
|
|
168
|
+
return new Promise((resolve) => {
|
|
169
|
+
if (this.stream) this.stream.end(() => resolve());
|
|
170
|
+
else resolve();
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** A no-op file-less logger for tests / library contexts. */
|
|
176
|
+
export function createLogger(opts?: LoggerOptions): Logger {
|
|
177
|
+
return new Logger({ console: false, ...opts });
|
|
178
|
+
}
|