@camstack/server 0.1.6 → 0.1.8
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 +3 -3
- package/src/__tests__/addon-upload.spec.ts +58 -0
- package/src/__tests__/bulk-update-coordinator.spec.ts +286 -0
- package/src/__tests__/cap-ownership-authority.spec.ts +400 -0
- package/src/__tests__/cap-providers/cap-providers-location-import.spec.ts +186 -0
- package/src/__tests__/cap-providers/integrations-delete-cascade.spec.ts +243 -0
- package/src/__tests__/cap-providers-bulk-update.spec.ts +388 -0
- package/src/__tests__/cap-route-adapter.spec.ts +289 -0
- package/src/__tests__/cap-routers/broker-routing.router.spec.ts +169 -0
- package/src/__tests__/cap-routers/cap-route-error-formatter.spec.ts +123 -0
- package/src/__tests__/cap-routers/capabilities-node.spec.ts +55 -0
- package/src/__tests__/cap-routers/device-link-overlay.spec.ts +132 -0
- package/src/__tests__/dev-bootstrap-shm-ring.spec.ts +30 -0
- package/src/__tests__/device-settings-contribution-dispatch.spec.ts +249 -0
- package/src/__tests__/framework-installer-defer-restart.spec.ts +165 -0
- package/src/__tests__/moleculer/uds-readiness.spec.ts +143 -0
- package/src/__tests__/moleculer/uds-topology.spec.ts +390 -0
- package/src/__tests__/moleculer/uds-unowned-call.spec.ts +329 -0
- package/src/__tests__/moleculer-register-node-idempotency.spec.ts +39 -4
- package/src/__tests__/native-cap-route.spec.ts +404 -0
- package/src/__tests__/oauth2-account-linking.spec.ts +85 -0
- package/src/__tests__/uds-addon-call-wiring.spec.ts +237 -0
- package/src/__tests__/uds-log-ingest.spec.ts +183 -0
- package/src/api/addon-upload.ts +27 -1
- package/src/api/capabilities.router.ts +1 -1
- package/src/api/core/__tests__/integration-markers.spec.ts +10 -0
- package/src/api/core/bulk-update-coordinator.ts +302 -0
- package/src/api/core/cap-providers.ts +211 -9
- package/src/api/core/capabilities.router.ts +26 -3
- package/src/api/core/logs.router.ts +4 -0
- package/src/api/oauth2/oauth2-routes.ts +5 -1
- package/src/api/trpc/__tests__/client-ip.spec.ts +146 -0
- package/src/api/trpc/__tests__/webrtc-session-ua-enrich.spec.ts +128 -0
- package/src/api/trpc/cap-mount-helpers.ts +12 -1
- package/src/api/trpc/cap-route-error-formatter.ts +163 -0
- package/src/api/trpc/client-ip.ts +147 -0
- package/src/api/trpc/generated-cap-mounts.ts +299 -8
- package/src/api/trpc/generated-cap-routers.ts +2384 -302
- package/src/api/trpc/trpc.middleware.ts +5 -1
- package/src/api/trpc/trpc.router.ts +84 -3
- package/src/boot/__tests__/integration-id-backfill.spec.ts +116 -0
- package/src/boot/integration-id-backfill.ts +109 -0
- package/src/core/addon/__tests__/addon-row-manifest.spec.ts +62 -0
- package/src/core/addon/addon-call-gateway.ts +157 -0
- package/src/core/addon/addon-package.service.ts +9 -0
- package/src/core/addon/addon-registry.service.ts +453 -107
- package/src/core/addon/addon-row-manifest.ts +29 -0
- package/src/core/addon/addon-settings-provider.ts +40 -116
- package/src/core/capability/capability.service.ts +9 -0
- package/src/core/logging/logging.service.ts +7 -2
- package/src/core/moleculer/cap-call-fn.spec.ts +166 -0
- package/src/core/moleculer/cap-call-fn.ts +103 -0
- package/src/core/moleculer/cap-route-authority.ts +182 -0
- package/src/core/moleculer/moleculer.service.ts +408 -36
- package/src/core/network/network-quality.service.spec.ts +2 -1
- package/src/main.ts +137 -12
- package/src/core/storage/settings-store.spec.ts +0 -213
- package/src/core/storage/settings-store.ts +0 -2
- package/src/core/storage/sql-schema.spec.ts +0 -140
- package/src/core/storage/sql-schema.ts +0 -3
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-member-access -- pre-existing lint debt across this 2200-line orchestration class. The flagged sites (StorageService.setLocationManager / setSettingsBackend, LoggingService.addDestination, RouteRegistry, etc.) are typed as `unknown` by their owning services to break circular construction-order dependencies; runtime contracts are validated structurally. Tracked separately; do not amend in unrelated edits. */
|
|
2
2
|
import * as os from "node:os";
|
|
3
3
|
import { ConfigService } from "../config/config.service";
|
|
4
|
+
import { overlayDeclaration } from "./addon-row-manifest";
|
|
4
5
|
import { LoggingService } from "../logging/logging.service";
|
|
5
6
|
import { EventBusService } from "../events/event-bus.service";
|
|
6
7
|
import { StorageService } from "../storage/storage.service";
|
|
@@ -60,12 +61,13 @@ import type {
|
|
|
60
61
|
IStorageProvider as INewStorageProvider,
|
|
61
62
|
ISettingsBackend,
|
|
62
63
|
} from "@camstack/types";
|
|
63
|
-
import { AddonRouteRegistry } from "@camstack/core";
|
|
64
|
+
import { AddonRouteRegistry, DataPlaneRegistry } from "@camstack/core";
|
|
64
65
|
import { randomUUID } from "node:crypto";
|
|
65
66
|
import * as path from "node:path";
|
|
66
67
|
import * as fs from "node:fs";
|
|
67
68
|
import { pathToFileURL } from "node:url";
|
|
68
69
|
import { createAddonSettingsProvider } from "./addon-settings-provider.js";
|
|
70
|
+
import { AddonCallGateway } from "./addon-call-gateway.js";
|
|
69
71
|
import { addonSettingsCapability } from "@camstack/types";
|
|
70
72
|
import { DisposerChain } from "@camstack/types";
|
|
71
73
|
|
|
@@ -111,6 +113,110 @@ function isSettingsStore(
|
|
|
111
113
|
return true;
|
|
112
114
|
}
|
|
113
115
|
|
|
116
|
+
/**
|
|
117
|
+
* The bridge-dispatch contract on an `addon-routes` provider. The operator-
|
|
118
|
+
* facing `IAddonRouteProvider` interface declares only `id` + `getRoutes`; the
|
|
119
|
+
* UDS proxy (and `buildAddonRouteProvider`) also expose `invoke`, which the
|
|
120
|
+
* forked-routes bridge calls per request. Narrowed via `isAddonRoutesInvoker`.
|
|
121
|
+
*/
|
|
122
|
+
interface AddonRoutesInvoker {
|
|
123
|
+
invoke(
|
|
124
|
+
input: import("@camstack/types").AddonRouteInvokeRequest,
|
|
125
|
+
): Promise<import("@camstack/types").AddonRouteReplyEnvelope>;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Structural guard: true when an `addon-routes` provider also exposes `invoke`. */
|
|
129
|
+
function isAddonRoutesInvoker<T extends object>(
|
|
130
|
+
provider: T,
|
|
131
|
+
): provider is T & AddonRoutesInvoker {
|
|
132
|
+
return typeof Reflect.get(provider, "invoke") === "function";
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const ROUTE_METHODS: readonly import("@camstack/types").IAddonHttpRoute["method"][] = [
|
|
136
|
+
"GET",
|
|
137
|
+
"POST",
|
|
138
|
+
"PUT",
|
|
139
|
+
"DELETE",
|
|
140
|
+
];
|
|
141
|
+
const ROUTE_ACCESS: readonly import("@camstack/types").RouteAccess[] = [
|
|
142
|
+
"public",
|
|
143
|
+
"authenticated",
|
|
144
|
+
"admin",
|
|
145
|
+
];
|
|
146
|
+
|
|
147
|
+
function asRouteMethod(value: string): import("@camstack/types").IAddonHttpRoute["method"] {
|
|
148
|
+
const upper = value.toUpperCase();
|
|
149
|
+
for (const m of ROUTE_METHODS) if (m === upper) return m;
|
|
150
|
+
throw new Error(`addon-routes: unsupported HTTP method "${value}"`);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function asRouteAccess(value: unknown): import("@camstack/types").RouteAccess {
|
|
154
|
+
if (typeof value === "string") {
|
|
155
|
+
for (const a of ROUTE_ACCESS) if (a === value) return a;
|
|
156
|
+
}
|
|
157
|
+
// Default to the most restrictive sensible default the original mount used.
|
|
158
|
+
return "public";
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Parse the wire-boundary `unknown` returned by `callAddonOnChild(...,
|
|
163
|
+
* {target:'routes'})` into typed route descriptors. The child sends route
|
|
164
|
+
* descriptors WITHOUT handlers (functions don't cross MsgPack); only
|
|
165
|
+
* `method`/`path`/`access`/`description` are present.
|
|
166
|
+
*/
|
|
167
|
+
interface ParsedRouteDescriptor {
|
|
168
|
+
readonly method: import("@camstack/types").IAddonHttpRoute["method"];
|
|
169
|
+
readonly path: string;
|
|
170
|
+
readonly access: import("@camstack/types").RouteAccess;
|
|
171
|
+
readonly description?: string;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function parseSerializableRouteDescriptors(raw: unknown): readonly ParsedRouteDescriptor[] {
|
|
175
|
+
if (!Array.isArray(raw)) {
|
|
176
|
+
throw new Error("addon-routes: child returned a non-array route descriptor set");
|
|
177
|
+
}
|
|
178
|
+
return raw.map((entry: unknown): ParsedRouteDescriptor => {
|
|
179
|
+
if (entry === null || typeof entry !== "object") {
|
|
180
|
+
throw new Error("addon-routes: route descriptor is not an object");
|
|
181
|
+
}
|
|
182
|
+
const method = Reflect.get(entry, "method");
|
|
183
|
+
const path = Reflect.get(entry, "path");
|
|
184
|
+
if (typeof method !== "string" || typeof path !== "string") {
|
|
185
|
+
throw new Error("addon-routes: route descriptor missing method/path");
|
|
186
|
+
}
|
|
187
|
+
const description = Reflect.get(entry, "description");
|
|
188
|
+
return {
|
|
189
|
+
method: asRouteMethod(method),
|
|
190
|
+
path,
|
|
191
|
+
access: asRouteAccess(Reflect.get(entry, "access")),
|
|
192
|
+
...(typeof description === "string" ? { description } : {}),
|
|
193
|
+
};
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Parse the wire-boundary `unknown` from `callForked(..., {target:'data-planes'})`
|
|
199
|
+
* into typed data-plane endpoint descriptors (`prefix/access/baseUrl/secret`).
|
|
200
|
+
* Pure data — the addon's `ctx.dataPlane` facility produced them.
|
|
201
|
+
*/
|
|
202
|
+
function parseDataPlaneEndpoints(raw: unknown): readonly import("@camstack/types").AddonDataPlaneEndpoint[] {
|
|
203
|
+
if (!Array.isArray(raw)) {
|
|
204
|
+
throw new Error("data-planes: child returned a non-array endpoint set");
|
|
205
|
+
}
|
|
206
|
+
return raw.map((entry: unknown): import("@camstack/types").AddonDataPlaneEndpoint => {
|
|
207
|
+
if (entry === null || typeof entry !== "object") {
|
|
208
|
+
throw new Error("data-planes: endpoint descriptor is not an object");
|
|
209
|
+
}
|
|
210
|
+
const prefix = Reflect.get(entry, "prefix");
|
|
211
|
+
const baseUrl = Reflect.get(entry, "baseUrl");
|
|
212
|
+
const secret = Reflect.get(entry, "secret");
|
|
213
|
+
if (typeof prefix !== "string" || typeof baseUrl !== "string" || typeof secret !== "string") {
|
|
214
|
+
throw new Error("data-planes: endpoint descriptor missing prefix/baseUrl/secret");
|
|
215
|
+
}
|
|
216
|
+
return { prefix, access: asRouteAccess(Reflect.get(entry, "access")), baseUrl, secret };
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
|
|
114
220
|
interface AddonEntry {
|
|
115
221
|
readonly addon: ICamstackAddon;
|
|
116
222
|
initialized: boolean;
|
|
@@ -140,6 +246,8 @@ interface AddonEntry {
|
|
|
140
246
|
export class AddonRegistryService {
|
|
141
247
|
private readonly addonEntries = new Map<string, AddonEntry>();
|
|
142
248
|
private readonly capabilityRegistry: CapabilityRegistry;
|
|
249
|
+
/** Single router for addon-level calls (routes / custom / settings). */
|
|
250
|
+
private addonCallGateway!: AddonCallGateway;
|
|
143
251
|
// Task 7.1: hub-wide registry of addon custom actions. Populated on
|
|
144
252
|
// each addon's initialize() (in-process path); Task 7.2 will dispatch
|
|
145
253
|
// through this from the `api.addons.custom` tRPC procedure.
|
|
@@ -156,6 +264,7 @@ export class AddonRegistryService {
|
|
|
156
264
|
private addonLoader!: AddonLoader;
|
|
157
265
|
private healthMonitor!: AddonHealthMonitor;
|
|
158
266
|
private addonRouteRegistry: AddonRouteRegistry | null = null;
|
|
267
|
+
private dataPlaneRegistry: DataPlaneRegistry | null = null;
|
|
159
268
|
|
|
160
269
|
// Broker-routed AddonApi proxy — every addon's `ctx.api` resolves
|
|
161
270
|
// to this. Calls go through `broker.call('${addonId}.${capName}.${method}')`
|
|
@@ -272,6 +381,24 @@ export class AddonRegistryService {
|
|
|
272
381
|
},
|
|
273
382
|
);
|
|
274
383
|
|
|
384
|
+
this.capabilityRegistry.setNodeConfigReader(
|
|
385
|
+
(capability: string, nodeId: string): string | undefined => {
|
|
386
|
+
try {
|
|
387
|
+
return (
|
|
388
|
+
this.configService.get<string>(
|
|
389
|
+
`capabilities.singletonNode.${capability}.${nodeId}`,
|
|
390
|
+
) ?? undefined
|
|
391
|
+
);
|
|
392
|
+
} catch (err) {
|
|
393
|
+
this.logger.debug(
|
|
394
|
+
'settings-store not wired yet during early boot',
|
|
395
|
+
{ meta: { capability, nodeId, error: errMsg(err) } },
|
|
396
|
+
);
|
|
397
|
+
return undefined;
|
|
398
|
+
}
|
|
399
|
+
},
|
|
400
|
+
);
|
|
401
|
+
|
|
275
402
|
// Collection-provider enable/disable persistence. Reads the same
|
|
276
403
|
// `capabilities.collection.<cap>` ConfigService key the `capabilities`
|
|
277
404
|
// router writes (`JSON.stringify({ disabled: string[] })`), so a
|
|
@@ -341,29 +468,33 @@ export class AddonRegistryService {
|
|
|
341
468
|
// former `$addonHost` Moleculer service. The provider resolves
|
|
342
469
|
// addonId → local addon instance (hub) or remote Moleculer call.
|
|
343
470
|
this.capabilityRegistry.declareCapability(addonSettingsCapability);
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
471
|
+
// The SINGLE router for addon-level calls (routes / custom / settings).
|
|
472
|
+
// It classifies in-process | hub-local-child (UDS) | remote-agent
|
|
473
|
+
// (Moleculer) in ONE place — `resolveNode` reports only the BASE node
|
|
474
|
+
// (the hub for every hub-local addon, forked or not); the gateway's
|
|
475
|
+
// `isChildKnown` distinguishes a forked UDS child from an in-process
|
|
476
|
+
// builtin. This is the fix for forked-addon settings: they were forced
|
|
477
|
+
// down a dead `<addonId>.settings.*` Moleculer path because the old
|
|
478
|
+
// `resolveNode` marked them non-local; now they route over UDS like
|
|
479
|
+
// their routes + custom-actions already do.
|
|
480
|
+
this.addonCallGateway = new AddonCallGateway({
|
|
481
|
+
hubNodeId: this.broker.nodeID,
|
|
349
482
|
resolveNode: (addonId) => {
|
|
350
|
-
// Group-runner addons live in a child Moleculer node (e.g.
|
|
351
|
-
// `hub/pipeline`). The hub-side `entry.addon` instance never
|
|
352
|
-
// runs `initialize()` and has no `_ctx.settings`, so calling
|
|
353
|
-
// `addon.getGlobalSettings()` on it returns defaults only —
|
|
354
|
-
// the real store lives in the worker. Route those via the
|
|
355
|
-
// remote path so the worker's `<addonId>.settings.*` handler
|
|
356
|
-
// serves the canonical response.
|
|
357
483
|
const entry = this.addonEntries.get(addonId);
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
}
|
|
362
|
-
return "hub";
|
|
484
|
+
// Every addon in `addonEntries` is hub-local — report the hub node;
|
|
485
|
+
// forked-vs-in-process is decided by `isChildKnown` in the gateway.
|
|
486
|
+
return entry ? this.broker.nodeID : "hub";
|
|
363
487
|
},
|
|
488
|
+
getChildRegistry: () => this.moleculer.childRegistry,
|
|
364
489
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- moleculer types unresolvable; see `broker` getter docstring
|
|
365
490
|
broker: this.moleculer.broker,
|
|
366
|
-
|
|
491
|
+
});
|
|
492
|
+
const settingsProvider = createAddonSettingsProvider({
|
|
493
|
+
getAddon: (addonId) => {
|
|
494
|
+
const entry = this.addonEntries.get(addonId);
|
|
495
|
+
return entry?.addon ?? null;
|
|
496
|
+
},
|
|
497
|
+
gateway: this.addonCallGateway,
|
|
367
498
|
});
|
|
368
499
|
this.capabilityRegistry.registerProvider(
|
|
369
500
|
"addon-settings",
|
|
@@ -487,38 +618,97 @@ export class AddonRegistryService {
|
|
|
487
618
|
|
|
488
619
|
// Native-cap cross-process bridge: when a hub consumer resolves a native
|
|
489
620
|
// provider for a device whose IDevice lives in a forked worker, we
|
|
490
|
-
// return a
|
|
491
|
-
// `<addonId>.native-provider.<capName>.<method>` via standard Moleculer RPC.
|
|
621
|
+
// return a proxy that routes calls to the correct transport.
|
|
492
622
|
//
|
|
493
|
-
//
|
|
494
|
-
//
|
|
495
|
-
//
|
|
496
|
-
//
|
|
497
|
-
//
|
|
498
|
-
//
|
|
623
|
+
// G2 — single ownership authority: the CapRouteResolver is consulted FIRST
|
|
624
|
+
// for hub-local native caps. If the resolver's snapshot identifies a
|
|
625
|
+
// hub-local-uds route for (capName, deviceId), we build the proxy directly —
|
|
626
|
+
// no `resolveNativeCapOwnerSync` consult needed for this branch. The resolver's
|
|
627
|
+
// `hubLocalChildProvides(capName, deviceId)` is the canonical hub-local authority
|
|
628
|
+
// (fed by the same LocalChildRegistry that owns UDS dispatch), so the old
|
|
629
|
+
// `owner.nodeId.startsWith(hubNodeId + '/')` gate is replaced by the resolver's
|
|
630
|
+
// own verdict.
|
|
499
631
|
//
|
|
500
|
-
// `resolveNativeCapOwnerSync`
|
|
501
|
-
//
|
|
502
|
-
//
|
|
503
|
-
//
|
|
504
|
-
//
|
|
505
|
-
//
|
|
506
|
-
//
|
|
632
|
+
// `resolveNativeCapOwnerSync` is only consulted for the REMOTE branch: when the
|
|
633
|
+
// resolver throws no-provider for the hub node (no hub-local-uds child owns the cap),
|
|
634
|
+
// the fallback queries `resolveNativeCapOwnerSync` to find a remote owner. That
|
|
635
|
+
// method carries data (push-fed `remoteNativeCaps` from `DeviceBindingsChanged`
|
|
636
|
+
// events) that the resolver's `NodeCapAuthority` doesn't see, so it stays as the
|
|
637
|
+
// remote-branch source. If `resolveNativeCapOwnerSync` also returns null, the cap
|
|
638
|
+
// genuinely has no cross-process provider and the fallback returns null — wrappers
|
|
639
|
+
// cleanly fall through to their own secondary strategy.
|
|
507
640
|
{
|
|
508
641
|
const { buildNativeCapProxy } = await import("@camstack/kernel");
|
|
509
642
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- moleculer types unresolvable; see `broker` getter docstring
|
|
510
643
|
const broker = this.moleculer.broker;
|
|
511
644
|
this.capabilityRegistry.setNativeFallback(
|
|
512
645
|
(capName: string, deviceId: number): unknown | null => {
|
|
646
|
+
const resolver = this.moleculer.capRouteResolver;
|
|
647
|
+
const hubNodeId = this.moleculer.nodeId;
|
|
648
|
+
|
|
649
|
+
// 1. Hub-local path: the resolver's hub-local-child authority is the
|
|
650
|
+
// single source of truth for whether a forked hub-local UDS child
|
|
651
|
+
// owns (capName, deviceId) — via hubLocalChildProvides +
|
|
652
|
+
// getHubLocalChildId (both deviceId-aware, M1).
|
|
653
|
+
//
|
|
654
|
+
// We deliberately use `resolveHubLocalUdsRoute`, NOT
|
|
655
|
+
// `resolveCapRoute`: this fallback's contract is to reach the
|
|
656
|
+
// NATIVE provider in the forked vendor child. `resolveCapRoute`
|
|
657
|
+
// gives Priority 1 to `hub-in-process`, so a cap that ALSO has an
|
|
658
|
+
// in-hub provider for the same name (a wrapper — today `snapshot`)
|
|
659
|
+
// would classify as `hub-in-process` (the wrapper) and never reach
|
|
660
|
+
// the hub-local-uds native. That made the wrapper's
|
|
661
|
+
// `getNativeProvider` return null and silently fall through to its
|
|
662
|
+
// secondary strategy (e.g. snapshot's ffmpeg frame grab), so the
|
|
663
|
+
// vendor native snapshot was never invoked. `resolveHubLocalUdsRoute`
|
|
664
|
+
// skips the in-process branch and returns the native child route.
|
|
665
|
+
if (resolver !== null) {
|
|
666
|
+
const route = resolver.resolveHubLocalUdsRoute(capName, deviceId)
|
|
667
|
+
if (route !== null) {
|
|
668
|
+
const childId = route.childId
|
|
669
|
+
const target: Record<string, (input: unknown) => Promise<unknown>> = {}
|
|
670
|
+
return new Proxy(target, {
|
|
671
|
+
get(_target, property): ((input: unknown) => Promise<unknown>) | undefined {
|
|
672
|
+
if (typeof property !== 'string') return undefined
|
|
673
|
+
return (input: unknown): Promise<unknown> => {
|
|
674
|
+
const mergedInput =
|
|
675
|
+
typeof input === 'object' && input !== null
|
|
676
|
+
? { ...input, deviceId }
|
|
677
|
+
: { deviceId }
|
|
678
|
+
// Re-resolve at call time so a child that respawned under a
|
|
679
|
+
// new runner id is still reachable (route is cheap to build).
|
|
680
|
+
const r = resolver.resolveHubLocalUdsRoute(capName, deviceId)
|
|
681
|
+
?? { kind: 'hub-local-uds' as const, capName, childId }
|
|
682
|
+
return resolver.dispatch(r, property, mergedInput)
|
|
683
|
+
}
|
|
684
|
+
},
|
|
685
|
+
})
|
|
686
|
+
}
|
|
687
|
+
// No hub-local child owns (capName, deviceId). Fall through to the
|
|
688
|
+
// remote branch — resolveNativeCapOwnerSync is still consulted there
|
|
689
|
+
// and may find a remote owner for this (capName, deviceId).
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
// 2. Remote path: resolver has no hub-local route for this (capName, deviceId).
|
|
693
|
+
// Consult resolveNativeCapOwnerSync — it includes push-fed remoteNativeCaps
|
|
694
|
+
// (DeviceBindingsChanged events from forked workers) that the resolver's
|
|
695
|
+
// NodeCapAuthority (backed by HubNodeRegistry) doesn't carry.
|
|
513
696
|
const dm = this.capabilityRegistry.getSingleton<{
|
|
514
697
|
resolveNativeCapOwnerSync?: (
|
|
515
698
|
capName: string,
|
|
516
699
|
deviceId: number,
|
|
517
700
|
) => { addonId: string; nodeId: string } | null;
|
|
518
701
|
}>("device-manager");
|
|
519
|
-
const owner =
|
|
520
|
-
dm?.resolveNativeCapOwnerSync?.(capName, deviceId) ?? null;
|
|
702
|
+
const owner = dm?.resolveNativeCapOwnerSync?.(capName, deviceId) ?? null;
|
|
521
703
|
if (!owner) return null;
|
|
704
|
+
|
|
705
|
+
// Guard: skip hub-local owners here — they were already handled (or skipped
|
|
706
|
+
// because the resolver was null / not initialised yet). Returning null avoids
|
|
707
|
+
// building a Moleculer proxy that points at a UDS-only child.
|
|
708
|
+
if (owner.nodeId.startsWith(`${hubNodeId}/`)) return null;
|
|
709
|
+
|
|
710
|
+
// Remote native cap → Moleculer with native-provider infix.
|
|
711
|
+
// buildNativeCapProxy uses the action `${addonId}.native-provider.${cap}.${method}`.
|
|
522
712
|
return buildNativeCapProxy(broker, owner.addonId, capName, deviceId);
|
|
523
713
|
},
|
|
524
714
|
);
|
|
@@ -864,6 +1054,41 @@ export class AddonRegistryService {
|
|
|
864
1054
|
this.addonRouteRegistry = registry;
|
|
865
1055
|
}
|
|
866
1056
|
|
|
1057
|
+
/** Set the DataPlaneRegistry the hub's `/addon/:id/*` dispatch reverse-proxies
|
|
1058
|
+
* against (addon HTTP data-planes). */
|
|
1059
|
+
setDataPlaneRegistry(registry: DataPlaneRegistry): void {
|
|
1060
|
+
this.dataPlaneRegistry = registry;
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
/**
|
|
1064
|
+
* Pull a forked addon's HTTP data-plane endpoints over UDS and register them so
|
|
1065
|
+
* the hub can reverse-proxy `/addon/<addonId>/<prefix>/*` to the addon's own
|
|
1066
|
+
* listener. Idempotent (replace-all); an addon with no data-plane registers an
|
|
1067
|
+
* empty set (cleared). Called off the capabilities-changed signal — a data-plane
|
|
1068
|
+
* isn't a cap, but the child is UDS-reachable and has served its endpoints by
|
|
1069
|
+
* the time any of its caps register.
|
|
1070
|
+
*/
|
|
1071
|
+
private async mountAddonDataPlanes(addonId: string): Promise<void> {
|
|
1072
|
+
const registry = this.dataPlaneRegistry;
|
|
1073
|
+
if (!registry) return;
|
|
1074
|
+
const entry = this.addonEntries.get(addonId);
|
|
1075
|
+
const childRegistry = this.moleculer.childRegistry;
|
|
1076
|
+
// Forked addons only for now — co-located builtins would publish into the
|
|
1077
|
+
// registry directly via a hub-side sink (deferred; no builtin serves a
|
|
1078
|
+
// data-plane yet).
|
|
1079
|
+
if (!entry || !this.isForkedAddonEntry(entry)) return;
|
|
1080
|
+
if (childRegistry === null || !childRegistry.isChildKnown(addonId)) return;
|
|
1081
|
+
|
|
1082
|
+
const raw = await this.addonCallGateway.callForked(addonId, { target: "data-planes" });
|
|
1083
|
+
const endpoints = parseDataPlaneEndpoints(raw);
|
|
1084
|
+
registry.registerAddon(addonId, endpoints);
|
|
1085
|
+
if (endpoints.length > 0) {
|
|
1086
|
+
this.logger.info("Addon data-planes mounted (reverse-proxy)", {
|
|
1087
|
+
meta: { phase: "v2", addonId, prefixes: endpoints.map((e) => e.prefix) },
|
|
1088
|
+
});
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
|
|
867
1092
|
/**
|
|
868
1093
|
* Called after app.init() when the tRPC router is available.
|
|
869
1094
|
* No-op now: addon `ctx.api` resolves to a broker-routed proxy, so
|
|
@@ -1118,8 +1343,8 @@ export class AddonRegistryService {
|
|
|
1118
1343
|
// vs in-process being the transport, exactly like cap methods.
|
|
1119
1344
|
await this.registerForkedAddonCustomActions(
|
|
1120
1345
|
id,
|
|
1121
|
-
// `
|
|
1122
|
-
resolveRunnerId(entry.declaration
|
|
1346
|
+
// `isForkedAddonEntry` narrowed `entry.declaration` to non-null.
|
|
1347
|
+
resolveRunnerId(entry.declaration, id),
|
|
1123
1348
|
);
|
|
1124
1349
|
|
|
1125
1350
|
entry.initialized = true;
|
|
@@ -1153,7 +1378,9 @@ export class AddonRegistryService {
|
|
|
1153
1378
|
// ProviderRegistration.kind/defaultActive are deprecated hints — if an
|
|
1154
1379
|
// addon still sets them and they disagree, warn (the cap def wins).
|
|
1155
1380
|
const kindDrift = describeProviderKindDrift(capName, reg.capability, {
|
|
1381
|
+
// eslint-disable-next-line @typescript-eslint/no-deprecated -- this IS the drift detector; it reads the deprecated hint on purpose
|
|
1156
1382
|
kind: reg.kind,
|
|
1383
|
+
// eslint-disable-next-line @typescript-eslint/no-deprecated -- this IS the drift detector; it reads the deprecated hint on purpose
|
|
1157
1384
|
defaultActive: reg.defaultActive,
|
|
1158
1385
|
});
|
|
1159
1386
|
if (kindDrift) {
|
|
@@ -1650,18 +1877,28 @@ export class AddonRegistryService {
|
|
|
1650
1877
|
}
|
|
1651
1878
|
|
|
1652
1879
|
/**
|
|
1653
|
-
* True when the entry boots in its own forked runner
|
|
1654
|
-
*
|
|
1655
|
-
*
|
|
1656
|
-
*
|
|
1657
|
-
*
|
|
1880
|
+
* True when the entry boots in its own forked runner. This MUST mirror
|
|
1881
|
+
* the fork authority `buildAddonGroupPlan`, which spawns a runner for
|
|
1882
|
+
* every addon that ships an on-disk `addonDir` + a manifest
|
|
1883
|
+
* `declaration`, is NOT a `@camstack/core` builtin, and is NOT
|
|
1884
|
+
* `agent-only`. The `execution` block is OPTIONAL — an addon with no
|
|
1885
|
+
* `execution` declaration still forks on its own dedicated runner
|
|
1886
|
+
* (`resolveRunnerId` falls back to the addon id). Requiring `execution`
|
|
1887
|
+
* here previously diverged from `buildAddonGroupPlan`: an addon like
|
|
1888
|
+
* `auth-oidc` (no `execution` block) was forked at boot yet classified
|
|
1889
|
+
* in-process by this predicate, so its route-mount took the co-located
|
|
1890
|
+
* path and received the async UDS cap proxy whose `getRoutes()` returns
|
|
1891
|
+
* a Promise (→ `getRoutes(...).map is not a function`). Only
|
|
1892
|
+
* `@camstack/core` builtins boot in-process on the hub. The type
|
|
1893
|
+
* predicate narrows both `addonDir` and `declaration` for callers.
|
|
1658
1894
|
*/
|
|
1659
1895
|
private isForkedAddonEntry(
|
|
1660
1896
|
entry: AddonEntry,
|
|
1661
|
-
): entry is AddonEntry & { addonDir: string } {
|
|
1897
|
+
): entry is AddonEntry & { addonDir: string; declaration: AddonDeclaration } {
|
|
1662
1898
|
return !!(
|
|
1663
|
-
entry.declaration
|
|
1899
|
+
entry.declaration &&
|
|
1664
1900
|
entry.addonDir &&
|
|
1901
|
+
entry.packageName !== "@camstack/core" &&
|
|
1665
1902
|
resolveAddonPlacement(entry.declaration) !== 'agent-only'
|
|
1666
1903
|
);
|
|
1667
1904
|
}
|
|
@@ -1735,7 +1972,14 @@ export class AddonRegistryService {
|
|
|
1735
1972
|
}
|
|
1736
1973
|
return {
|
|
1737
1974
|
manifest: {
|
|
1738
|
-
|
|
1975
|
+
// Overlay the fresh on-disk declaration (refreshed by
|
|
1976
|
+
// `loadNewAddons` on every `camstack deploy`) onto the live
|
|
1977
|
+
// instance manifest, so a redeployed addon's new capabilities /
|
|
1978
|
+
// brokerKind surface here without a full backend restart. See
|
|
1979
|
+
// `overlayDeclaration` — this is what keeps Home Assistant
|
|
1980
|
+
// (post broker rework: broker + device-adoption) visible in the
|
|
1981
|
+
// "+ New Integration" picker.
|
|
1982
|
+
...overlayDeclaration(entry.addon.manifest!, entry.declaration),
|
|
1739
1983
|
packageName: entry.packageName,
|
|
1740
1984
|
packageVersion: entry.packageVersion,
|
|
1741
1985
|
packageDisplayName: entry.packageDisplayName,
|
|
@@ -2275,6 +2519,16 @@ export class AddonRegistryService {
|
|
|
2275
2519
|
default:
|
|
2276
2520
|
break;
|
|
2277
2521
|
}
|
|
2522
|
+
|
|
2523
|
+
// HTTP data-planes aren't capabilities, so no `case` fires for them —
|
|
2524
|
+
// pull them off ANY cap-changed signal for this (forked) addon. Cheap +
|
|
2525
|
+
// idempotent (replace-all), and re-pulls the fresh baseUrl/secret after a
|
|
2526
|
+
// restart (the child re-handshakes → caps re-register → this re-fires).
|
|
2527
|
+
void this.mountAddonDataPlanes(addonId).catch((err: unknown) => {
|
|
2528
|
+
this.logger.error('Failed to mount addon data-planes', {
|
|
2529
|
+
meta: { phase: 'v2', addonId, error: errMsg(err) },
|
|
2530
|
+
})
|
|
2531
|
+
})
|
|
2278
2532
|
},
|
|
2279
2533
|
);
|
|
2280
2534
|
}
|
|
@@ -2282,53 +2536,118 @@ export class AddonRegistryService {
|
|
|
2282
2536
|
/**
|
|
2283
2537
|
* Mount the `addon-routes` provider for `addonId` into the
|
|
2284
2538
|
* `AddonRouteRegistry`. Handles both co-located (in-process) and
|
|
2285
|
-
* forked
|
|
2539
|
+
* forked addons:
|
|
2286
2540
|
*
|
|
2287
|
-
* - For a
|
|
2288
|
-
*
|
|
2289
|
-
*
|
|
2290
|
-
*
|
|
2291
|
-
*
|
|
2292
|
-
*
|
|
2293
|
-
*
|
|
2294
|
-
*
|
|
2541
|
+
* - For a hub-local FORKED addon the route handlers live in the
|
|
2542
|
+
* child process and cannot cross the UDS wire (MsgPack can't encode
|
|
2543
|
+
* a function). We fetch the handler-stripped route descriptors over
|
|
2544
|
+
* UDS via `LocalChildRegistry.callAddonOnChild(addonId,
|
|
2545
|
+
* {target:'routes'})` (F3 — replaces the removed per-addon Moleculer
|
|
2546
|
+
* `getRoutes` action), synthesize bridge handlers that dispatch
|
|
2547
|
+
* through the `addon-routes` cap's `invoke(...)` method (a normal
|
|
2548
|
+
* serializable cap call over UDS), and translate the captured
|
|
2549
|
+
* envelope back onto the Fastify reply.
|
|
2550
|
+
* - For a co-located (hub-resident) addon `getRoutes()` resolves to the
|
|
2551
|
+
* live route list with real handlers, so we register the provider
|
|
2552
|
+
* directly.
|
|
2295
2553
|
*/
|
|
2296
2554
|
private async mountAddonRoutes(addonId: string): Promise<void> {
|
|
2297
|
-
if (!this.capabilityRegistry) return;
|
|
2555
|
+
if (!this.capabilityRegistry || !this.addonRouteRegistry) return;
|
|
2556
|
+
const addonRouteRegistry = this.addonRouteRegistry;
|
|
2298
2557
|
const routeProvider = this.capabilityRegistry.getProviderByAddon(
|
|
2299
2558
|
"addon-routes",
|
|
2300
2559
|
addonId,
|
|
2301
2560
|
);
|
|
2302
|
-
if (!routeProvider
|
|
2303
|
-
|
|
2304
|
-
//
|
|
2305
|
-
//
|
|
2306
|
-
// `
|
|
2307
|
-
//
|
|
2308
|
-
//
|
|
2309
|
-
|
|
2310
|
-
|
|
2311
|
-
|
|
2312
|
-
|
|
2313
|
-
|
|
2314
|
-
|
|
2315
|
-
|
|
2316
|
-
|
|
2561
|
+
if (!routeProvider) return;
|
|
2562
|
+
|
|
2563
|
+
// ── Forked addon: fetch handler-stripped routes over UDS (F3) ──────
|
|
2564
|
+
// EVERY non-`@camstack/core` addon forks (the fork authority is
|
|
2565
|
+
// `buildAddonGroupPlan`, which does NOT require an `execution` block — an
|
|
2566
|
+
// addon like `auth-oidc` with no `execution` still runs in its own
|
|
2567
|
+
// runner). `isForkedAddonEntry` now mirrors that rule. For a forked addon
|
|
2568
|
+
// the `getProviderByAddon('addon-routes', …)` result is the async UDS cap
|
|
2569
|
+
// proxy whose `getRoutes()` returns a Promise — registering it directly
|
|
2570
|
+
// would crash `AddonRouteRegistry.registerRoutes` (`getRoutes(...).map is
|
|
2571
|
+
// not a function`), and dispatching the proxy's `getRoutes` cap method
|
|
2572
|
+
// over UDS would try to MsgPack-serialize the child's live handler
|
|
2573
|
+
// functions (→ "Unrecognized object: [object AsyncFunction]"). Instead we
|
|
2574
|
+
// fetch handler-STRIPPED descriptors via `callAddonOnChild(target:
|
|
2575
|
+
// 'routes')` and bridge each through the `invoke` cap method. The UDS
|
|
2576
|
+
// childId for a hub-local single-addon runner equals the addonId
|
|
2577
|
+
// (`resolveRunnerId` — no shipped addon declares a group). Gate on
|
|
2578
|
+
// `isChildKnown` (the child completed its UDS handshake) rather than
|
|
2579
|
+
// `childProvides('addon-routes')`: the `addon-call` route fetch works as
|
|
2580
|
+
// long as the child is connected, and the child is added to the UDS
|
|
2581
|
+
// registry BEFORE its manifest fires the cap-changed event that triggers
|
|
2582
|
+
// this mount, so the gate is satisfied on the happy path.
|
|
2583
|
+
const entry = this.addonEntries.get(addonId);
|
|
2584
|
+
const childRegistry = this.moleculer.childRegistry;
|
|
2585
|
+
if (entry && this.isForkedAddonEntry(entry)) {
|
|
2586
|
+
if (childRegistry !== null && childRegistry.isChildKnown(addonId)) {
|
|
2587
|
+
await this.mountForkedAddonRoutes(addonId, routeProvider, addonRouteRegistry);
|
|
2588
|
+
return;
|
|
2317
2589
|
}
|
|
2318
|
-
//
|
|
2319
|
-
//
|
|
2320
|
-
|
|
2321
|
-
|
|
2590
|
+
// Child not yet connected over UDS: defer rather than register the async
|
|
2591
|
+
// proxy directly (which would crash on `.map`). The `addon-routes`
|
|
2592
|
+
// cap-changed event re-fires once the child finishes its handshake.
|
|
2593
|
+
this.logger.warn('Deferring forked addon route mount — child not yet UDS-reachable', {
|
|
2594
|
+
meta: { phase: 'v2', addonId },
|
|
2595
|
+
});
|
|
2596
|
+
return;
|
|
2597
|
+
}
|
|
2598
|
+
|
|
2599
|
+
// ── Co-located addon (`@camstack/core` builtin): live handlers, register
|
|
2600
|
+
// the provider directly. The route handlers run in-process against the
|
|
2601
|
+
// real Fastify reply, so no wire bridge is needed.
|
|
2602
|
+
addonRouteRegistry.registerRoutes(routeProvider.id, routeProvider);
|
|
2603
|
+
this.logger.info('Addon routes mounted', {
|
|
2604
|
+
meta: { phase: 'v2', routeProviderId: routeProvider.id },
|
|
2605
|
+
});
|
|
2606
|
+
}
|
|
2607
|
+
|
|
2608
|
+
/**
|
|
2609
|
+
* Mount a hub-local FORKED addon's HTTP routes (F3). The route handlers live
|
|
2610
|
+
* in the child process; only handler-stripped descriptors cross the UDS wire.
|
|
2611
|
+
* We:
|
|
2612
|
+
* 1. fetch the descriptors via `callAddonOnChild(addonId, {target:'routes'})`,
|
|
2613
|
+
* 2. synthesize a bridge handler per route that dispatches the captured
|
|
2614
|
+
* request through the `addon-routes` cap's `invoke(...)` method (a normal
|
|
2615
|
+
* serializable UDS cap call on `routeProvider`), and
|
|
2616
|
+
* 3. translate the returned reply envelope back onto the Fastify reply.
|
|
2617
|
+
*
|
|
2618
|
+
* `addonRouteRegistry` is narrowed non-null by the caller's guard in
|
|
2619
|
+
* `mountAddonRoutes` — no assertion needed here.
|
|
2620
|
+
*/
|
|
2621
|
+
private async mountForkedAddonRoutes(
|
|
2622
|
+
addonId: string,
|
|
2623
|
+
routeProvider: import('@camstack/types').IAddonRouteProvider,
|
|
2624
|
+
addonRouteRegistry: AddonRouteRegistry,
|
|
2625
|
+
): Promise<void> {
|
|
2626
|
+
// The cap-registry typed surface for `addon-routes` is the operator-facing
|
|
2627
|
+
// `IAddonRouteProvider` (id + getRoutes). The bridge dispatch contract
|
|
2628
|
+
// `invoke(...)` is present on the UDS proxy but not on that interface —
|
|
2629
|
+
// narrow it via a structural type guard (no cast).
|
|
2630
|
+
if (!isAddonRoutesInvoker(routeProvider)) {
|
|
2631
|
+
this.logger.warn(
|
|
2632
|
+
'Forked addon-routes provider missing `invoke` method — routes will not dispatch. Use `buildAddonRouteProvider()` from @camstack/types.',
|
|
2633
|
+
{ meta: { phase: 'v2', routeProviderId: routeProvider.id } },
|
|
2634
|
+
);
|
|
2635
|
+
return;
|
|
2636
|
+
}
|
|
2637
|
+
const invoker = routeProvider;
|
|
2638
|
+
|
|
2639
|
+
const rawRoutes = await this.addonCallGateway.callForked(addonId, {
|
|
2640
|
+
target: 'routes',
|
|
2641
|
+
});
|
|
2642
|
+
const descriptors = parseSerializableRouteDescriptors(rawRoutes);
|
|
2643
|
+
const bridgeRoutes: import('@camstack/types').IAddonHttpRoute[] = descriptors.map(
|
|
2644
|
+
(route) => ({
|
|
2645
|
+
method: route.method,
|
|
2322
2646
|
path: route.path,
|
|
2323
|
-
access:
|
|
2647
|
+
access: route.access,
|
|
2324
2648
|
...(route.description !== undefined ? { description: route.description } : {}),
|
|
2325
|
-
handler: async (req
|
|
2326
|
-
|
|
2327
|
-
reply.code(500)
|
|
2328
|
-
reply.send({ error: 'Forked addon-routes provider missing invoke' })
|
|
2329
|
-
return
|
|
2330
|
-
}
|
|
2331
|
-
const envelope = await invokeFn({
|
|
2649
|
+
handler: async (req, reply) => {
|
|
2650
|
+
const envelope = await invoker.invoke({
|
|
2332
2651
|
method: route.method,
|
|
2333
2652
|
path: route.path,
|
|
2334
2653
|
params: req.params,
|
|
@@ -2336,31 +2655,27 @@ export class AddonRegistryService {
|
|
|
2336
2655
|
body: req.body,
|
|
2337
2656
|
headers: req.headers,
|
|
2338
2657
|
...(req.user ? { user: req.user } : {}),
|
|
2339
|
-
...(req.scopedToken ? { scopedToken: req.scopedToken } : {}),
|
|
2340
|
-
})
|
|
2341
|
-
reply.code(envelope.status)
|
|
2342
|
-
if (envelope.contentType) reply.type(envelope.contentType)
|
|
2343
|
-
for (const [k, v] of Object.entries(envelope.headers)) reply.header(k, v)
|
|
2658
|
+
...(req.scopedToken !== undefined ? { scopedToken: req.scopedToken } : {}),
|
|
2659
|
+
});
|
|
2660
|
+
reply.code(envelope.status);
|
|
2661
|
+
if (envelope.contentType) reply.type(envelope.contentType);
|
|
2662
|
+
for (const [k, v] of Object.entries(envelope.headers)) reply.header(k, v);
|
|
2344
2663
|
if (envelope.redirectUrl !== null) {
|
|
2345
|
-
reply.header('Location', envelope.redirectUrl)
|
|
2346
|
-
reply.send('')
|
|
2664
|
+
reply.header('Location', envelope.redirectUrl);
|
|
2665
|
+
reply.send('');
|
|
2347
2666
|
} else {
|
|
2348
|
-
reply.send(envelope.body)
|
|
2667
|
+
reply.send(envelope.body);
|
|
2349
2668
|
}
|
|
2350
2669
|
},
|
|
2351
|
-
})
|
|
2352
|
-
|
|
2353
|
-
|
|
2354
|
-
|
|
2355
|
-
)
|
|
2356
|
-
|
|
2357
|
-
|
|
2358
|
-
|
|
2359
|
-
|
|
2360
|
-
routeProvider,
|
|
2361
|
-
);
|
|
2362
|
-
this.logger.info('Addon routes mounted', { meta: { phase: 'v2', routeProviderId: routeProvider.id } });
|
|
2363
|
-
}
|
|
2670
|
+
}),
|
|
2671
|
+
);
|
|
2672
|
+
addonRouteRegistry.registerRoutes(routeProvider.id, {
|
|
2673
|
+
id: routeProvider.id,
|
|
2674
|
+
getRoutes: () => bridgeRoutes,
|
|
2675
|
+
});
|
|
2676
|
+
this.logger.info('Addon routes mounted (forked-bridge over UDS)', {
|
|
2677
|
+
meta: { phase: 'v2', routeProviderId: routeProvider.id, routes: bridgeRoutes.length },
|
|
2678
|
+
});
|
|
2364
2679
|
}
|
|
2365
2680
|
|
|
2366
2681
|
// Cleanup: `addonHasConfigFields` deleted. It was the last reader of
|
|
@@ -2634,12 +2949,22 @@ export class AddonRegistryService {
|
|
|
2634
2949
|
streamProbe: kernelStreamProbe,
|
|
2635
2950
|
hwaccel: createKernelHwAccel(),
|
|
2636
2951
|
capabilityRegistry: this.capabilityRegistry,
|
|
2952
|
+
// Per-addon storage-location declarations across every installed
|
|
2953
|
+
// addon — surfaced from the kernel's AddonLoader so the
|
|
2954
|
+
// storage-orchestrator builtin can aggregate them and seed defaults.
|
|
2955
|
+
listStorageLocationDeclarations: () =>
|
|
2956
|
+
this.addonLoader.listStorageLocationDeclarations(),
|
|
2637
2957
|
readinessRegistry: this.moleculer.readinessRegistry,
|
|
2638
2958
|
// D3: handshake-fed native-cap view of the whole cluster. Backed by
|
|
2639
2959
|
// `HubNodeRegistry.listNativeCapEntries()` populated by every
|
|
2640
2960
|
// `$hub.registerNode` re-handshake. Used by device-manager as the
|
|
2641
2961
|
// reliable fallback when push events were lost mid-transport.
|
|
2642
2962
|
listClusterNativeCaps: () => this.moleculer.listClusterNativeCaps(),
|
|
2963
|
+
// Per-device slice of the above — O(caps-for-device) via the registry's
|
|
2964
|
+
// deviceId index. The per-device `getBindings` resolver prefers this
|
|
2965
|
+
// over filtering the whole-cluster flat view.
|
|
2966
|
+
listClusterNativeCapsForDevice: (deviceId: number) =>
|
|
2967
|
+
this.moleculer.listClusterNativeCapsForDevice(deviceId),
|
|
2643
2968
|
},
|
|
2644
2969
|
registerProvider,
|
|
2645
2970
|
resolveProvider: <T = unknown>(capName: string): T | null => {
|
|
@@ -2838,8 +3163,11 @@ export class AddonRegistryService {
|
|
|
2838
3163
|
*
|
|
2839
3164
|
* The catalog (zod `input`/`output` specs) is read STATICALLY from the
|
|
2840
3165
|
* addon module's `customActions` named export — the handler dispatches
|
|
2841
|
-
* via `
|
|
2842
|
-
*
|
|
3166
|
+
* over UDS via `LocalChildRegistry.callAddonOnChild(addonId,
|
|
3167
|
+
* {target:'custom', action, args})` (F3 — replaces the removed per-addon
|
|
3168
|
+
* Moleculer `custom.<action>` action), so the only divergence vs an
|
|
3169
|
+
* in-process addon is the transport, exactly like cap methods. The hub's
|
|
3170
|
+
* `CustomActionRegistry` validates input/output around this dispatch.
|
|
2843
3171
|
*
|
|
2844
3172
|
* Why a fresh import: `this.addonLoader`'s `module` namespace is
|
|
2845
3173
|
* captured once at boot. After a hot-update (`installFromTgz` →
|
|
@@ -2916,11 +3244,29 @@ export class AddonRegistryService {
|
|
|
2916
3244
|
this.customActionRegistry.registerAddon(
|
|
2917
3245
|
addonId,
|
|
2918
3246
|
catalog as import("@camstack/types").CustomActionsSpec,
|
|
2919
|
-
(action, input) => this.
|
|
3247
|
+
(action, input) => this.dispatchForkedCustomAction(addonId, action, input),
|
|
2920
3248
|
);
|
|
2921
3249
|
this.logger.info("Runner addon custom actions registered", {
|
|
2922
3250
|
tags: { addonId },
|
|
2923
3251
|
meta: { runnerId },
|
|
2924
3252
|
});
|
|
2925
3253
|
}
|
|
3254
|
+
|
|
3255
|
+
/**
|
|
3256
|
+
* Dispatch a forked addon's custom action through the shared
|
|
3257
|
+
* {@link AddonCallGateway} (UDS to the hub-local child). The gateway owns the
|
|
3258
|
+
* routing + the child-availability error; there is no broker fallback after
|
|
3259
|
+
* the per-addon Moleculer broker was removed.
|
|
3260
|
+
*/
|
|
3261
|
+
private async dispatchForkedCustomAction(
|
|
3262
|
+
addonId: string,
|
|
3263
|
+
action: string,
|
|
3264
|
+
input: unknown,
|
|
3265
|
+
): Promise<unknown> {
|
|
3266
|
+
return this.addonCallGateway.callForked(addonId, {
|
|
3267
|
+
target: "custom",
|
|
3268
|
+
action,
|
|
3269
|
+
args: input,
|
|
3270
|
+
});
|
|
3271
|
+
}
|
|
2926
3272
|
}
|