@camstack/server 0.1.6 → 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.
Files changed (42) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/addon-upload.spec.ts +58 -0
  3. package/src/__tests__/bulk-update-coordinator.spec.ts +286 -0
  4. package/src/__tests__/cap-ownership-authority.spec.ts +400 -0
  5. package/src/__tests__/cap-providers-bulk-update.spec.ts +388 -0
  6. package/src/__tests__/cap-route-adapter.spec.ts +289 -0
  7. package/src/__tests__/cap-routers/cap-route-error-formatter.spec.ts +123 -0
  8. package/src/__tests__/cap-routers/capabilities-node.spec.ts +55 -0
  9. package/src/__tests__/dev-bootstrap-shm-ring.spec.ts +30 -0
  10. package/src/__tests__/device-settings-contribution-dispatch.spec.ts +249 -0
  11. package/src/__tests__/framework-installer-defer-restart.spec.ts +165 -0
  12. package/src/__tests__/moleculer/uds-readiness.spec.ts +143 -0
  13. package/src/__tests__/moleculer/uds-topology.spec.ts +390 -0
  14. package/src/__tests__/moleculer/uds-unowned-call.spec.ts +123 -0
  15. package/src/__tests__/moleculer-register-node-idempotency.spec.ts +39 -4
  16. package/src/__tests__/native-cap-route.spec.ts +404 -0
  17. package/src/__tests__/oauth2-account-linking.spec.ts +85 -0
  18. package/src/__tests__/uds-addon-call-wiring.spec.ts +237 -0
  19. package/src/__tests__/uds-log-ingest.spec.ts +183 -0
  20. package/src/api/addon-upload.ts +27 -1
  21. package/src/api/capabilities.router.ts +1 -1
  22. package/src/api/core/bulk-update-coordinator.ts +302 -0
  23. package/src/api/core/cap-providers.ts +59 -6
  24. package/src/api/core/capabilities.router.ts +26 -3
  25. package/src/api/oauth2/oauth2-routes.ts +5 -1
  26. package/src/api/trpc/__tests__/client-ip.spec.ts +120 -0
  27. package/src/api/trpc/cap-route-error-formatter.ts +163 -0
  28. package/src/api/trpc/client-ip.ts +130 -0
  29. package/src/api/trpc/generated-cap-mounts.ts +19 -1
  30. package/src/api/trpc/generated-cap-routers.ts +180 -1
  31. package/src/api/trpc/trpc.middleware.ts +5 -1
  32. package/src/api/trpc/trpc.router.ts +45 -0
  33. package/src/core/addon/addon-call-gateway.ts +157 -0
  34. package/src/core/addon/addon-package.service.ts +9 -0
  35. package/src/core/addon/addon-registry.service.ts +364 -105
  36. package/src/core/addon/addon-settings-provider.ts +40 -116
  37. package/src/core/capability/capability.service.ts +9 -0
  38. package/src/core/moleculer/cap-call-fn.spec.ts +166 -0
  39. package/src/core/moleculer/cap-call-fn.ts +103 -0
  40. package/src/core/moleculer/cap-route-authority.ts +182 -0
  41. package/src/core/moleculer/moleculer.service.ts +380 -36
  42. 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
- * Acts as a gateway: resolves `addonId` to the target addon and
5
- * delegates the settings call. For hub-local addons, calls the
6
- * addon instance directly. For remote agents, proxies via the
7
- * per-addon Moleculer service (`<addonId>.settings.<method>`).
8
- *
9
- * Replaces the `$addonHost` Moleculer service.
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 on this hub node. */
108
+ /** Look up a loaded IN-PROCESS addon by id (the `@camstack/core` builtins). */
125
109
  readonly getAddon: AddonLookup
126
- /** Resolve which node hosts a given addon. Defaults to 'hub'. */
127
- readonly resolveNode: NodeResolver
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, resolveNode, broker, hubNodeId } = deps
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
- async function remoteGet(
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
- nodeId: string,
181
- action: string,
182
- extraParams?: Record<string, unknown>,
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: unknown = await broker.call(
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
- async function remoteUpdate(
136
+ // Forked UPDATE — route through the shared gateway; returns the ack.
137
+ async function forkedUpdate(
205
138
  addonId: string,
206
- nodeId: string,
207
- action: string,
208
- extraParams: Record<string, unknown>,
139
+ method: AddonSettingsMethod,
140
+ args: Record<string, unknown>,
141
+ nodeId?: string,
209
142
  ): Promise<SettingsUpdateResult> {
210
- const workerNodeId = resolveWorkerNodeId(addonId, nodeId)
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 (isLocal(input.addonId, input.nodeId)) {
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
- const nodeId = input.nodeId ?? resolveNode(input.addonId)
230
- return remoteGet(input.addonId, nodeId, 'getGlobalSettings', { ...(input.overlay ? { overlay: input.overlay } : {}) })
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 (isLocal(input.addonId, input.nodeId)) {
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
- const nodeId = input.nodeId ?? resolveNode(input.addonId)
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 (isLocal(input.addonId, input.nodeId)) {
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
- const nodeId = input.nodeId ?? resolveNode(input.addonId)
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 (isLocal(input.addonId, input.nodeId)) {
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
- const nodeId = input.nodeId ?? resolveNode(input.addonId)
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
+ }