@camstack/server 0.1.5 → 0.1.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/__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-bulk-update.spec.ts +388 -0
- package/src/__tests__/cap-route-adapter.spec.ts +289 -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__/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 +123 -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/bulk-update-coordinator.ts +302 -0
- package/src/api/core/cap-providers.ts +59 -6
- package/src/api/core/capabilities.router.ts +26 -3
- package/src/api/oauth2/oauth2-routes.ts +5 -1
- package/src/api/trpc/__tests__/client-ip.spec.ts +120 -0
- package/src/api/trpc/cap-route-error-formatter.ts +163 -0
- package/src/api/trpc/client-ip.ts +130 -0
- package/src/api/trpc/generated-cap-mounts.ts +19 -1
- package/src/api/trpc/generated-cap-routers.ts +180 -1
- package/src/api/trpc/trpc.middleware.ts +5 -1
- package/src/api/trpc/trpc.router.ts +45 -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 +364 -105
- package/src/core/addon/addon-settings-provider.ts +40 -116
- package/src/core/capability/capability.service.ts +9 -0
- 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 +380 -36
- package/src/main.ts +45 -12
|
@@ -66,6 +66,7 @@ import * as path from "node:path";
|
|
|
66
66
|
import * as fs from "node:fs";
|
|
67
67
|
import { pathToFileURL } from "node:url";
|
|
68
68
|
import { createAddonSettingsProvider } from "./addon-settings-provider.js";
|
|
69
|
+
import { AddonCallGateway } from "./addon-call-gateway.js";
|
|
69
70
|
import { addonSettingsCapability } from "@camstack/types";
|
|
70
71
|
import { DisposerChain } from "@camstack/types";
|
|
71
72
|
|
|
@@ -111,6 +112,87 @@ function isSettingsStore(
|
|
|
111
112
|
return true;
|
|
112
113
|
}
|
|
113
114
|
|
|
115
|
+
/**
|
|
116
|
+
* The bridge-dispatch contract on an `addon-routes` provider. The operator-
|
|
117
|
+
* facing `IAddonRouteProvider` interface declares only `id` + `getRoutes`; the
|
|
118
|
+
* UDS proxy (and `buildAddonRouteProvider`) also expose `invoke`, which the
|
|
119
|
+
* forked-routes bridge calls per request. Narrowed via `isAddonRoutesInvoker`.
|
|
120
|
+
*/
|
|
121
|
+
interface AddonRoutesInvoker {
|
|
122
|
+
invoke(
|
|
123
|
+
input: import("@camstack/types").AddonRouteInvokeRequest,
|
|
124
|
+
): Promise<import("@camstack/types").AddonRouteReplyEnvelope>;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Structural guard: true when an `addon-routes` provider also exposes `invoke`. */
|
|
128
|
+
function isAddonRoutesInvoker<T extends object>(
|
|
129
|
+
provider: T,
|
|
130
|
+
): provider is T & AddonRoutesInvoker {
|
|
131
|
+
return typeof Reflect.get(provider, "invoke") === "function";
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const ROUTE_METHODS: readonly import("@camstack/types").IAddonHttpRoute["method"][] = [
|
|
135
|
+
"GET",
|
|
136
|
+
"POST",
|
|
137
|
+
"PUT",
|
|
138
|
+
"DELETE",
|
|
139
|
+
];
|
|
140
|
+
const ROUTE_ACCESS: readonly import("@camstack/types").RouteAccess[] = [
|
|
141
|
+
"public",
|
|
142
|
+
"authenticated",
|
|
143
|
+
"admin",
|
|
144
|
+
];
|
|
145
|
+
|
|
146
|
+
function asRouteMethod(value: string): import("@camstack/types").IAddonHttpRoute["method"] {
|
|
147
|
+
const upper = value.toUpperCase();
|
|
148
|
+
for (const m of ROUTE_METHODS) if (m === upper) return m;
|
|
149
|
+
throw new Error(`addon-routes: unsupported HTTP method "${value}"`);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function asRouteAccess(value: unknown): import("@camstack/types").RouteAccess {
|
|
153
|
+
if (typeof value === "string") {
|
|
154
|
+
for (const a of ROUTE_ACCESS) if (a === value) return a;
|
|
155
|
+
}
|
|
156
|
+
// Default to the most restrictive sensible default the original mount used.
|
|
157
|
+
return "public";
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Parse the wire-boundary `unknown` returned by `callAddonOnChild(...,
|
|
162
|
+
* {target:'routes'})` into typed route descriptors. The child sends route
|
|
163
|
+
* descriptors WITHOUT handlers (functions don't cross MsgPack); only
|
|
164
|
+
* `method`/`path`/`access`/`description` are present.
|
|
165
|
+
*/
|
|
166
|
+
interface ParsedRouteDescriptor {
|
|
167
|
+
readonly method: import("@camstack/types").IAddonHttpRoute["method"];
|
|
168
|
+
readonly path: string;
|
|
169
|
+
readonly access: import("@camstack/types").RouteAccess;
|
|
170
|
+
readonly description?: string;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function parseSerializableRouteDescriptors(raw: unknown): readonly ParsedRouteDescriptor[] {
|
|
174
|
+
if (!Array.isArray(raw)) {
|
|
175
|
+
throw new Error("addon-routes: child returned a non-array route descriptor set");
|
|
176
|
+
}
|
|
177
|
+
return raw.map((entry: unknown): ParsedRouteDescriptor => {
|
|
178
|
+
if (entry === null || typeof entry !== "object") {
|
|
179
|
+
throw new Error("addon-routes: route descriptor is not an object");
|
|
180
|
+
}
|
|
181
|
+
const method = Reflect.get(entry, "method");
|
|
182
|
+
const path = Reflect.get(entry, "path");
|
|
183
|
+
if (typeof method !== "string" || typeof path !== "string") {
|
|
184
|
+
throw new Error("addon-routes: route descriptor missing method/path");
|
|
185
|
+
}
|
|
186
|
+
const description = Reflect.get(entry, "description");
|
|
187
|
+
return {
|
|
188
|
+
method: asRouteMethod(method),
|
|
189
|
+
path,
|
|
190
|
+
access: asRouteAccess(Reflect.get(entry, "access")),
|
|
191
|
+
...(typeof description === "string" ? { description } : {}),
|
|
192
|
+
};
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
|
|
114
196
|
interface AddonEntry {
|
|
115
197
|
readonly addon: ICamstackAddon;
|
|
116
198
|
initialized: boolean;
|
|
@@ -140,6 +222,8 @@ interface AddonEntry {
|
|
|
140
222
|
export class AddonRegistryService {
|
|
141
223
|
private readonly addonEntries = new Map<string, AddonEntry>();
|
|
142
224
|
private readonly capabilityRegistry: CapabilityRegistry;
|
|
225
|
+
/** Single router for addon-level calls (routes / custom / settings). */
|
|
226
|
+
private addonCallGateway!: AddonCallGateway;
|
|
143
227
|
// Task 7.1: hub-wide registry of addon custom actions. Populated on
|
|
144
228
|
// each addon's initialize() (in-process path); Task 7.2 will dispatch
|
|
145
229
|
// through this from the `api.addons.custom` tRPC procedure.
|
|
@@ -272,6 +356,24 @@ export class AddonRegistryService {
|
|
|
272
356
|
},
|
|
273
357
|
);
|
|
274
358
|
|
|
359
|
+
this.capabilityRegistry.setNodeConfigReader(
|
|
360
|
+
(capability: string, nodeId: string): string | undefined => {
|
|
361
|
+
try {
|
|
362
|
+
return (
|
|
363
|
+
this.configService.get<string>(
|
|
364
|
+
`capabilities.singletonNode.${capability}.${nodeId}`,
|
|
365
|
+
) ?? undefined
|
|
366
|
+
);
|
|
367
|
+
} catch (err) {
|
|
368
|
+
this.logger.debug(
|
|
369
|
+
'settings-store not wired yet during early boot',
|
|
370
|
+
{ meta: { capability, nodeId, error: errMsg(err) } },
|
|
371
|
+
);
|
|
372
|
+
return undefined;
|
|
373
|
+
}
|
|
374
|
+
},
|
|
375
|
+
);
|
|
376
|
+
|
|
275
377
|
// Collection-provider enable/disable persistence. Reads the same
|
|
276
378
|
// `capabilities.collection.<cap>` ConfigService key the `capabilities`
|
|
277
379
|
// router writes (`JSON.stringify({ disabled: string[] })`), so a
|
|
@@ -341,29 +443,33 @@ export class AddonRegistryService {
|
|
|
341
443
|
// former `$addonHost` Moleculer service. The provider resolves
|
|
342
444
|
// addonId → local addon instance (hub) or remote Moleculer call.
|
|
343
445
|
this.capabilityRegistry.declareCapability(addonSettingsCapability);
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
446
|
+
// The SINGLE router for addon-level calls (routes / custom / settings).
|
|
447
|
+
// It classifies in-process | hub-local-child (UDS) | remote-agent
|
|
448
|
+
// (Moleculer) in ONE place — `resolveNode` reports only the BASE node
|
|
449
|
+
// (the hub for every hub-local addon, forked or not); the gateway's
|
|
450
|
+
// `isChildKnown` distinguishes a forked UDS child from an in-process
|
|
451
|
+
// builtin. This is the fix for forked-addon settings: they were forced
|
|
452
|
+
// down a dead `<addonId>.settings.*` Moleculer path because the old
|
|
453
|
+
// `resolveNode` marked them non-local; now they route over UDS like
|
|
454
|
+
// their routes + custom-actions already do.
|
|
455
|
+
this.addonCallGateway = new AddonCallGateway({
|
|
456
|
+
hubNodeId: this.broker.nodeID,
|
|
349
457
|
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
458
|
const entry = this.addonEntries.get(addonId);
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
}
|
|
362
|
-
return "hub";
|
|
459
|
+
// Every addon in `addonEntries` is hub-local — report the hub node;
|
|
460
|
+
// forked-vs-in-process is decided by `isChildKnown` in the gateway.
|
|
461
|
+
return entry ? this.broker.nodeID : "hub";
|
|
363
462
|
},
|
|
463
|
+
getChildRegistry: () => this.moleculer.childRegistry,
|
|
364
464
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- moleculer types unresolvable; see `broker` getter docstring
|
|
365
465
|
broker: this.moleculer.broker,
|
|
366
|
-
|
|
466
|
+
});
|
|
467
|
+
const settingsProvider = createAddonSettingsProvider({
|
|
468
|
+
getAddon: (addonId) => {
|
|
469
|
+
const entry = this.addonEntries.get(addonId);
|
|
470
|
+
return entry?.addon ?? null;
|
|
471
|
+
},
|
|
472
|
+
gateway: this.addonCallGateway,
|
|
367
473
|
});
|
|
368
474
|
this.capabilityRegistry.registerProvider(
|
|
369
475
|
"addon-settings",
|
|
@@ -487,38 +593,97 @@ export class AddonRegistryService {
|
|
|
487
593
|
|
|
488
594
|
// Native-cap cross-process bridge: when a hub consumer resolves a native
|
|
489
595
|
// 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.
|
|
596
|
+
// return a proxy that routes calls to the correct transport.
|
|
492
597
|
//
|
|
493
|
-
//
|
|
494
|
-
//
|
|
495
|
-
//
|
|
496
|
-
//
|
|
497
|
-
//
|
|
498
|
-
//
|
|
598
|
+
// G2 — single ownership authority: the CapRouteResolver is consulted FIRST
|
|
599
|
+
// for hub-local native caps. If the resolver's snapshot identifies a
|
|
600
|
+
// hub-local-uds route for (capName, deviceId), we build the proxy directly —
|
|
601
|
+
// no `resolveNativeCapOwnerSync` consult needed for this branch. The resolver's
|
|
602
|
+
// `hubLocalChildProvides(capName, deviceId)` is the canonical hub-local authority
|
|
603
|
+
// (fed by the same LocalChildRegistry that owns UDS dispatch), so the old
|
|
604
|
+
// `owner.nodeId.startsWith(hubNodeId + '/')` gate is replaced by the resolver's
|
|
605
|
+
// own verdict.
|
|
499
606
|
//
|
|
500
|
-
// `resolveNativeCapOwnerSync`
|
|
501
|
-
//
|
|
502
|
-
//
|
|
503
|
-
//
|
|
504
|
-
//
|
|
505
|
-
//
|
|
506
|
-
//
|
|
607
|
+
// `resolveNativeCapOwnerSync` is only consulted for the REMOTE branch: when the
|
|
608
|
+
// resolver throws no-provider for the hub node (no hub-local-uds child owns the cap),
|
|
609
|
+
// the fallback queries `resolveNativeCapOwnerSync` to find a remote owner. That
|
|
610
|
+
// method carries data (push-fed `remoteNativeCaps` from `DeviceBindingsChanged`
|
|
611
|
+
// events) that the resolver's `NodeCapAuthority` doesn't see, so it stays as the
|
|
612
|
+
// remote-branch source. If `resolveNativeCapOwnerSync` also returns null, the cap
|
|
613
|
+
// genuinely has no cross-process provider and the fallback returns null — wrappers
|
|
614
|
+
// cleanly fall through to their own secondary strategy.
|
|
507
615
|
{
|
|
508
616
|
const { buildNativeCapProxy } = await import("@camstack/kernel");
|
|
509
617
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- moleculer types unresolvable; see `broker` getter docstring
|
|
510
618
|
const broker = this.moleculer.broker;
|
|
511
619
|
this.capabilityRegistry.setNativeFallback(
|
|
512
620
|
(capName: string, deviceId: number): unknown | null => {
|
|
621
|
+
const resolver = this.moleculer.capRouteResolver;
|
|
622
|
+
const hubNodeId = this.moleculer.nodeId;
|
|
623
|
+
|
|
624
|
+
// 1. Hub-local path: the resolver's hub-local-child authority is the
|
|
625
|
+
// single source of truth for whether a forked hub-local UDS child
|
|
626
|
+
// owns (capName, deviceId) — via hubLocalChildProvides +
|
|
627
|
+
// getHubLocalChildId (both deviceId-aware, M1).
|
|
628
|
+
//
|
|
629
|
+
// We deliberately use `resolveHubLocalUdsRoute`, NOT
|
|
630
|
+
// `resolveCapRoute`: this fallback's contract is to reach the
|
|
631
|
+
// NATIVE provider in the forked vendor child. `resolveCapRoute`
|
|
632
|
+
// gives Priority 1 to `hub-in-process`, so a cap that ALSO has an
|
|
633
|
+
// in-hub provider for the same name (a wrapper — today `snapshot`)
|
|
634
|
+
// would classify as `hub-in-process` (the wrapper) and never reach
|
|
635
|
+
// the hub-local-uds native. That made the wrapper's
|
|
636
|
+
// `getNativeProvider` return null and silently fall through to its
|
|
637
|
+
// secondary strategy (e.g. snapshot's ffmpeg frame grab), so the
|
|
638
|
+
// vendor native snapshot was never invoked. `resolveHubLocalUdsRoute`
|
|
639
|
+
// skips the in-process branch and returns the native child route.
|
|
640
|
+
if (resolver !== null) {
|
|
641
|
+
const route = resolver.resolveHubLocalUdsRoute(capName, deviceId)
|
|
642
|
+
if (route !== null) {
|
|
643
|
+
const childId = route.childId
|
|
644
|
+
const target: Record<string, (input: unknown) => Promise<unknown>> = {}
|
|
645
|
+
return new Proxy(target, {
|
|
646
|
+
get(_target, property): ((input: unknown) => Promise<unknown>) | undefined {
|
|
647
|
+
if (typeof property !== 'string') return undefined
|
|
648
|
+
return (input: unknown): Promise<unknown> => {
|
|
649
|
+
const mergedInput =
|
|
650
|
+
typeof input === 'object' && input !== null
|
|
651
|
+
? { ...input, deviceId }
|
|
652
|
+
: { deviceId }
|
|
653
|
+
// Re-resolve at call time so a child that respawned under a
|
|
654
|
+
// new runner id is still reachable (route is cheap to build).
|
|
655
|
+
const r = resolver.resolveHubLocalUdsRoute(capName, deviceId)
|
|
656
|
+
?? { kind: 'hub-local-uds' as const, capName, childId }
|
|
657
|
+
return resolver.dispatch(r, property, mergedInput)
|
|
658
|
+
}
|
|
659
|
+
},
|
|
660
|
+
})
|
|
661
|
+
}
|
|
662
|
+
// No hub-local child owns (capName, deviceId). Fall through to the
|
|
663
|
+
// remote branch — resolveNativeCapOwnerSync is still consulted there
|
|
664
|
+
// and may find a remote owner for this (capName, deviceId).
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
// 2. Remote path: resolver has no hub-local route for this (capName, deviceId).
|
|
668
|
+
// Consult resolveNativeCapOwnerSync — it includes push-fed remoteNativeCaps
|
|
669
|
+
// (DeviceBindingsChanged events from forked workers) that the resolver's
|
|
670
|
+
// NodeCapAuthority (backed by HubNodeRegistry) doesn't carry.
|
|
513
671
|
const dm = this.capabilityRegistry.getSingleton<{
|
|
514
672
|
resolveNativeCapOwnerSync?: (
|
|
515
673
|
capName: string,
|
|
516
674
|
deviceId: number,
|
|
517
675
|
) => { addonId: string; nodeId: string } | null;
|
|
518
676
|
}>("device-manager");
|
|
519
|
-
const owner =
|
|
520
|
-
dm?.resolveNativeCapOwnerSync?.(capName, deviceId) ?? null;
|
|
677
|
+
const owner = dm?.resolveNativeCapOwnerSync?.(capName, deviceId) ?? null;
|
|
521
678
|
if (!owner) return null;
|
|
679
|
+
|
|
680
|
+
// Guard: skip hub-local owners here — they were already handled (or skipped
|
|
681
|
+
// because the resolver was null / not initialised yet). Returning null avoids
|
|
682
|
+
// building a Moleculer proxy that points at a UDS-only child.
|
|
683
|
+
if (owner.nodeId.startsWith(`${hubNodeId}/`)) return null;
|
|
684
|
+
|
|
685
|
+
// Remote native cap → Moleculer with native-provider infix.
|
|
686
|
+
// buildNativeCapProxy uses the action `${addonId}.native-provider.${cap}.${method}`.
|
|
522
687
|
return buildNativeCapProxy(broker, owner.addonId, capName, deviceId);
|
|
523
688
|
},
|
|
524
689
|
);
|
|
@@ -1118,8 +1283,8 @@ export class AddonRegistryService {
|
|
|
1118
1283
|
// vs in-process being the transport, exactly like cap methods.
|
|
1119
1284
|
await this.registerForkedAddonCustomActions(
|
|
1120
1285
|
id,
|
|
1121
|
-
// `
|
|
1122
|
-
resolveRunnerId(entry.declaration
|
|
1286
|
+
// `isForkedAddonEntry` narrowed `entry.declaration` to non-null.
|
|
1287
|
+
resolveRunnerId(entry.declaration, id),
|
|
1123
1288
|
);
|
|
1124
1289
|
|
|
1125
1290
|
entry.initialized = true;
|
|
@@ -1153,7 +1318,9 @@ export class AddonRegistryService {
|
|
|
1153
1318
|
// ProviderRegistration.kind/defaultActive are deprecated hints — if an
|
|
1154
1319
|
// addon still sets them and they disagree, warn (the cap def wins).
|
|
1155
1320
|
const kindDrift = describeProviderKindDrift(capName, reg.capability, {
|
|
1321
|
+
// eslint-disable-next-line @typescript-eslint/no-deprecated -- this IS the drift detector; it reads the deprecated hint on purpose
|
|
1156
1322
|
kind: reg.kind,
|
|
1323
|
+
// eslint-disable-next-line @typescript-eslint/no-deprecated -- this IS the drift detector; it reads the deprecated hint on purpose
|
|
1157
1324
|
defaultActive: reg.defaultActive,
|
|
1158
1325
|
});
|
|
1159
1326
|
if (kindDrift) {
|
|
@@ -1650,18 +1817,28 @@ export class AddonRegistryService {
|
|
|
1650
1817
|
}
|
|
1651
1818
|
|
|
1652
1819
|
/**
|
|
1653
|
-
* True when the entry boots in its own forked runner
|
|
1654
|
-
*
|
|
1655
|
-
*
|
|
1656
|
-
*
|
|
1657
|
-
*
|
|
1820
|
+
* True when the entry boots in its own forked runner. This MUST mirror
|
|
1821
|
+
* the fork authority `buildAddonGroupPlan`, which spawns a runner for
|
|
1822
|
+
* every addon that ships an on-disk `addonDir` + a manifest
|
|
1823
|
+
* `declaration`, is NOT a `@camstack/core` builtin, and is NOT
|
|
1824
|
+
* `agent-only`. The `execution` block is OPTIONAL — an addon with no
|
|
1825
|
+
* `execution` declaration still forks on its own dedicated runner
|
|
1826
|
+
* (`resolveRunnerId` falls back to the addon id). Requiring `execution`
|
|
1827
|
+
* here previously diverged from `buildAddonGroupPlan`: an addon like
|
|
1828
|
+
* `auth-oidc` (no `execution` block) was forked at boot yet classified
|
|
1829
|
+
* in-process by this predicate, so its route-mount took the co-located
|
|
1830
|
+
* path and received the async UDS cap proxy whose `getRoutes()` returns
|
|
1831
|
+
* a Promise (→ `getRoutes(...).map is not a function`). Only
|
|
1832
|
+
* `@camstack/core` builtins boot in-process on the hub. The type
|
|
1833
|
+
* predicate narrows both `addonDir` and `declaration` for callers.
|
|
1658
1834
|
*/
|
|
1659
1835
|
private isForkedAddonEntry(
|
|
1660
1836
|
entry: AddonEntry,
|
|
1661
|
-
): entry is AddonEntry & { addonDir: string } {
|
|
1837
|
+
): entry is AddonEntry & { addonDir: string; declaration: AddonDeclaration } {
|
|
1662
1838
|
return !!(
|
|
1663
|
-
entry.declaration
|
|
1839
|
+
entry.declaration &&
|
|
1664
1840
|
entry.addonDir &&
|
|
1841
|
+
entry.packageName !== "@camstack/core" &&
|
|
1665
1842
|
resolveAddonPlacement(entry.declaration) !== 'agent-only'
|
|
1666
1843
|
);
|
|
1667
1844
|
}
|
|
@@ -2282,53 +2459,118 @@ export class AddonRegistryService {
|
|
|
2282
2459
|
/**
|
|
2283
2460
|
* Mount the `addon-routes` provider for `addonId` into the
|
|
2284
2461
|
* `AddonRouteRegistry`. Handles both co-located (in-process) and
|
|
2285
|
-
* forked
|
|
2462
|
+
* forked addons:
|
|
2286
2463
|
*
|
|
2287
|
-
* - For a
|
|
2288
|
-
*
|
|
2289
|
-
*
|
|
2290
|
-
*
|
|
2291
|
-
*
|
|
2292
|
-
*
|
|
2293
|
-
*
|
|
2294
|
-
*
|
|
2464
|
+
* - For a hub-local FORKED addon the route handlers live in the
|
|
2465
|
+
* child process and cannot cross the UDS wire (MsgPack can't encode
|
|
2466
|
+
* a function). We fetch the handler-stripped route descriptors over
|
|
2467
|
+
* UDS via `LocalChildRegistry.callAddonOnChild(addonId,
|
|
2468
|
+
* {target:'routes'})` (F3 — replaces the removed per-addon Moleculer
|
|
2469
|
+
* `getRoutes` action), synthesize bridge handlers that dispatch
|
|
2470
|
+
* through the `addon-routes` cap's `invoke(...)` method (a normal
|
|
2471
|
+
* serializable cap call over UDS), and translate the captured
|
|
2472
|
+
* envelope back onto the Fastify reply.
|
|
2473
|
+
* - For a co-located (hub-resident) addon `getRoutes()` resolves to the
|
|
2474
|
+
* live route list with real handlers, so we register the provider
|
|
2475
|
+
* directly.
|
|
2295
2476
|
*/
|
|
2296
2477
|
private async mountAddonRoutes(addonId: string): Promise<void> {
|
|
2297
|
-
if (!this.capabilityRegistry) return;
|
|
2478
|
+
if (!this.capabilityRegistry || !this.addonRouteRegistry) return;
|
|
2479
|
+
const addonRouteRegistry = this.addonRouteRegistry;
|
|
2298
2480
|
const routeProvider = this.capabilityRegistry.getProviderByAddon(
|
|
2299
2481
|
"addon-routes",
|
|
2300
2482
|
addonId,
|
|
2301
2483
|
);
|
|
2302
|
-
if (!routeProvider
|
|
2303
|
-
|
|
2304
|
-
//
|
|
2305
|
-
//
|
|
2306
|
-
// `
|
|
2307
|
-
//
|
|
2308
|
-
//
|
|
2309
|
-
|
|
2310
|
-
|
|
2311
|
-
|
|
2312
|
-
|
|
2313
|
-
|
|
2314
|
-
|
|
2315
|
-
|
|
2316
|
-
|
|
2484
|
+
if (!routeProvider) return;
|
|
2485
|
+
|
|
2486
|
+
// ── Forked addon: fetch handler-stripped routes over UDS (F3) ──────
|
|
2487
|
+
// EVERY non-`@camstack/core` addon forks (the fork authority is
|
|
2488
|
+
// `buildAddonGroupPlan`, which does NOT require an `execution` block — an
|
|
2489
|
+
// addon like `auth-oidc` with no `execution` still runs in its own
|
|
2490
|
+
// runner). `isForkedAddonEntry` now mirrors that rule. For a forked addon
|
|
2491
|
+
// the `getProviderByAddon('addon-routes', …)` result is the async UDS cap
|
|
2492
|
+
// proxy whose `getRoutes()` returns a Promise — registering it directly
|
|
2493
|
+
// would crash `AddonRouteRegistry.registerRoutes` (`getRoutes(...).map is
|
|
2494
|
+
// not a function`), and dispatching the proxy's `getRoutes` cap method
|
|
2495
|
+
// over UDS would try to MsgPack-serialize the child's live handler
|
|
2496
|
+
// functions (→ "Unrecognized object: [object AsyncFunction]"). Instead we
|
|
2497
|
+
// fetch handler-STRIPPED descriptors via `callAddonOnChild(target:
|
|
2498
|
+
// 'routes')` and bridge each through the `invoke` cap method. The UDS
|
|
2499
|
+
// childId for a hub-local single-addon runner equals the addonId
|
|
2500
|
+
// (`resolveRunnerId` — no shipped addon declares a group). Gate on
|
|
2501
|
+
// `isChildKnown` (the child completed its UDS handshake) rather than
|
|
2502
|
+
// `childProvides('addon-routes')`: the `addon-call` route fetch works as
|
|
2503
|
+
// long as the child is connected, and the child is added to the UDS
|
|
2504
|
+
// registry BEFORE its manifest fires the cap-changed event that triggers
|
|
2505
|
+
// this mount, so the gate is satisfied on the happy path.
|
|
2506
|
+
const entry = this.addonEntries.get(addonId);
|
|
2507
|
+
const childRegistry = this.moleculer.childRegistry;
|
|
2508
|
+
if (entry && this.isForkedAddonEntry(entry)) {
|
|
2509
|
+
if (childRegistry !== null && childRegistry.isChildKnown(addonId)) {
|
|
2510
|
+
await this.mountForkedAddonRoutes(addonId, routeProvider, addonRouteRegistry);
|
|
2511
|
+
return;
|
|
2317
2512
|
}
|
|
2318
|
-
//
|
|
2319
|
-
//
|
|
2320
|
-
|
|
2321
|
-
|
|
2513
|
+
// Child not yet connected over UDS: defer rather than register the async
|
|
2514
|
+
// proxy directly (which would crash on `.map`). The `addon-routes`
|
|
2515
|
+
// cap-changed event re-fires once the child finishes its handshake.
|
|
2516
|
+
this.logger.warn('Deferring forked addon route mount — child not yet UDS-reachable', {
|
|
2517
|
+
meta: { phase: 'v2', addonId },
|
|
2518
|
+
});
|
|
2519
|
+
return;
|
|
2520
|
+
}
|
|
2521
|
+
|
|
2522
|
+
// ── Co-located addon (`@camstack/core` builtin): live handlers, register
|
|
2523
|
+
// the provider directly. The route handlers run in-process against the
|
|
2524
|
+
// real Fastify reply, so no wire bridge is needed.
|
|
2525
|
+
addonRouteRegistry.registerRoutes(routeProvider.id, routeProvider);
|
|
2526
|
+
this.logger.info('Addon routes mounted', {
|
|
2527
|
+
meta: { phase: 'v2', routeProviderId: routeProvider.id },
|
|
2528
|
+
});
|
|
2529
|
+
}
|
|
2530
|
+
|
|
2531
|
+
/**
|
|
2532
|
+
* Mount a hub-local FORKED addon's HTTP routes (F3). The route handlers live
|
|
2533
|
+
* in the child process; only handler-stripped descriptors cross the UDS wire.
|
|
2534
|
+
* We:
|
|
2535
|
+
* 1. fetch the descriptors via `callAddonOnChild(addonId, {target:'routes'})`,
|
|
2536
|
+
* 2. synthesize a bridge handler per route that dispatches the captured
|
|
2537
|
+
* request through the `addon-routes` cap's `invoke(...)` method (a normal
|
|
2538
|
+
* serializable UDS cap call on `routeProvider`), and
|
|
2539
|
+
* 3. translate the returned reply envelope back onto the Fastify reply.
|
|
2540
|
+
*
|
|
2541
|
+
* `addonRouteRegistry` is narrowed non-null by the caller's guard in
|
|
2542
|
+
* `mountAddonRoutes` — no assertion needed here.
|
|
2543
|
+
*/
|
|
2544
|
+
private async mountForkedAddonRoutes(
|
|
2545
|
+
addonId: string,
|
|
2546
|
+
routeProvider: import('@camstack/types').IAddonRouteProvider,
|
|
2547
|
+
addonRouteRegistry: AddonRouteRegistry,
|
|
2548
|
+
): Promise<void> {
|
|
2549
|
+
// The cap-registry typed surface for `addon-routes` is the operator-facing
|
|
2550
|
+
// `IAddonRouteProvider` (id + getRoutes). The bridge dispatch contract
|
|
2551
|
+
// `invoke(...)` is present on the UDS proxy but not on that interface —
|
|
2552
|
+
// narrow it via a structural type guard (no cast).
|
|
2553
|
+
if (!isAddonRoutesInvoker(routeProvider)) {
|
|
2554
|
+
this.logger.warn(
|
|
2555
|
+
'Forked addon-routes provider missing `invoke` method — routes will not dispatch. Use `buildAddonRouteProvider()` from @camstack/types.',
|
|
2556
|
+
{ meta: { phase: 'v2', routeProviderId: routeProvider.id } },
|
|
2557
|
+
);
|
|
2558
|
+
return;
|
|
2559
|
+
}
|
|
2560
|
+
const invoker = routeProvider;
|
|
2561
|
+
|
|
2562
|
+
const rawRoutes = await this.addonCallGateway.callForked(addonId, {
|
|
2563
|
+
target: 'routes',
|
|
2564
|
+
});
|
|
2565
|
+
const descriptors = parseSerializableRouteDescriptors(rawRoutes);
|
|
2566
|
+
const bridgeRoutes: import('@camstack/types').IAddonHttpRoute[] = descriptors.map(
|
|
2567
|
+
(route) => ({
|
|
2568
|
+
method: route.method,
|
|
2322
2569
|
path: route.path,
|
|
2323
|
-
access:
|
|
2570
|
+
access: route.access,
|
|
2324
2571
|
...(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({
|
|
2572
|
+
handler: async (req, reply) => {
|
|
2573
|
+
const envelope = await invoker.invoke({
|
|
2332
2574
|
method: route.method,
|
|
2333
2575
|
path: route.path,
|
|
2334
2576
|
params: req.params,
|
|
@@ -2336,31 +2578,27 @@ export class AddonRegistryService {
|
|
|
2336
2578
|
body: req.body,
|
|
2337
2579
|
headers: req.headers,
|
|
2338
2580
|
...(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)
|
|
2581
|
+
...(req.scopedToken !== undefined ? { scopedToken: req.scopedToken } : {}),
|
|
2582
|
+
});
|
|
2583
|
+
reply.code(envelope.status);
|
|
2584
|
+
if (envelope.contentType) reply.type(envelope.contentType);
|
|
2585
|
+
for (const [k, v] of Object.entries(envelope.headers)) reply.header(k, v);
|
|
2344
2586
|
if (envelope.redirectUrl !== null) {
|
|
2345
|
-
reply.header('Location', envelope.redirectUrl)
|
|
2346
|
-
reply.send('')
|
|
2587
|
+
reply.header('Location', envelope.redirectUrl);
|
|
2588
|
+
reply.send('');
|
|
2347
2589
|
} else {
|
|
2348
|
-
reply.send(envelope.body)
|
|
2590
|
+
reply.send(envelope.body);
|
|
2349
2591
|
}
|
|
2350
2592
|
},
|
|
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
|
-
}
|
|
2593
|
+
}),
|
|
2594
|
+
);
|
|
2595
|
+
addonRouteRegistry.registerRoutes(routeProvider.id, {
|
|
2596
|
+
id: routeProvider.id,
|
|
2597
|
+
getRoutes: () => bridgeRoutes,
|
|
2598
|
+
});
|
|
2599
|
+
this.logger.info('Addon routes mounted (forked-bridge over UDS)', {
|
|
2600
|
+
meta: { phase: 'v2', routeProviderId: routeProvider.id, routes: bridgeRoutes.length },
|
|
2601
|
+
});
|
|
2364
2602
|
}
|
|
2365
2603
|
|
|
2366
2604
|
// Cleanup: `addonHasConfigFields` deleted. It was the last reader of
|
|
@@ -2838,8 +3076,11 @@ export class AddonRegistryService {
|
|
|
2838
3076
|
*
|
|
2839
3077
|
* The catalog (zod `input`/`output` specs) is read STATICALLY from the
|
|
2840
3078
|
* addon module's `customActions` named export — the handler dispatches
|
|
2841
|
-
* via `
|
|
2842
|
-
*
|
|
3079
|
+
* over UDS via `LocalChildRegistry.callAddonOnChild(addonId,
|
|
3080
|
+
* {target:'custom', action, args})` (F3 — replaces the removed per-addon
|
|
3081
|
+
* Moleculer `custom.<action>` action), so the only divergence vs an
|
|
3082
|
+
* in-process addon is the transport, exactly like cap methods. The hub's
|
|
3083
|
+
* `CustomActionRegistry` validates input/output around this dispatch.
|
|
2843
3084
|
*
|
|
2844
3085
|
* Why a fresh import: `this.addonLoader`'s `module` namespace is
|
|
2845
3086
|
* captured once at boot. After a hot-update (`installFromTgz` →
|
|
@@ -2916,11 +3157,29 @@ export class AddonRegistryService {
|
|
|
2916
3157
|
this.customActionRegistry.registerAddon(
|
|
2917
3158
|
addonId,
|
|
2918
3159
|
catalog as import("@camstack/types").CustomActionsSpec,
|
|
2919
|
-
(action, input) => this.
|
|
3160
|
+
(action, input) => this.dispatchForkedCustomAction(addonId, action, input),
|
|
2920
3161
|
);
|
|
2921
3162
|
this.logger.info("Runner addon custom actions registered", {
|
|
2922
3163
|
tags: { addonId },
|
|
2923
3164
|
meta: { runnerId },
|
|
2924
3165
|
});
|
|
2925
3166
|
}
|
|
3167
|
+
|
|
3168
|
+
/**
|
|
3169
|
+
* Dispatch a forked addon's custom action through the shared
|
|
3170
|
+
* {@link AddonCallGateway} (UDS to the hub-local child). The gateway owns the
|
|
3171
|
+
* routing + the child-availability error; there is no broker fallback after
|
|
3172
|
+
* the per-addon Moleculer broker was removed.
|
|
3173
|
+
*/
|
|
3174
|
+
private async dispatchForkedCustomAction(
|
|
3175
|
+
addonId: string,
|
|
3176
|
+
action: string,
|
|
3177
|
+
input: unknown,
|
|
3178
|
+
): Promise<unknown> {
|
|
3179
|
+
return this.addonCallGateway.callForked(addonId, {
|
|
3180
|
+
target: "custom",
|
|
3181
|
+
action,
|
|
3182
|
+
args: input,
|
|
3183
|
+
});
|
|
3184
|
+
}
|
|
2926
3185
|
}
|