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,279 @@
|
|
|
1
|
+
import { Worker } from 'node:worker_threads';
|
|
2
|
+
import type { PluginManifest, SandboxLimits } from '../manifest.js';
|
|
3
|
+
import type { PluginHandle, HostBindings } from '../host.js';
|
|
4
|
+
import { authorize } from './capabilityPolicy.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Sandboxed plugin: the entry runs inside a worker_thread with heap + event-loop
|
|
8
|
+
* isolation. A crash in the worker cannot take the host down. The host owns the
|
|
9
|
+
* real registrars (HostBindings); the worker calls them over a request/response
|
|
10
|
+
* RPC, and the host authorizes every call via the capability policy before
|
|
11
|
+
* forwarding. Route handlers registered by the worker run *inside* the worker —
|
|
12
|
+
* the host proxies inbound requests to the worker over `invokeRoute` and awaits
|
|
13
|
+
* the response. If the worker dies mid-request, the host returns a 503 and
|
|
14
|
+
* (optionally) restarts it.
|
|
15
|
+
*/
|
|
16
|
+
export class SandboxedPlugin implements PluginHandle {
|
|
17
|
+
readonly manifest: PluginManifest;
|
|
18
|
+
state: PluginHandle['state'] = 'loaded';
|
|
19
|
+
private readonly bindings: HostBindings;
|
|
20
|
+
private readonly pluginDir: string;
|
|
21
|
+
private readonly limits: SandboxLimits;
|
|
22
|
+
private readonly caps: Set<string>;
|
|
23
|
+
private worker: Worker | null = null;
|
|
24
|
+
private ready = false;
|
|
25
|
+
private readonly rpcReplies = new Map<number, { resolve: (v: unknown) => void; reject: (e: Error) => void }>();
|
|
26
|
+
private readonly routeHandlers = new Map<string, (ctx: any) => void | Promise<void>>();
|
|
27
|
+
private readonly pendingRoutes = new Map<string, { resolve: (r: { status: number; body: unknown; headers: Record<string, string> }) => void }>();
|
|
28
|
+
private heartbeatTimer: NodeJS.Timeout | null = null;
|
|
29
|
+
private lastHeartbeat = 0;
|
|
30
|
+
private lifecycleWaiters = new Map<string, { resolve: () => void; reject: (e: Error) => void }>();
|
|
31
|
+
private restartCount = 0;
|
|
32
|
+
|
|
33
|
+
constructor(manifest: PluginManifest, pluginDir: string, bindings: HostBindings, config: unknown, limits: SandboxLimits = {}) {
|
|
34
|
+
this.manifest = manifest;
|
|
35
|
+
this.bindings = bindings;
|
|
36
|
+
this.pluginDir = pluginDir;
|
|
37
|
+
this.limits = limits;
|
|
38
|
+
this.caps = new Set(manifest.capabilities as string[]);
|
|
39
|
+
this.config = config;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
private config: unknown;
|
|
43
|
+
|
|
44
|
+
private bootstrapUrl(): URL {
|
|
45
|
+
return new URL('./workerBootstrap.mjs', import.meta.url);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
private spawnWorker(): void {
|
|
49
|
+
const entryPath = `${this.pluginDir}/${this.manifest.entry}`;
|
|
50
|
+
const worker = new Worker(this.bootstrapUrl(), {
|
|
51
|
+
workerData: {
|
|
52
|
+
entryPath,
|
|
53
|
+
manifest: this.manifest,
|
|
54
|
+
config: this.config,
|
|
55
|
+
limits: this.limits,
|
|
56
|
+
},
|
|
57
|
+
resourceLimits: this.limits.resourceLimits ?? { maxOldGenerationSizeMb: 64, maxYoungGenerationSizeMb: 16 },
|
|
58
|
+
});
|
|
59
|
+
this.worker = worker;
|
|
60
|
+
this.lastHeartbeat = Date.now();
|
|
61
|
+
|
|
62
|
+
worker.on('message', (msg) => this.onWorkerMessage(msg));
|
|
63
|
+
worker.on('error', (err) => {
|
|
64
|
+
this.bindings.logger.child({ plugin: this.manifest.name }).error('worker error', { error: String(err.message) });
|
|
65
|
+
this.handleWorkerDeath(err);
|
|
66
|
+
});
|
|
67
|
+
worker.on('exit', (code) => {
|
|
68
|
+
if (code !== 0 && this.state !== 'stopped' && this.state !== 'errored') {
|
|
69
|
+
this.handleWorkerDeath(new Error(`worker exited with code ${code}`));
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
private handleWorkerDeath(err: Error): void {
|
|
75
|
+
this.ready = false;
|
|
76
|
+
// Reject any in-flight RPC requests.
|
|
77
|
+
for (const [, p] of this.rpcReplies) p.reject(err);
|
|
78
|
+
this.rpcReplies.clear();
|
|
79
|
+
for (const [, p] of this.pendingRoutes) p.resolve({ status: 503, body: { error: 'plugin worker unavailable' }, headers: {} });
|
|
80
|
+
this.pendingRoutes.clear();
|
|
81
|
+
for (const [, p] of this.lifecycleWaiters) p.reject(err);
|
|
82
|
+
this.lifecycleWaiters.clear();
|
|
83
|
+
if (this.heartbeatTimer) { clearInterval(this.heartbeatTimer); this.heartbeatTimer = null; }
|
|
84
|
+
this.state = 'errored';
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
private onWorkerMessage(msg: any): void {
|
|
88
|
+
switch (msg.kind) {
|
|
89
|
+
case 'ready':
|
|
90
|
+
this.ready = true;
|
|
91
|
+
this.startHeartbeatWatchdog();
|
|
92
|
+
break;
|
|
93
|
+
case 'heartbeat':
|
|
94
|
+
this.lastHeartbeat = Date.now();
|
|
95
|
+
break;
|
|
96
|
+
case 'log':
|
|
97
|
+
{
|
|
98
|
+
const logger = this.bindings.logger.child({ plugin: this.manifest.name });
|
|
99
|
+
const level = msg.level as keyof typeof logger;
|
|
100
|
+
logger[level]?.(msg.msg, msg.meta);
|
|
101
|
+
}
|
|
102
|
+
break;
|
|
103
|
+
case 'rpc': {
|
|
104
|
+
this.handleRpc(msg.id, msg.method, msg.args);
|
|
105
|
+
break;
|
|
106
|
+
}
|
|
107
|
+
case 'routeResponse': {
|
|
108
|
+
const p = this.pendingRoutes.get(msg.routeId);
|
|
109
|
+
if (p) { this.pendingRoutes.delete(msg.routeId); p.resolve({ status: msg.status, body: msg.body, headers: msg.headers }); }
|
|
110
|
+
break;
|
|
111
|
+
}
|
|
112
|
+
case 'lifecycleDone': {
|
|
113
|
+
const w = this.lifecycleWaiters.get(msg.hook);
|
|
114
|
+
if (w) {
|
|
115
|
+
this.lifecycleWaiters.delete(msg.hook);
|
|
116
|
+
if (msg.ok) w.resolve();
|
|
117
|
+
else w.reject(new Error(msg.error));
|
|
118
|
+
}
|
|
119
|
+
break;
|
|
120
|
+
}
|
|
121
|
+
case 'fatal':
|
|
122
|
+
this.handleWorkerDeath(new Error(msg.error));
|
|
123
|
+
break;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
private async handleRpc(id: number, method: string, args: Record<string, unknown>): Promise<void> {
|
|
128
|
+
// Authorize first.
|
|
129
|
+
const decision = authorize(method, args, this.caps as Set<any>, this.limits.fsAllowPaths, this.limits.netAllowHosts);
|
|
130
|
+
if (!decision.allowed) {
|
|
131
|
+
this.worker?.postMessage({ kind: 'rpcResult', id, ok: false, error: decision.reason ?? 'denied' });
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
try {
|
|
135
|
+
const result = await this.dispatch(method, args);
|
|
136
|
+
this.worker?.postMessage({ kind: 'rpcResult', id, ok: true, result });
|
|
137
|
+
} catch (err: any) {
|
|
138
|
+
this.worker?.postMessage({ kind: 'rpcResult', id, ok: false, error: String(err?.message ?? err) });
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
private async dispatch(method: string, args: Record<string, unknown>): Promise<unknown> {
|
|
143
|
+
switch (method) {
|
|
144
|
+
case 'http.addRoute': {
|
|
145
|
+
const { method: m, path, routeId } = args as { method: string; path: string; routeId: string };
|
|
146
|
+
const self = this;
|
|
147
|
+
const handler = (ctx: any): void => self.invokeRoute(routeId, ctx);
|
|
148
|
+
this.routeHandlers.set(routeId, handler);
|
|
149
|
+
this.bindings.http.addRoute(m as any, path, handler);
|
|
150
|
+
return undefined;
|
|
151
|
+
}
|
|
152
|
+
case 'http.addMiddleware':
|
|
153
|
+
// Sandbox cannot serialize middleware functions; documented v1 no-op.
|
|
154
|
+
this.bindings.logger.child({ plugin: this.manifest.name }).warn('sandboxed middleware not applied (v1 limitation)');
|
|
155
|
+
return undefined;
|
|
156
|
+
case 'data.registerModel': return this.bindings.data.registerModel(args.name as string, args.schema);
|
|
157
|
+
case 'data.getModel': return this.bindings.data.getModel(args.name as string);
|
|
158
|
+
case 'realtime.join': this.bindings.realtime.join(args.room as string, args.connId as string); return undefined;
|
|
159
|
+
case 'realtime.broadcast': this.bindings.realtime.broadcast(args.room as string, args.msg); return undefined;
|
|
160
|
+
case 'scheduler.schedule': {
|
|
161
|
+
const self = this;
|
|
162
|
+
this.bindings.scheduler.schedule(args.name as string, args.cron as string, () => self.worker?.postMessage({ kind: 'runSchedule', name: args.name as string }));
|
|
163
|
+
return undefined;
|
|
164
|
+
}
|
|
165
|
+
case 'scheduler.cancel': this.bindings.scheduler.cancel(args.name as string); return undefined;
|
|
166
|
+
case 'events.publish': this.bindings.events.publish(args.topic as string, args.payload); return undefined;
|
|
167
|
+
case 'events.subscribe': {
|
|
168
|
+
const self = this;
|
|
169
|
+
this.bindings.events.subscribe(args.topic as string, (payload) => self.worker?.postMessage({ kind: 'event', topic: args.topic, payload }));
|
|
170
|
+
return undefined;
|
|
171
|
+
}
|
|
172
|
+
case 'events.unsubscribe':
|
|
173
|
+
// Best-effort: we don't track the unsubscribe fn here in v1.
|
|
174
|
+
return undefined;
|
|
175
|
+
case 'admin.registerAdminPage': this.bindings.admin.registerAdminPage(args.page as any); return undefined;
|
|
176
|
+
case 'admin.registerSlot': this.bindings.admin.registerSlot(args.slot as any); return undefined;
|
|
177
|
+
case 'services.register': this.bindings.services.register(args.name as string, args.svc); return undefined;
|
|
178
|
+
case 'services.get': return this.bindings.services.get(args.name as string);
|
|
179
|
+
case 'fs.readFile': {
|
|
180
|
+
const f = await import('node:fs/promises');
|
|
181
|
+
return f.readFile(args.path as string);
|
|
182
|
+
}
|
|
183
|
+
case 'fs.writeFile': {
|
|
184
|
+
const f = await import('node:fs/promises');
|
|
185
|
+
await f.writeFile(args.path as string, args.data as any);
|
|
186
|
+
return undefined;
|
|
187
|
+
}
|
|
188
|
+
case 'net.fetch': {
|
|
189
|
+
const r = await fetch(args.url as string, { method: (args.init as any)?.method, headers: (args.init as any)?.headers, body: (args.init as any)?.body });
|
|
190
|
+
return { status: r.status, body: await r.text() };
|
|
191
|
+
}
|
|
192
|
+
default:
|
|
193
|
+
throw new Error(`unknown RPC method: ${method}`);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Proxy an inbound HTTP request into the worker, await its response. */
|
|
198
|
+
private invokeRoute(routeId: string, ctx: any): void {
|
|
199
|
+
const request = {
|
|
200
|
+
method: ctx.method,
|
|
201
|
+
path: ctx.path,
|
|
202
|
+
headers: ctx.headers,
|
|
203
|
+
body: ctx.body,
|
|
204
|
+
params: ctx.params,
|
|
205
|
+
query: ctx.query,
|
|
206
|
+
};
|
|
207
|
+
const promise = new Promise<{ status: number; body: unknown; headers: Record<string, string> }>((resolve) => {
|
|
208
|
+
this.pendingRoutes.set(routeId, { resolve });
|
|
209
|
+
});
|
|
210
|
+
this.worker?.postMessage({ kind: 'invokeRoute', routeId, request });
|
|
211
|
+
promise.then((res) => {
|
|
212
|
+
ctx.status(res.status);
|
|
213
|
+
if (res.headers) for (const [k, v] of Object.entries(res.headers)) ctx.setHeader(k, v);
|
|
214
|
+
if (res.body !== undefined) ctx.json(res.body);
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
private startHeartbeatWatchdog(): void {
|
|
219
|
+
const timeout = this.limits.heartbeatTimeoutMs ?? 4000;
|
|
220
|
+
this.heartbeatTimer = setInterval(() => {
|
|
221
|
+
if (Date.now() - this.lastHeartbeat > timeout) {
|
|
222
|
+
this.bindings.logger.child({ plugin: this.manifest.name }).error('heartbeat missed; terminating worker');
|
|
223
|
+
this.worker?.terminate().catch(() => {});
|
|
224
|
+
this.handleWorkerDeath(new Error('heartbeat timeout'));
|
|
225
|
+
}
|
|
226
|
+
}, timeout);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
private sendLifecycle(hook: string, config?: unknown): Promise<void> {
|
|
230
|
+
return new Promise((resolve, reject) => {
|
|
231
|
+
this.lifecycleWaiters.set(hook, { resolve, reject });
|
|
232
|
+
this.worker?.postMessage({ kind: 'lifecycle', hook, config });
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
async install(): Promise<void> {
|
|
237
|
+
if (!this.worker) this.spawnWorker();
|
|
238
|
+
// Wait for the worker to report ready, then drive install via RPC.
|
|
239
|
+
await this.waitForReady();
|
|
240
|
+
await this.sendLifecycle('install');
|
|
241
|
+
this.state = 'installed';
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
private waitForReady(): Promise<void> {
|
|
245
|
+
if (this.ready) return Promise.resolve();
|
|
246
|
+
return new Promise((resolve, reject) => {
|
|
247
|
+
const check = () => {
|
|
248
|
+
if (this.ready) resolve();
|
|
249
|
+
else if (this.state === 'errored') reject(new Error('worker failed to start'));
|
|
250
|
+
else setTimeout(check, 20);
|
|
251
|
+
};
|
|
252
|
+
check();
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
async init(): Promise<void> { await this.sendLifecycle('init'); this.state = 'initialized'; }
|
|
257
|
+
async start(): Promise<void> { await this.sendLifecycle('start'); this.state = 'started'; }
|
|
258
|
+
async stop(): Promise<void> {
|
|
259
|
+
try { await this.sendLifecycle('stop'); } catch { /* worker may be dead */ }
|
|
260
|
+
this.state = 'stopped';
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
async updateConfig(cfg: unknown): Promise<void> {
|
|
264
|
+
this.config = cfg;
|
|
265
|
+
await this.sendLifecycle('config:change', cfg);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
async unload(): Promise<void> {
|
|
269
|
+
if (this.heartbeatTimer) { clearInterval(this.heartbeatTimer); this.heartbeatTimer = null; }
|
|
270
|
+
try { await this.sendLifecycle('stop'); } catch { /* ignore */ }
|
|
271
|
+
await this.worker?.terminate().catch(() => {});
|
|
272
|
+
this.worker = null;
|
|
273
|
+
this.ready = false;
|
|
274
|
+
this.state = 'stopped';
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** Test helper: number of times the worker died. */
|
|
278
|
+
getRestarts(): number { return this.restartCount; }
|
|
279
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type { Capability } from '../manifest.js';
|
|
2
|
+
|
|
3
|
+
/** Maps an RPC method name to the capability it requires. */
|
|
4
|
+
const METHOD_CAPABILITY: Record<string, Capability> = {
|
|
5
|
+
'http.addRoute': 'http',
|
|
6
|
+
'http.addMiddleware': 'http',
|
|
7
|
+
'data.registerModel': 'data',
|
|
8
|
+
'data.getModel': 'data',
|
|
9
|
+
'realtime.join': 'realtime',
|
|
10
|
+
'realtime.broadcast': 'realtime',
|
|
11
|
+
'scheduler.schedule': 'scheduler',
|
|
12
|
+
'scheduler.cancel': 'scheduler',
|
|
13
|
+
'events.publish': 'events',
|
|
14
|
+
'events.subscribe': 'events',
|
|
15
|
+
'events.unsubscribe': 'events',
|
|
16
|
+
'admin.registerAdminPage': 'admin',
|
|
17
|
+
'admin.registerSlot': 'admin',
|
|
18
|
+
'services.register': 'events',
|
|
19
|
+
'services.get': 'events',
|
|
20
|
+
'fs.readFile': 'fs',
|
|
21
|
+
'fs.writeFile': 'fs',
|
|
22
|
+
'net.fetch': 'net',
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/** Result of an authorization check. */
|
|
26
|
+
export interface PolicyDecision {
|
|
27
|
+
allowed: boolean;
|
|
28
|
+
reason?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Decide whether a sandboxed plugin may invoke an RPC method with the given args,
|
|
33
|
+
* given its declared capabilities and allowlists. The capability set is checked
|
|
34
|
+
* first; for fs/net the path/host is additionally checked against the allowlist.
|
|
35
|
+
* The `http` route handler is *not* gated here — it is a push from the host to the
|
|
36
|
+
* worker (invokeRoute), not a worker RPC.
|
|
37
|
+
*/
|
|
38
|
+
export function authorize(
|
|
39
|
+
method: string,
|
|
40
|
+
args: Record<string, unknown>,
|
|
41
|
+
capabilities: Set<Capability>,
|
|
42
|
+
fsAllowPaths?: string[],
|
|
43
|
+
netAllowHosts?: string[],
|
|
44
|
+
): PolicyDecision {
|
|
45
|
+
const cap = METHOD_CAPABILITY[method];
|
|
46
|
+
if (!cap) {
|
|
47
|
+
// Unknown methods (incl. logger, which is always allowed) — allow by default.
|
|
48
|
+
return { allowed: true };
|
|
49
|
+
}
|
|
50
|
+
if (!capabilities.has(cap)) {
|
|
51
|
+
return { allowed: false, reason: `missing capability: ${cap}` };
|
|
52
|
+
}
|
|
53
|
+
if (method === 'fs.readFile' || method === 'fs.writeFile') {
|
|
54
|
+
const path = String(args.path ?? '');
|
|
55
|
+
if (fsAllowPaths && fsAllowPaths.length > 0 && !fsAllowPaths.some((p) => path === p || path.startsWith(p))) {
|
|
56
|
+
return { allowed: false, reason: `path outside allowlist: ${path}` };
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
if (method === 'net.fetch') {
|
|
60
|
+
const url = String(args.url ?? '');
|
|
61
|
+
let host = '';
|
|
62
|
+
try { host = new URL(url).hostname; } catch { host = url; }
|
|
63
|
+
if (netAllowHosts && netAllowHosts.length > 0 && !netAllowHosts.includes(host)) {
|
|
64
|
+
return { allowed: false, reason: `host outside allowlist: ${host}` };
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return { allowed: true };
|
|
68
|
+
}
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
// Plugin worker bootstrap (plain ESM — Node runs this directly in a worker_thread,
|
|
2
|
+
// no TS loader needed). Loads the plugin entry and exposes a PluginContext whose
|
|
3
|
+
// registrars RPC back to the host (capability-gated there). Runs lifecycle hooks
|
|
4
|
+
// and route/schedule/event handlers in response to host messages. A crash here
|
|
5
|
+
// is isolated from the host process.
|
|
6
|
+
import { workerData, parentPort } from 'node:worker_threads';
|
|
7
|
+
import { pathToFileURL } from 'node:url';
|
|
8
|
+
|
|
9
|
+
const { entryPath, manifest, config } = workerData;
|
|
10
|
+
const caps = new Set(manifest.capabilities);
|
|
11
|
+
|
|
12
|
+
// Pending RPC requests awaiting host replies.
|
|
13
|
+
const rpcPending = new Map();
|
|
14
|
+
let rpcSeq = 0;
|
|
15
|
+
|
|
16
|
+
function callHost(method, args) {
|
|
17
|
+
const id = ++rpcSeq;
|
|
18
|
+
return new Promise((resolve, reject) => {
|
|
19
|
+
rpcPending.set(id, { resolve, reject });
|
|
20
|
+
parentPort.postMessage({ kind: 'rpc', id, method, args });
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function requireCap(cap) {
|
|
25
|
+
if (!caps.has(cap)) throw new Error(`plugin ${manifest.name} lacks capability: ${cap}`);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Stored handlers the host invokes via messages.
|
|
29
|
+
const routes = new Map(); // routeId -> handler
|
|
30
|
+
const schedules = new Map(); // name -> fn
|
|
31
|
+
const eventHandlers = new Map(); // topic -> handler
|
|
32
|
+
|
|
33
|
+
function buildContext() {
|
|
34
|
+
const logger = {
|
|
35
|
+
info: (m, meta) => parentPort.postMessage({ kind: 'log', level: 'info', msg: m, meta }),
|
|
36
|
+
warn: (m, meta) => parentPort.postMessage({ kind: 'log', level: 'warn', msg: m, meta }),
|
|
37
|
+
error: (m, meta) => parentPort.postMessage({ kind: 'log', level: 'error', msg: m, meta }),
|
|
38
|
+
};
|
|
39
|
+
const ctx = {
|
|
40
|
+
pluginName: manifest.name,
|
|
41
|
+
capabilities: manifest.capabilities,
|
|
42
|
+
config,
|
|
43
|
+
logger,
|
|
44
|
+
http: {
|
|
45
|
+
addRoute(method, path, handler) {
|
|
46
|
+
requireCap('http');
|
|
47
|
+
const routeId = `${method}:${path}:${Math.random().toString(36).slice(2, 8)}`;
|
|
48
|
+
routes.set(routeId, handler);
|
|
49
|
+
callHost('http.addRoute', { method, path, routeId });
|
|
50
|
+
},
|
|
51
|
+
addMiddleware(mw, phase) {
|
|
52
|
+
requireCap('http');
|
|
53
|
+
// Middleware can't be serialized; sandboxed middleware is a v1 no-op stub
|
|
54
|
+
// (documented). The host logs that middleware was requested but not applied.
|
|
55
|
+
callHost('http.addMiddleware', { phase });
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
graphql: {
|
|
59
|
+
addSubgraph() {
|
|
60
|
+
throw new Error('sandboxed plugins cannot add GraphQL subgraphs in v1 (resolvers are not serializable; use trusted mode)');
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
data: {
|
|
64
|
+
registerModel(name, schema) { requireCap('data'); return callHost('data.registerModel', { name, schema }); },
|
|
65
|
+
getModel(name) { requireCap('data'); return callHost('data.getModel', { name }); },
|
|
66
|
+
},
|
|
67
|
+
realtime: {
|
|
68
|
+
join(room, connId) { requireCap('realtime'); callHost('realtime.join', { room, connId }); },
|
|
69
|
+
broadcast(room, msg) { requireCap('realtime'); callHost('realtime.broadcast', { room, msg }); },
|
|
70
|
+
},
|
|
71
|
+
scheduler: {
|
|
72
|
+
schedule(name, cron, fn) { requireCap('scheduler'); schedules.set(name, fn); callHost('scheduler.schedule', { name, cron }); },
|
|
73
|
+
cancel(name) { requireCap('scheduler'); schedules.delete(name); callHost('scheduler.cancel', { name }); },
|
|
74
|
+
},
|
|
75
|
+
events: {
|
|
76
|
+
publish(topic, payload) { requireCap('events'); callHost('events.publish', { topic, payload }); },
|
|
77
|
+
subscribe(topic, handler) {
|
|
78
|
+
requireCap('events');
|
|
79
|
+
eventHandlers.set(topic, handler);
|
|
80
|
+
callHost('events.subscribe', { topic });
|
|
81
|
+
return () => { eventHandlers.delete(topic); callHost('events.unsubscribe', { topic }); };
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
admin: {
|
|
85
|
+
registerAdminPage(page) { requireCap('admin'); callHost('admin.registerAdminPage', { page }); },
|
|
86
|
+
registerSlot(slot) { requireCap('admin'); callHost('admin.registerSlot', { slot }); },
|
|
87
|
+
},
|
|
88
|
+
services: {
|
|
89
|
+
register(name, svc) { requireCap('events'); callHost('services.register', { name, svc }); },
|
|
90
|
+
get(name) { requireCap('events'); return callHost('services.get', { name }); },
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
if (caps.has('fs')) {
|
|
94
|
+
ctx.fs = {
|
|
95
|
+
readFile(path) { return callHost('fs.readFile', { path }); },
|
|
96
|
+
writeFile(path, data) { return callHost('fs.writeFile', { path, data }); },
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
if (caps.has('net')) {
|
|
100
|
+
ctx.net = {
|
|
101
|
+
fetch(url, init) { return callHost('net.fetch', { url, init }); },
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
return ctx;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Build a serializable request context for an invoked route handler.
|
|
108
|
+
function buildRequestContext(request) {
|
|
109
|
+
let sent = false;
|
|
110
|
+
const response = { status: 200, body: undefined, headers: {} };
|
|
111
|
+
const api = {
|
|
112
|
+
method: request.method,
|
|
113
|
+
path: request.path,
|
|
114
|
+
headers: request.headers,
|
|
115
|
+
body: request.body,
|
|
116
|
+
params: request.params,
|
|
117
|
+
query: request.query,
|
|
118
|
+
json(data, status) { response.body = data; response.status = status ?? 200; sent = true; },
|
|
119
|
+
text(data, status) { response.body = data; response.status = status ?? 200; sent = true; },
|
|
120
|
+
html(data, status) { response.body = data; response.status = status ?? 200; sent = true; },
|
|
121
|
+
status(status) { response.status = status; sent = true; },
|
|
122
|
+
setHeader(name, value) { response.headers[name] = value; },
|
|
123
|
+
};
|
|
124
|
+
return { api, response, isSent: () => sent };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
let ctx;
|
|
128
|
+
let pluginModule;
|
|
129
|
+
|
|
130
|
+
async function loadPlugin() {
|
|
131
|
+
const mod = await import(pathToFileURL(entryPath).href);
|
|
132
|
+
pluginModule = mod.default ?? mod;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function runHook(hook, hookConfig) {
|
|
136
|
+
const fn = pluginModule?.[hook];
|
|
137
|
+
if (hook === 'onConfigChange') {
|
|
138
|
+
if (pluginModule?.onConfigChange) await pluginModule.onConfigChange(ctx, hookConfig);
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
if (fn) await fn(ctx);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
parentPort.on('message', async (msg) => {
|
|
145
|
+
try {
|
|
146
|
+
if (msg.kind === 'rpcResult') {
|
|
147
|
+
const p = rpcPending.get(msg.id);
|
|
148
|
+
if (!p) return;
|
|
149
|
+
rpcPending.delete(msg.id);
|
|
150
|
+
if (msg.ok) p.resolve(msg.result);
|
|
151
|
+
else p.reject(new Error(msg.error));
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
if (msg.kind === 'lifecycle') {
|
|
155
|
+
if (msg.config !== undefined) ctx.config = msg.config;
|
|
156
|
+
try {
|
|
157
|
+
// Map hook names to module methods.
|
|
158
|
+
const hookMap = { install: 'install', init: 'init', start: 'start', stop: 'stop', 'config:change': 'onConfigChange' };
|
|
159
|
+
const fnName = hookMap[msg.hook];
|
|
160
|
+
const fn = pluginModule?.[fnName];
|
|
161
|
+
if (fn) await fn(ctx, msg.config);
|
|
162
|
+
parentPort.postMessage({ kind: 'lifecycleDone', hook: msg.hook, ok: true });
|
|
163
|
+
} catch (err) {
|
|
164
|
+
parentPort.postMessage({ kind: 'lifecycleDone', hook: msg.hook, ok: false, error: String(err?.message ?? err) });
|
|
165
|
+
}
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
if (msg.kind === 'invokeRoute') {
|
|
169
|
+
const handler = routes.get(msg.routeId);
|
|
170
|
+
const { api, response, isSent } = buildRequestContext(msg.request);
|
|
171
|
+
try {
|
|
172
|
+
if (handler) await handler(api);
|
|
173
|
+
if (!isSent()) { response.body = response.body ?? ''; response.status = response.status ?? 200; }
|
|
174
|
+
parentPort.postMessage({ kind: 'routeResponse', routeId: msg.routeId, status: response.status, body: response.body, headers: response.headers });
|
|
175
|
+
} catch (err) {
|
|
176
|
+
parentPort.postMessage({ kind: 'routeResponse', routeId: msg.routeId, status: 500, body: { error: String(err?.message ?? err) }, headers: {} });
|
|
177
|
+
}
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
if (msg.kind === 'runSchedule') {
|
|
181
|
+
const fn = schedules.get(msg.name);
|
|
182
|
+
if (fn) await fn();
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
if (msg.kind === 'event') {
|
|
186
|
+
const h = eventHandlers.get(msg.topic);
|
|
187
|
+
if (h) h(msg.payload);
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
} catch (err) {
|
|
191
|
+
// Never let a message-handler throw crash the worker silently.
|
|
192
|
+
parentPort.postMessage({ kind: 'log', level: 'error', msg: 'handler error', meta: { error: String(err?.message ?? err) } });
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
// Heartbeat — tell the host we're alive on an interval.
|
|
197
|
+
const heartbeatMs = (workerData.limits && workerData.limits.heartbeatMs) || 1000;
|
|
198
|
+
const heartbeat = setInterval(() => parentPort.postMessage({ kind: 'heartbeat' }), heartbeatMs);
|
|
199
|
+
|
|
200
|
+
async function start() {
|
|
201
|
+
ctx = buildContext();
|
|
202
|
+
await loadPlugin();
|
|
203
|
+
parentPort.postMessage({ kind: 'ready' });
|
|
204
|
+
// The host drives all lifecycle hooks (install → init → start) via messages.
|
|
205
|
+
}
|
|
206
|
+
start().catch((err) => {
|
|
207
|
+
parentPort.postMessage({ kind: 'log', level: 'error', msg: 'plugin failed to start', meta: { error: String(err?.message ?? err) } });
|
|
208
|
+
parentPort.postMessage({ kind: 'fatal', error: String(err?.message ?? err) });
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
export {}; // keep this an ES module
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@bhooai/nexus-realtime",
|
|
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
|
+
"@bhooai/nexus-auth": "^0.1.0",
|
|
15
|
+
"ws": "^8.18.0"
|
|
16
|
+
},
|
|
17
|
+
"optionalDependencies": {
|
|
18
|
+
"mediasoup": "^3.16.0"
|
|
19
|
+
},
|
|
20
|
+
"devDependencies": {
|
|
21
|
+
"@types/node": "^22.5.0",
|
|
22
|
+
"@types/ws": "^8.5.13",
|
|
23
|
+
"typescript": "^5.6.2",
|
|
24
|
+
"vitest": "^2.1.1"
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { EventEmitter } from 'node:events';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Pub/sub adapter for cross-instance room broadcast fanout. The default
|
|
5
|
+
* `MemoryPubSubAdapter` is a single-process EventEmitter-backed implementation
|
|
6
|
+
* (fine for tests and single-node deployments). A Redis-backed adapter is used
|
|
7
|
+
* for horizontal scaling — see `RedisPubSubAdapter`.
|
|
8
|
+
*
|
|
9
|
+
* Broadcast model: `RealtimeServer` publishes a room event; every instance's
|
|
10
|
+
* subscriber (including the originator) delivers it to its local members. This
|
|
11
|
+
* gives exactly-once delivery per instance without the originator also sending
|
|
12
|
+
* locally.
|
|
13
|
+
*/
|
|
14
|
+
export interface PubSubAdapter {
|
|
15
|
+
/** Publish a message to a channel (room). Returns void (fire-and-forget). */
|
|
16
|
+
publish(channel: string, message: Buffer | string): void;
|
|
17
|
+
/** Subscribe to a channel; the handler is called for each published message. */
|
|
18
|
+
subscribe(channel: string, handler: (message: Buffer | string) => void): Promise<void> | void;
|
|
19
|
+
/** Unsubscribe a handler from a channel. */
|
|
20
|
+
unsubscribe(channel: string, handler: (message: Buffer | string) => void): Promise<void> | void;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export class MemoryPubSubAdapter implements PubSubAdapter {
|
|
24
|
+
private bus = new EventEmitter();
|
|
25
|
+
|
|
26
|
+
constructor() {
|
|
27
|
+
// Many subscribers per channel; allow headroom for fanout.
|
|
28
|
+
this.bus.setMaxListeners(0);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
publish(channel: string, message: Buffer | string): void {
|
|
32
|
+
this.bus.emit(channel, message);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
subscribe(channel: string, handler: (message: Buffer | string) => void): void {
|
|
36
|
+
this.bus.on(channel, handler);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
unsubscribe(channel: string, handler: (message: Buffer | string) => void): void {
|
|
40
|
+
this.bus.off(channel, handler);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { PubSubAdapter } from './PubSubAdapter.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Minimal Redis client interface this adapter depends on (compatible with
|
|
5
|
+
* `node-redis` i.e. `createClient()`). Kept structural so the realtime package
|
|
6
|
+
* does not hard-depend on a redis client at import time — `nexus-cache`
|
|
7
|
+
* (Phase 7) supplies a configured client.
|
|
8
|
+
*/
|
|
9
|
+
export interface RedisLike {
|
|
10
|
+
publish(channel: string, message: string): Promise<number>;
|
|
11
|
+
subscribe(...channels: string[]): Promise<number>;
|
|
12
|
+
unsubscribe(...channels: string[]): Promise<number>;
|
|
13
|
+
on(event: 'message', listener: (channel: string, message: string) => void): unknown;
|
|
14
|
+
off(event: 'message', listener: (channel: string, message: string) => void): unknown;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Redis-backed pub/sub adapter for multi-instance room broadcast. Uses one
|
|
19
|
+
* configured Redis client in subscribe mode; messages are JSON strings.
|
|
20
|
+
*/
|
|
21
|
+
export class RedisPubSubAdapter implements PubSubAdapter {
|
|
22
|
+
private handlers = new Map<string, Set<(message: Buffer | string) => void>>();
|
|
23
|
+
|
|
24
|
+
constructor(private client: RedisLike) {
|
|
25
|
+
// Route incoming Redis messages to registered handlers.
|
|
26
|
+
const route = (channel: string, message: string) => {
|
|
27
|
+
const set = this.handlers.get(channel);
|
|
28
|
+
if (set) for (const h of set) h(message);
|
|
29
|
+
};
|
|
30
|
+
this.route = route;
|
|
31
|
+
client.on('message', route);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
private route: (channel: string, message: string) => void;
|
|
35
|
+
|
|
36
|
+
publish(channel: string, message: Buffer | string): void {
|
|
37
|
+
void this.client.publish(channel, typeof message === 'string' ? message : message.toString('utf8'));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async subscribe(channel: string, handler: (message: Buffer | string) => void): Promise<void> {
|
|
41
|
+
let set = this.handlers.get(channel);
|
|
42
|
+
if (!set) {
|
|
43
|
+
set = new Set();
|
|
44
|
+
this.handlers.set(channel, set);
|
|
45
|
+
await this.client.subscribe(channel);
|
|
46
|
+
}
|
|
47
|
+
set.add(handler);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async unsubscribe(channel: string, handler: (message: Buffer | string) => void): Promise<void> {
|
|
51
|
+
const set = this.handlers.get(channel);
|
|
52
|
+
if (!set) return;
|
|
53
|
+
set.delete(handler);
|
|
54
|
+
if (set.size === 0) {
|
|
55
|
+
this.handlers.delete(channel);
|
|
56
|
+
await this.client.unsubscribe(channel);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|