@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
|
@@ -1,46 +1,30 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Hub-side singleton provider for the `addon-settings` capability.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
4
|
+
* Resolves `addonId` and delegates the settings call. In-process hub builtins
|
|
5
|
+
* are called directly; every FORKED addon (hub-local child over UDS, or remote
|
|
6
|
+
* agent over Moleculer) routes through the shared {@link AddonCallGateway} —
|
|
7
|
+
* the SAME centralised router routes/custom-actions use. Settings used to live
|
|
8
|
+
* on its own Moleculer-only path here, which is why forked hub-local addons'
|
|
9
|
+
* panels went empty after the UDS migration; that path is gone.
|
|
10
10
|
*/
|
|
11
11
|
import type { ICamstackAddon, ConfigUISchemaWithValues } from '@camstack/types'
|
|
12
12
|
import type { IAddonSettingsProvider } from '@camstack/types'
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
* Minimal structural view of the Moleculer broker this provider needs
|
|
16
|
-
* (`call` + `registry`). Typed locally — mirroring the
|
|
17
|
-
* `ReconcileBrokerLike` pattern in `agent-registry.service.ts` —
|
|
18
|
-
* because the lint type-checker cannot resolve Moleculer's
|
|
19
|
-
* `ServiceBroker` type, which left every `broker.*` access `any`-typed.
|
|
20
|
-
*/
|
|
21
|
-
interface SettingsBroker {
|
|
22
|
-
readonly registry: unknown
|
|
23
|
-
call(
|
|
24
|
-
action: string,
|
|
25
|
-
params: Record<string, unknown>,
|
|
26
|
-
opts?: { readonly nodeID?: string; readonly timeout?: number },
|
|
27
|
-
): Promise<unknown>
|
|
28
|
-
}
|
|
13
|
+
import type { AddonSettingsMethod } from '@camstack/kernel'
|
|
14
|
+
import type { AddonCallGateway } from './addon-call-gateway.js'
|
|
29
15
|
|
|
30
16
|
// ── Dependency interfaces ────────────────────────────────────────────
|
|
31
17
|
|
|
32
18
|
/** Resolve an addon by id. Returns null if not loaded on this node. */
|
|
33
19
|
type AddonLookup = (addonId: string) => ICamstackAddon | null
|
|
34
20
|
|
|
35
|
-
/** Resolve which node hosts a given addon. Returns 'hub' for local. */
|
|
36
|
-
type NodeResolver = (addonId: string) => string
|
|
37
|
-
|
|
38
21
|
// ── Input types (match the cap definition's z.infer) ─────────────────
|
|
39
22
|
|
|
40
23
|
interface AddonIdInput {
|
|
41
24
|
readonly addonId: string
|
|
42
25
|
readonly nodeId?: string
|
|
43
26
|
readonly overlay?: Record<string, unknown>
|
|
27
|
+
readonly cap?: string
|
|
44
28
|
}
|
|
45
29
|
|
|
46
30
|
interface AddonPatchInput {
|
|
@@ -121,98 +105,42 @@ function isServiceNotFoundError(err: unknown): boolean {
|
|
|
121
105
|
// ── Provider factory ─────────────────────────────────────────────────
|
|
122
106
|
|
|
123
107
|
interface AddonSettingsProviderDeps {
|
|
124
|
-
/** Look up a loaded addon by id
|
|
108
|
+
/** Look up a loaded IN-PROCESS addon by id (the `@camstack/core` builtins). */
|
|
125
109
|
readonly getAddon: AddonLookup
|
|
126
|
-
/**
|
|
127
|
-
readonly
|
|
128
|
-
/** Moleculer broker for remote calls. */
|
|
129
|
-
readonly broker: SettingsBroker
|
|
130
|
-
/** This hub's node ID (for local short-circuit). */
|
|
131
|
-
readonly hubNodeId: string
|
|
110
|
+
/** Shared router for forked addon-level calls (hub-local-child / remote). */
|
|
111
|
+
readonly gateway: AddonCallGateway
|
|
132
112
|
}
|
|
133
113
|
|
|
134
114
|
function createAddonSettingsProvider(deps: AddonSettingsProviderDeps): IAddonSettingsProvider {
|
|
135
|
-
const { getAddon,
|
|
136
|
-
|
|
137
|
-
function isLocal(addonId: string, explicitNodeId?: string): boolean {
|
|
138
|
-
// `resolveNode` is consulted FIRST: it knows whether the addon
|
|
139
|
-
// runs in-process on the hub or as a forkable worker. An explicit
|
|
140
|
-
// `nodeId: 'hub'` from the caller is treated as "target the hub
|
|
141
|
-
// cluster" — NOT "force local dispatch" — so for forkable addons
|
|
142
|
-
// we still route via Moleculer to `hub/<addonId>`. Without this
|
|
143
|
-
// check the hub-side stub (which never had `initialize()` run and
|
|
144
|
-
// has no `_ctx.settings`) would respond with defaults only and
|
|
145
|
-
// silently drop every `updateGlobalSettings` patch.
|
|
146
|
-
const resolved = resolveNode(addonId)
|
|
147
|
-
if (resolved !== 'hub' && resolved !== hubNodeId) return false
|
|
148
|
-
const nodeId = explicitNodeId ?? resolved
|
|
149
|
-
return nodeId === 'hub' || nodeId === hubNodeId
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
// ── Remote call helpers ──────────────────────────────────────────
|
|
153
|
-
|
|
154
|
-
// Resolve the Moleculer nodeID that actually hosts an addon's
|
|
155
|
-
// `<addonId>.settings.*` service. Forkable addons register under
|
|
156
|
-
// `${baseNodeId}/${addonId}`; in-process addons register under the
|
|
157
|
-
// base nodeId itself. Walk the Moleculer registry and pick the
|
|
158
|
-
// first node whose id matches either convention.
|
|
159
|
-
function resolveWorkerNodeId(addonId: string, baseNodeId: string): string | null {
|
|
160
|
-
const registry = broker.registry
|
|
161
|
-
const services = (registry as unknown as {
|
|
162
|
-
getServiceList: (opts: { onlyAvailable: boolean }) => readonly { name: string; nodeID: string }[]
|
|
163
|
-
}).getServiceList({ onlyAvailable: true })
|
|
164
|
-
// Find the node that actually hosts the `<addonId>` service (the
|
|
165
|
-
// moleculer service factory registers addons under their id). The
|
|
166
|
-
// baseNodeId is a hint — we trust the registry's ground truth
|
|
167
|
-
// instead. Forkable addons live at `<parent>/<addonId>`;
|
|
168
|
-
// in-process addons live at `<parent>`.
|
|
169
|
-
const exactNode = `${baseNodeId}/${addonId}`
|
|
170
|
-
const preferred = services.find((s) => s.name === addonId && s.nodeID === exactNode)
|
|
171
|
-
if (preferred) return preferred.nodeID
|
|
172
|
-
const anyForBase = services.find((s) => s.name === addonId && s.nodeID === baseNodeId)
|
|
173
|
-
if (anyForBase) return anyForBase.nodeID
|
|
174
|
-
const anyWithName = services.find((s) => s.name === addonId)
|
|
175
|
-
return anyWithName?.nodeID ?? null
|
|
176
|
-
}
|
|
115
|
+
const { getAddon, gateway } = deps
|
|
177
116
|
|
|
178
|
-
|
|
117
|
+
// Forked READ — route through the shared gateway (UDS hub-local-child OR
|
|
118
|
+
// Moleculer remote agent). Degrades to null when the forked addon can't
|
|
119
|
+
// answer yet (mid-boot / missing service) so the panel shows its empty state
|
|
120
|
+
// instead of bubbling a 500.
|
|
121
|
+
async function forkedGet(
|
|
179
122
|
addonId: string,
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
123
|
+
method: AddonSettingsMethod,
|
|
124
|
+
args: Record<string, unknown>,
|
|
125
|
+
nodeId?: string,
|
|
183
126
|
): Promise<ReshapedSchema | null> {
|
|
184
|
-
const params = { ...extraParams }
|
|
185
|
-
const workerNodeId = resolveWorkerNodeId(addonId, nodeId)
|
|
186
127
|
try {
|
|
187
|
-
const result
|
|
188
|
-
`${addonId}.settings.${action}`,
|
|
189
|
-
params,
|
|
190
|
-
workerNodeId ? { nodeID: workerNodeId, timeout: 10_000 } : { timeout: 10_000 },
|
|
191
|
-
)
|
|
128
|
+
const result = await gateway.callForked(addonId, { target: 'settings', method, args }, nodeId)
|
|
192
129
|
return result ? reshapeForOutput(result as ConfigUISchemaWithValues) : null
|
|
193
130
|
} catch (err) {
|
|
194
|
-
// A forked addon that is still booting (or wedged in
|
|
195
|
-
// `onInitialize`) has not yet registered its
|
|
196
|
-
// `<addonId>.settings.*` Moleculer service. A settings READ
|
|
197
|
-
// against a missing service should degrade to "no schema" so the
|
|
198
|
-
// panel shows its empty state — NOT bubble a 500 to the operator.
|
|
199
131
|
if (isServiceNotFoundError(err)) return null
|
|
200
132
|
throw err
|
|
201
133
|
}
|
|
202
134
|
}
|
|
203
135
|
|
|
204
|
-
|
|
136
|
+
// Forked UPDATE — route through the shared gateway; returns the ack.
|
|
137
|
+
async function forkedUpdate(
|
|
205
138
|
addonId: string,
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
139
|
+
method: AddonSettingsMethod,
|
|
140
|
+
args: Record<string, unknown>,
|
|
141
|
+
nodeId?: string,
|
|
209
142
|
): Promise<SettingsUpdateResult> {
|
|
210
|
-
|
|
211
|
-
await broker.call(
|
|
212
|
-
`${addonId}.settings.${action}`,
|
|
213
|
-
extraParams,
|
|
214
|
-
workerNodeId ? { nodeID: workerNodeId, timeout: 10_000 } : { timeout: 10_000 },
|
|
215
|
-
)
|
|
143
|
+
await gateway.callForked(addonId, { target: 'settings', method, args }, nodeId)
|
|
216
144
|
return { success: true as const }
|
|
217
145
|
}
|
|
218
146
|
|
|
@@ -220,18 +148,20 @@ function createAddonSettingsProvider(deps: AddonSettingsProviderDeps): IAddonSet
|
|
|
220
148
|
|
|
221
149
|
return {
|
|
222
150
|
async getGlobalSettings(input: AddonIdInput) {
|
|
223
|
-
if (
|
|
151
|
+
if (gateway.isInProcess(input.addonId, input.nodeId)) {
|
|
224
152
|
const addon = getAddon(input.addonId)
|
|
225
153
|
if (!addon || typeof addon.getGlobalSettings !== 'function') return null
|
|
226
|
-
const result = await addon.getGlobalSettings(input.overlay)
|
|
154
|
+
const result = await addon.getGlobalSettings(input.overlay, input.cap)
|
|
227
155
|
return result ? reshapeForOutput(result) : null
|
|
228
156
|
}
|
|
229
|
-
|
|
230
|
-
|
|
157
|
+
return forkedGet(input.addonId, 'getGlobalSettings', {
|
|
158
|
+
...(input.overlay ? { overlay: input.overlay } : {}),
|
|
159
|
+
...(input.cap ? { cap: input.cap } : {}),
|
|
160
|
+
}, input.nodeId)
|
|
231
161
|
},
|
|
232
162
|
|
|
233
163
|
async updateGlobalSettings(input: AddonPatchInput) {
|
|
234
|
-
if (
|
|
164
|
+
if (gateway.isInProcess(input.addonId, input.nodeId)) {
|
|
235
165
|
const addon = getAddon(input.addonId)
|
|
236
166
|
if (!addon || typeof addon.updateGlobalSettings !== 'function') {
|
|
237
167
|
throw new Error(`Addon "${input.addonId}" does not implement updateGlobalSettings`)
|
|
@@ -239,23 +169,21 @@ function createAddonSettingsProvider(deps: AddonSettingsProviderDeps): IAddonSet
|
|
|
239
169
|
await addon.updateGlobalSettings(input.patch)
|
|
240
170
|
return { success: true as const }
|
|
241
171
|
}
|
|
242
|
-
|
|
243
|
-
return remoteUpdate(input.addonId, nodeId, 'updateGlobalSettings', { patch: input.patch })
|
|
172
|
+
return forkedUpdate(input.addonId, 'updateGlobalSettings', { patch: input.patch }, input.nodeId)
|
|
244
173
|
},
|
|
245
174
|
|
|
246
175
|
async getDeviceSettings(input: DeviceGetInput) {
|
|
247
|
-
if (
|
|
176
|
+
if (gateway.isInProcess(input.addonId, input.nodeId)) {
|
|
248
177
|
const addon = getAddon(input.addonId)
|
|
249
178
|
if (!addon || typeof addon.getDeviceSettings !== 'function') return null
|
|
250
179
|
const result = await addon.getDeviceSettings(input.deviceId)
|
|
251
180
|
return result ? reshapeForOutput(result) : null
|
|
252
181
|
}
|
|
253
|
-
|
|
254
|
-
return remoteGet(input.addonId, nodeId, 'getDeviceSettings', { deviceId: input.deviceId })
|
|
182
|
+
return forkedGet(input.addonId, 'getDeviceSettings', { deviceId: input.deviceId }, input.nodeId)
|
|
255
183
|
},
|
|
256
184
|
|
|
257
185
|
async updateDeviceSettings(input: DeviceUpdateInput) {
|
|
258
|
-
if (
|
|
186
|
+
if (gateway.isInProcess(input.addonId, input.nodeId)) {
|
|
259
187
|
const addon = getAddon(input.addonId)
|
|
260
188
|
if (!addon || typeof addon.updateDeviceSettings !== 'function') {
|
|
261
189
|
throw new Error(`Addon "${input.addonId}" does not implement updateDeviceSettings`)
|
|
@@ -263,11 +191,7 @@ function createAddonSettingsProvider(deps: AddonSettingsProviderDeps): IAddonSet
|
|
|
263
191
|
await addon.updateDeviceSettings(input.deviceId, input.patch)
|
|
264
192
|
return { success: true as const }
|
|
265
193
|
}
|
|
266
|
-
|
|
267
|
-
return remoteUpdate(input.addonId, nodeId, 'updateDeviceSettings', {
|
|
268
|
-
deviceId: input.deviceId,
|
|
269
|
-
patch: input.patch,
|
|
270
|
-
})
|
|
194
|
+
return forkedUpdate(input.addonId, 'updateDeviceSettings', { deviceId: input.deviceId, patch: input.patch }, input.nodeId)
|
|
271
195
|
},
|
|
272
196
|
}
|
|
273
197
|
}
|
|
@@ -25,6 +25,15 @@ export class CapabilityService {
|
|
|
25
25
|
return this.registry?.getSingleton<T>(capability) ?? null
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
/**
|
|
29
|
+
* Resolve the hub-local provider honoring the 'hub' per-node singleton override.
|
|
30
|
+
* Delegates to `CapabilityRegistry.getSingletonForNode` so the in-process lookup
|
|
31
|
+
* respects per-node overrides set by the operator.
|
|
32
|
+
*/
|
|
33
|
+
getSingletonForNode<T>(capability: string, nodeId: string): T | null {
|
|
34
|
+
return this.registry?.getSingletonForNode<T>(capability, nodeId) ?? null
|
|
35
|
+
}
|
|
36
|
+
|
|
28
37
|
/** Get the addon ID of the active singleton provider for a capability */
|
|
29
38
|
getSingletonAddonId(capability: string): string | null {
|
|
30
39
|
return this.registry?.getSingletonAddonId(capability) ?? null
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest'
|
|
2
|
+
import type { CapRoute, CapCallInput } from '@camstack/kernel'
|
|
3
|
+
import {
|
|
4
|
+
buildCapCallFn,
|
|
5
|
+
type CapCallFnLocalChild,
|
|
6
|
+
type CapCallFnResolver,
|
|
7
|
+
} from './cap-call-fn.js'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* buildCapCallFn — the per-(cap,node) dispatcher behind every CapabilityRegistry
|
|
11
|
+
* provider proxy. These specs lock every routing branch so the UDS-migration
|
|
12
|
+
* gap (an agent-hosted provider's cap call falling onto a `broker.call` to a
|
|
13
|
+
* Moleculer node that does not exist → 30s `waitForServices` timeout) cannot
|
|
14
|
+
* regress:
|
|
15
|
+
* - hub-local child that provides → per-child UDS (collection-safe)
|
|
16
|
+
* - hub-local child that does NOT provide → fail fast (NEVER Moleculer)
|
|
17
|
+
* - agent-hosted / remote → delegate to the unified CapRouteResolver
|
|
18
|
+
* - resolver not built yet → legacy broker fallback
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const REMOTE_ROUTE: CapRoute = { kind: 'remote-moleculer', capName: 'cap-x', nodeId: 'dev-agent-0' }
|
|
22
|
+
|
|
23
|
+
function recordingLocalChild(provides: boolean): {
|
|
24
|
+
fake: CapCallFnLocalChild
|
|
25
|
+
childProvidesCalls: Array<{ childId: string; capName: string; deviceId?: number }>
|
|
26
|
+
callCapOnChildCalls: Array<{ childId: string; input: CapCallInput }>
|
|
27
|
+
} {
|
|
28
|
+
const childProvidesCalls: Array<{ childId: string; capName: string; deviceId?: number }> = []
|
|
29
|
+
const callCapOnChildCalls: Array<{ childId: string; input: CapCallInput }> = []
|
|
30
|
+
return {
|
|
31
|
+
childProvidesCalls,
|
|
32
|
+
callCapOnChildCalls,
|
|
33
|
+
fake: {
|
|
34
|
+
childProvides: (childId, capName, deviceId) => {
|
|
35
|
+
childProvidesCalls.push({ childId, capName, deviceId })
|
|
36
|
+
return provides
|
|
37
|
+
},
|
|
38
|
+
callCapOnChild: async (childId, input) => {
|
|
39
|
+
callCapOnChildCalls.push({ childId, input })
|
|
40
|
+
return { from: 'uds' }
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function recordingResolver(): {
|
|
47
|
+
fake: CapCallFnResolver
|
|
48
|
+
resolveCalls: Array<{ capName: string; nodeId?: string; deviceId?: number }>
|
|
49
|
+
dispatchCalls: Array<{ route: CapRoute; method: string; args: unknown }>
|
|
50
|
+
} {
|
|
51
|
+
const resolveCalls: Array<{ capName: string; nodeId?: string; deviceId?: number }> = []
|
|
52
|
+
const dispatchCalls: Array<{ route: CapRoute; method: string; args: unknown }> = []
|
|
53
|
+
return {
|
|
54
|
+
resolveCalls,
|
|
55
|
+
dispatchCalls,
|
|
56
|
+
fake: {
|
|
57
|
+
resolveCapRoute: (capName, opts) => {
|
|
58
|
+
resolveCalls.push({ capName, nodeId: opts.nodeId, deviceId: opts.deviceId })
|
|
59
|
+
return REMOTE_ROUTE
|
|
60
|
+
},
|
|
61
|
+
dispatch: async (route, method, args) => {
|
|
62
|
+
dispatchCalls.push({ route, method, args })
|
|
63
|
+
return { from: 'resolver' }
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
describe('buildCapCallFn', () => {
|
|
70
|
+
it('hub-local child that provides the cap → routes per-child over UDS', async () => {
|
|
71
|
+
const child = recordingLocalChild(true)
|
|
72
|
+
const resolver = recordingResolver()
|
|
73
|
+
const legacy = vi.fn<(m: string, p: unknown, n: string) => Promise<unknown>>()
|
|
74
|
+
const fn = buildCapCallFn({
|
|
75
|
+
capName: 'cap-x',
|
|
76
|
+
nodeId: 'hub/benchmark',
|
|
77
|
+
udsChildId: 'benchmark',
|
|
78
|
+
getLocalChildRegistry: () => child.fake,
|
|
79
|
+
getResolver: () => resolver.fake,
|
|
80
|
+
legacyBrokerCall: legacy,
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
const result = await fn('listPages', { deviceId: 7 })
|
|
84
|
+
|
|
85
|
+
expect(result).toEqual({ from: 'uds' })
|
|
86
|
+
expect(child.callCapOnChildCalls).toEqual([
|
|
87
|
+
{ childId: 'benchmark', input: { capName: 'cap-x', method: 'listPages', args: { deviceId: 7 }, deviceId: 7 } },
|
|
88
|
+
])
|
|
89
|
+
expect(resolver.resolveCalls).toEqual([]) // resolver never consulted
|
|
90
|
+
expect(legacy).not.toHaveBeenCalled()
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('hub-local child that does NOT provide → fails fast, never touches Moleculer', async () => {
|
|
94
|
+
const child = recordingLocalChild(false)
|
|
95
|
+
const resolver = recordingResolver()
|
|
96
|
+
const legacy = vi.fn<(m: string, p: unknown, n: string) => Promise<unknown>>()
|
|
97
|
+
const fn = buildCapCallFn({
|
|
98
|
+
capName: 'cap-x',
|
|
99
|
+
nodeId: 'hub/benchmark',
|
|
100
|
+
udsChildId: 'benchmark',
|
|
101
|
+
getLocalChildRegistry: () => child.fake,
|
|
102
|
+
getResolver: () => resolver.fake,
|
|
103
|
+
legacyBrokerCall: legacy,
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
await expect(fn('listPages', undefined)).rejects.toThrow(/does not currently provide/)
|
|
107
|
+
expect(child.callCapOnChildCalls).toEqual([])
|
|
108
|
+
expect(resolver.resolveCalls).toEqual([])
|
|
109
|
+
expect(resolver.dispatchCalls).toEqual([])
|
|
110
|
+
expect(legacy).not.toHaveBeenCalled()
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('agent-hosted provider → delegates to the resolver (agent-child-forward)', async () => {
|
|
114
|
+
const resolver = recordingResolver()
|
|
115
|
+
const legacy = vi.fn<(m: string, p: unknown, n: string) => Promise<unknown>>()
|
|
116
|
+
const fn = buildCapCallFn({
|
|
117
|
+
capName: 'cap-x',
|
|
118
|
+
nodeId: 'dev-agent-0',
|
|
119
|
+
udsChildId: null, // not hub-local
|
|
120
|
+
getLocalChildRegistry: () => null,
|
|
121
|
+
getResolver: () => resolver.fake,
|
|
122
|
+
legacyBrokerCall: legacy,
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
const result = await fn('listPages', { deviceId: 3 })
|
|
126
|
+
|
|
127
|
+
expect(result).toEqual({ from: 'resolver' })
|
|
128
|
+
expect(resolver.resolveCalls).toEqual([{ capName: 'cap-x', nodeId: 'dev-agent-0', deviceId: 3 }])
|
|
129
|
+
expect(resolver.dispatchCalls).toEqual([{ route: REMOTE_ROUTE, method: 'listPages', args: { deviceId: 3 } }])
|
|
130
|
+
expect(legacy).not.toHaveBeenCalled()
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it('explicit targetNodeId overrides the registered nodeId in resolution', async () => {
|
|
134
|
+
const resolver = recordingResolver()
|
|
135
|
+
const legacy = vi.fn<(m: string, p: unknown, n: string) => Promise<unknown>>()
|
|
136
|
+
const fn = buildCapCallFn({
|
|
137
|
+
capName: 'cap-x',
|
|
138
|
+
nodeId: 'dev-agent-0',
|
|
139
|
+
udsChildId: null,
|
|
140
|
+
getLocalChildRegistry: () => null,
|
|
141
|
+
getResolver: () => resolver.fake,
|
|
142
|
+
legacyBrokerCall: legacy,
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
await fn('listPages', undefined, 'dev-agent-1')
|
|
146
|
+
|
|
147
|
+
expect(resolver.resolveCalls).toEqual([{ capName: 'cap-x', nodeId: 'dev-agent-1', deviceId: undefined }])
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
it('resolver not yet built → falls back to the legacy broker call', async () => {
|
|
151
|
+
const legacy = vi.fn<(m: string, p: unknown, n: string) => Promise<unknown>>(async () => ({ from: 'legacy' }))
|
|
152
|
+
const fn = buildCapCallFn({
|
|
153
|
+
capName: 'cap-x',
|
|
154
|
+
nodeId: 'dev-agent-0',
|
|
155
|
+
udsChildId: null,
|
|
156
|
+
getLocalChildRegistry: () => null,
|
|
157
|
+
getResolver: () => null, // pre-init window
|
|
158
|
+
legacyBrokerCall: legacy,
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
const result = await fn('listPages', { deviceId: 1 })
|
|
162
|
+
|
|
163
|
+
expect(result).toEqual({ from: 'legacy' })
|
|
164
|
+
expect(legacy).toHaveBeenCalledWith('listPages', { deviceId: 1 }, 'dev-agent-0')
|
|
165
|
+
})
|
|
166
|
+
})
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cap-call-fn — the per-(cap, node) dispatcher behind every CapabilityRegistry
|
|
3
|
+
* provider proxy the hub builds in `applyNodeManifest`.
|
|
4
|
+
*
|
|
5
|
+
* Extracted from `MoleculerService` so its routing branches are unit-testable
|
|
6
|
+
* in isolation (the closure that lived inline could only be exercised by
|
|
7
|
+
* standing up a full broker). It closes a UDS-migration gap: the inline
|
|
8
|
+
* version hand-rolled a `broker.call` for any non-hub-local provider, so an
|
|
9
|
+
* AGENT-hosted addon cap (a UDS child of the agent, NOT a Moleculer service)
|
|
10
|
+
* resolved to a Moleculer node that does not exist → `waitForServices` waited
|
|
11
|
+
* its full 30s discovery timeout → "Services waiting is timed out". The fix
|
|
12
|
+
* routes everything that isn't a hub-local child through the unified
|
|
13
|
+
* `CapRouteResolver`, which classifies an agent node as `agent-child-forward`
|
|
14
|
+
* (hub → agent over Moleculer → agent's UDS child) and a direct remote as
|
|
15
|
+
* `remote-moleculer`.
|
|
16
|
+
*/
|
|
17
|
+
import type { CallFn, CapRoute, CapRouteOpts, CapCallInput } from '@camstack/kernel'
|
|
18
|
+
|
|
19
|
+
/** Minimal LocalChildRegistry surface this dispatcher needs (per-child UDS). */
|
|
20
|
+
export interface CapCallFnLocalChild {
|
|
21
|
+
childProvides(childId: string, capName: string, deviceId?: number): boolean
|
|
22
|
+
callCapOnChild(childId: string, input: CapCallInput): Promise<unknown>
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Minimal CapRouteResolver surface this dispatcher needs. */
|
|
26
|
+
export interface CapCallFnResolver {
|
|
27
|
+
resolveCapRoute(capName: string, opts: CapRouteOpts): CapRoute
|
|
28
|
+
dispatch(route: CapRoute, method: string, args: unknown): Promise<unknown>
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface CapCallFnDeps {
|
|
32
|
+
/** The capability this dispatcher routes. */
|
|
33
|
+
readonly capName: string
|
|
34
|
+
/** The registering node's id (the agent node for agent-hosted providers). */
|
|
35
|
+
readonly nodeId: string
|
|
36
|
+
/**
|
|
37
|
+
* Runner id of the hub-local UDS child that owns this provider, or `null`
|
|
38
|
+
* for agent-hosted / remote providers. Only hub-local children are reachable
|
|
39
|
+
* over UDS.
|
|
40
|
+
*/
|
|
41
|
+
readonly udsChildId: string | null
|
|
42
|
+
/** Live getter for the UDS child registry (`null` if the UDS server is down). */
|
|
43
|
+
readonly getLocalChildRegistry: () => CapCallFnLocalChild | null
|
|
44
|
+
/** Live getter for the resolver (`null` before `onModuleInit` builds it). */
|
|
45
|
+
readonly getResolver: () => CapCallFnResolver | null
|
|
46
|
+
/** Legacy Moleculer call — used ONLY in the pre-init window (no resolver yet). */
|
|
47
|
+
readonly legacyBrokerCall: (method: string, params: unknown, targetNodeId: string) => Promise<unknown>
|
|
48
|
+
/** Optional diagnostic hook fired the first time this cap routes over UDS. */
|
|
49
|
+
readonly onUdsRoute?: (capName: string) => void
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Extract a numeric `deviceId` routing hint out of arbitrary method args. */
|
|
53
|
+
function extractDeviceId(params: unknown): number | undefined {
|
|
54
|
+
if (params === null || typeof params !== 'object') return undefined
|
|
55
|
+
const raw: unknown = Reflect.get(params, 'deviceId')
|
|
56
|
+
return typeof raw === 'number' ? raw : undefined
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Build the `CallFn` (`(method, params, targetNodeId?) => Promise`) for one
|
|
61
|
+
* registered provider. See module docs for the routing rationale.
|
|
62
|
+
*/
|
|
63
|
+
export function buildCapCallFn(deps: CapCallFnDeps): CallFn {
|
|
64
|
+
return async (method: string, params: unknown, targetNodeId?: string): Promise<unknown> => {
|
|
65
|
+
const deviceId = extractDeviceId(params)
|
|
66
|
+
|
|
67
|
+
// ── Hub-local child: route per-child over UDS (collection-safe — keyed by
|
|
68
|
+
// the specific child, not by capName which would collapse a collection
|
|
69
|
+
// cap onto the first child). NEVER fall back to Moleculer: a hub-local
|
|
70
|
+
// child is not a Moleculer service, so a broker call would wait the full
|
|
71
|
+
// discovery timeout for a node that never appears. If the child isn't
|
|
72
|
+
// currently providing the cap, fail fast.
|
|
73
|
+
if (deps.udsChildId !== null && targetNodeId === undefined) {
|
|
74
|
+
const registry = deps.getLocalChildRegistry()
|
|
75
|
+
if (registry !== null && registry.childProvides(deps.udsChildId, deps.capName, deviceId)) {
|
|
76
|
+
deps.onUdsRoute?.(deps.capName)
|
|
77
|
+
const input: CapCallInput = {
|
|
78
|
+
capName: deps.capName,
|
|
79
|
+
method,
|
|
80
|
+
args: params,
|
|
81
|
+
...(deviceId !== undefined ? { deviceId } : {}),
|
|
82
|
+
}
|
|
83
|
+
return registry.callCapOnChild(deps.udsChildId, input)
|
|
84
|
+
}
|
|
85
|
+
throw new Error(
|
|
86
|
+
`hub-local child "${deps.udsChildId}" does not currently provide cap "${deps.capName}"`,
|
|
87
|
+
)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ── Agent-hosted or explicit remote: delegate to the unified resolver.
|
|
91
|
+
const resolver = deps.getResolver()
|
|
92
|
+
if (resolver !== null) {
|
|
93
|
+
const route = resolver.resolveCapRoute(deps.capName, {
|
|
94
|
+
nodeId: targetNodeId ?? deps.nodeId,
|
|
95
|
+
...(deviceId !== undefined ? { deviceId } : {}),
|
|
96
|
+
})
|
|
97
|
+
return resolver.dispatch(route, method, params)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ── Pre-init window (resolver not yet constructed): legacy Moleculer call.
|
|
101
|
+
return deps.legacyBrokerCall(method, params, targetNodeId ?? deps.nodeId)
|
|
102
|
+
}
|
|
103
|
+
}
|