@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.
- 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
|
@@ -3,8 +3,12 @@ import superjson from 'superjson'
|
|
|
3
3
|
import { METHOD_ACCESS_MAP } from '@camstack/types'
|
|
4
4
|
import type { TrpcContext } from './trpc.context'
|
|
5
5
|
import { checkScopeAccess } from './scope-access.js'
|
|
6
|
+
import { formatTrpcError } from './cap-route-error-formatter.js'
|
|
6
7
|
|
|
7
|
-
const t = initTRPC.context<TrpcContext>().create({
|
|
8
|
+
const t = initTRPC.context<TrpcContext>().create({
|
|
9
|
+
transformer: superjson,
|
|
10
|
+
errorFormatter: formatTrpcError,
|
|
11
|
+
})
|
|
8
12
|
|
|
9
13
|
// ---------------------------------------------------------------------------
|
|
10
14
|
// Async-generator subscription helpers (tRPC v11 — replaces deprecated observable)
|
|
@@ -30,6 +30,7 @@ import {
|
|
|
30
30
|
platformProbeCapability,
|
|
31
31
|
decoderCapability,
|
|
32
32
|
localNetworkCapability,
|
|
33
|
+
webrtcSessionCapability,
|
|
33
34
|
} from '@camstack/types'
|
|
34
35
|
import {
|
|
35
36
|
// The auto-mount covers ~75 caps. The handful re-imported below back
|
|
@@ -51,6 +52,7 @@ import {
|
|
|
51
52
|
createCapRouter_integrations,
|
|
52
53
|
createCapRouter_nodes,
|
|
53
54
|
createCapRouter_addons,
|
|
55
|
+
createCapRouter_webrtcSession,
|
|
54
56
|
} from './generated-cap-routers'
|
|
55
57
|
import { mountAllCaps } from './generated-cap-mounts.js'
|
|
56
58
|
import {
|
|
@@ -74,6 +76,7 @@ import { createCapabilitiesRouter } from '../core/capabilities.router.js'
|
|
|
74
76
|
import { createStreamProbeRouter } from '../core/stream-probe.router.js'
|
|
75
77
|
import { createHwAccelRouter } from '../core/hwaccel.router.js'
|
|
76
78
|
import { requireSingleton, firstSupported, anySupports } from './cap-mount-helpers.js'
|
|
79
|
+
import type { TrpcContext } from './trpc.context.js'
|
|
77
80
|
import type { AuthService } from '../../core/auth/auth.service'
|
|
78
81
|
import type { ConfigService } from '../../core/config/config.service'
|
|
79
82
|
import type { FeatureService } from '../../core/feature/feature.service'
|
|
@@ -108,6 +111,28 @@ export interface RouterServices {
|
|
|
108
111
|
streamProbe: StreamProbeService | null
|
|
109
112
|
}
|
|
110
113
|
|
|
114
|
+
type WebrtcSessionProvider = InferProvider<typeof webrtcSessionCapability>
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Relay-only forcing for remote viewers is DISABLED (2026-05-26).
|
|
118
|
+
*
|
|
119
|
+
* It was meant to give CGNAT/4G viewers a clean relay↔relay path, but werift's
|
|
120
|
+
* TURN media-forward is unreliable between two real TURN servers (relay↔relay
|
|
121
|
+
* connects yet media never arrives → connected-but-black), and forcing relay
|
|
122
|
+
* ALSO kills the direct LAN/Tailscale host pair — which carries full native
|
|
123
|
+
* quality with no relay. We now offer ALL candidates (host incl. the hub's
|
|
124
|
+
* advertised Tailscale address, srflx, relay) and let ICE nominate the best
|
|
125
|
+
* reachable pair: direct when possible, relay only as a fallback. The
|
|
126
|
+
* `relayOnly` cap field + broker support remain for when relay media-forward
|
|
127
|
+
* is fixed; this wrapper is a pass-through for now.
|
|
128
|
+
*/
|
|
129
|
+
function wrapWebrtcSessionProviderWithRelay(
|
|
130
|
+
provider: WebrtcSessionProvider,
|
|
131
|
+
_ctx: TrpcContext,
|
|
132
|
+
): WebrtcSessionProvider {
|
|
133
|
+
return provider
|
|
134
|
+
}
|
|
135
|
+
|
|
111
136
|
/**
|
|
112
137
|
* Build the AppRouter. Mounts every codegen'd cap router via the auto-
|
|
113
138
|
* mount entrypoint and overrides the handful that need service-backed
|
|
@@ -183,6 +208,7 @@ function buildCapabilityRouters(services: RouterServices) {
|
|
|
183
208
|
services.moleculer,
|
|
184
209
|
services.configService,
|
|
185
210
|
ctx,
|
|
211
|
+
services.eventBus,
|
|
186
212
|
),
|
|
187
213
|
),
|
|
188
214
|
|
|
@@ -253,6 +279,25 @@ function buildCapabilityRouters(services: RouterServices) {
|
|
|
253
279
|
},
|
|
254
280
|
),
|
|
255
281
|
|
|
282
|
+
// ── Cap override: server-detected remote → relay-only ────────────
|
|
283
|
+
// The broker (a forked addon) can't see the HTTP request, so it
|
|
284
|
+
// can't tell a LAN viewer from a remote one. We override only the
|
|
285
|
+
// `getProvider` accessor to return a per-request provider whose
|
|
286
|
+
// `createSession` carries a server-computed `relayOnly` flag derived
|
|
287
|
+
// from the client IP in `ctx.req`. Remote (CGNAT/4G) viewers force
|
|
288
|
+
// TURN-relay-only ICE; LAN viewers keep the direct host/srflx path.
|
|
289
|
+
// All other methods delegate straight through, and the cross-node
|
|
290
|
+
// remote-proxy routing is preserved (forked/agent-hosted brokers).
|
|
291
|
+
webrtcSession: createCapRouter_webrtcSession(
|
|
292
|
+
(ctx) => {
|
|
293
|
+
const provider = services.capabilityRegistry
|
|
294
|
+
?.getSingleton<WebrtcSessionProvider>('webrtc-session') ?? null
|
|
295
|
+
return provider ? wrapWebrtcSessionProviderWithRelay(provider, ctx) : null
|
|
296
|
+
},
|
|
297
|
+
(capName, nodeId) =>
|
|
298
|
+
services.moleculer.createCapabilityProxy(capName, nodeId) as WebrtcSessionProvider | null,
|
|
299
|
+
),
|
|
300
|
+
|
|
256
301
|
// NOT MOUNTED — legacy provider shapes (positional args / sync
|
|
257
302
|
// returns) that don't match the codegen routers' {input}-object +
|
|
258
303
|
// Promise<T> contract. Tracked by `LEGACY_SHAPE_SKIP` in
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `AddonCallGateway` — the SINGLE hub-side router for addon-LEVEL calls (the
|
|
3
|
+
* surfaces the removed per-addon Moleculer broker used to carry: routes,
|
|
4
|
+
* custom-actions, settings — see `AddonCallTarget`).
|
|
5
|
+
*
|
|
6
|
+
* Why this exists: that routing decision (is the addon running in-process on
|
|
7
|
+
* the hub, as a forked hub-local CHILD reachable over UDS, or on a REMOTE
|
|
8
|
+
* agent over Moleculer?) used to be duplicated per surface — routes/custom in
|
|
9
|
+
* `addon-registry.service.ts`, settings in `addon-settings-provider.ts`. When
|
|
10
|
+
* the UDS migration ported routes+custom to `callAddonOnChild`, settings was
|
|
11
|
+
* left on the dead `<addonId>.settings.<method>` Moleculer path because nothing
|
|
12
|
+
* centralised "dispatch an addon-level call to wherever the addon runs". Every
|
|
13
|
+
* forked hub-local addon's settings panel silently went empty for months.
|
|
14
|
+
*
|
|
15
|
+
* Now every addon-level surface routes through `callForked` here. Combined with
|
|
16
|
+
* the exhaustive `AddonCallTarget` union (a `never`-checked dispatch in
|
|
17
|
+
* `createChildAddonCallDispatch`), a future surface can't be half-wired without
|
|
18
|
+
* a compile error — the class of "missed link" is closed.
|
|
19
|
+
*/
|
|
20
|
+
import type { AddonCallInput, LocalChildRegistry } from '@camstack/kernel'
|
|
21
|
+
|
|
22
|
+
/** An addon-level call minus its `addonId` — the gateway merges that in. */
|
|
23
|
+
export type AddonCallSurface = Omit<AddonCallInput, 'addonId'>
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Minimal structural view of the Moleculer broker (remote-agent leg) — typed
|
|
27
|
+
* locally because the lint type-checker can't resolve Moleculer's
|
|
28
|
+
* `ServiceBroker` (mirrors the pattern in `addon-settings-provider.ts`).
|
|
29
|
+
*/
|
|
30
|
+
export interface AddonCallBroker {
|
|
31
|
+
readonly registry: unknown
|
|
32
|
+
call(
|
|
33
|
+
action: string,
|
|
34
|
+
params: Record<string, unknown>,
|
|
35
|
+
opts?: { readonly nodeID?: string; readonly timeout?: number },
|
|
36
|
+
): Promise<unknown>
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Where an addon physically runs — the routing decision, made in one place. */
|
|
40
|
+
export type AddonCallDestination =
|
|
41
|
+
| { readonly kind: 'in-process' }
|
|
42
|
+
| { readonly kind: 'hub-local-child' }
|
|
43
|
+
| { readonly kind: 'remote-agent'; readonly baseNodeId: string }
|
|
44
|
+
|
|
45
|
+
export interface AddonCallGatewayDeps {
|
|
46
|
+
/** This hub's node id (for the local short-circuit). */
|
|
47
|
+
readonly hubNodeId: string
|
|
48
|
+
/** Which node hosts a given addon ('hub' / hubNodeId = on this hub). */
|
|
49
|
+
readonly resolveNode: (addonId: string) => string
|
|
50
|
+
/** UDS registry of forked hub-local children (null before it's wired). */
|
|
51
|
+
readonly getChildRegistry: () => LocalChildRegistry | null
|
|
52
|
+
/** Moleculer broker for the remote-agent leg. */
|
|
53
|
+
readonly broker: AddonCallBroker
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const REMOTE_TIMEOUT_MS = 10_000
|
|
57
|
+
|
|
58
|
+
export class AddonCallGateway {
|
|
59
|
+
constructor(private readonly deps: AddonCallGatewayDeps) {}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Classify where an addon runs. `nodeId: 'hub'` from a caller means "the hub
|
|
63
|
+
* cluster", NOT "force in-process" — a forked hub-local addon is still a
|
|
64
|
+
* `hub-local-child` (UDS), never the in-process path (which is only the
|
|
65
|
+
* `@camstack/core` builtins that have no forked runner).
|
|
66
|
+
*/
|
|
67
|
+
classify(addonId: string, explicitNodeId?: string): AddonCallDestination {
|
|
68
|
+
const resolved = this.deps.resolveNode(addonId)
|
|
69
|
+
const onHub = resolved === 'hub' || resolved === this.deps.hubNodeId
|
|
70
|
+
if (onHub) {
|
|
71
|
+
const childRegistry = this.deps.getChildRegistry()
|
|
72
|
+
if (childRegistry !== null && childRegistry.isChildKnown(addonId)) {
|
|
73
|
+
return { kind: 'hub-local-child' }
|
|
74
|
+
}
|
|
75
|
+
return { kind: 'in-process' }
|
|
76
|
+
}
|
|
77
|
+
const onThisHub = explicitNodeId === 'hub' || explicitNodeId === this.deps.hubNodeId
|
|
78
|
+
const baseNodeId = explicitNodeId && !onThisHub ? explicitNodeId : resolved
|
|
79
|
+
return { kind: 'remote-agent', baseNodeId }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** True when the addon is an in-process hub builtin (caller invokes directly). */
|
|
83
|
+
isInProcess(addonId: string, explicitNodeId?: string): boolean {
|
|
84
|
+
return this.classify(addonId, explicitNodeId).kind === 'in-process'
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Dispatch a forked addon-level call to wherever the addon runs:
|
|
89
|
+
* - `hub-local-child` → UDS `LocalChildRegistry.callAddonOnChild`
|
|
90
|
+
* - `remote-agent` → Moleculer `broker.call`
|
|
91
|
+
* Throws for `in-process` — that addon has no forked surface, so the caller
|
|
92
|
+
* must invoke the in-process instance directly (the invocation is
|
|
93
|
+
* surface-specific; only the ROUTING is centralised here).
|
|
94
|
+
*/
|
|
95
|
+
async callForked(addonId: string, input: AddonCallSurface, explicitNodeId?: string): Promise<unknown> {
|
|
96
|
+
const dest = this.classify(addonId, explicitNodeId)
|
|
97
|
+
const fullInput: AddonCallInput = { ...input, addonId }
|
|
98
|
+
switch (dest.kind) {
|
|
99
|
+
case 'hub-local-child': {
|
|
100
|
+
const childRegistry = this.deps.getChildRegistry()
|
|
101
|
+
if (childRegistry === null) {
|
|
102
|
+
throw new Error(`AddonCallGateway: child registry unavailable for "${addonId}"`)
|
|
103
|
+
}
|
|
104
|
+
return childRegistry.callAddonOnChild(addonId, fullInput)
|
|
105
|
+
}
|
|
106
|
+
case 'remote-agent':
|
|
107
|
+
return this.callRemoteAgent(addonId, dest.baseNodeId, fullInput)
|
|
108
|
+
case 'in-process':
|
|
109
|
+
throw new Error(`AddonCallGateway: addon "${addonId}" runs in-process — invoke it directly`)
|
|
110
|
+
default: {
|
|
111
|
+
const _exhaustive: never = dest
|
|
112
|
+
throw new Error(`AddonCallGateway: unhandled destination ${JSON.stringify(_exhaustive)}`)
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Map an addon-level call to the remote agent's Moleculer action. */
|
|
118
|
+
private async callRemoteAgent(addonId: string, baseNodeId: string, input: AddonCallInput): Promise<unknown> {
|
|
119
|
+
const workerNodeId = this.resolveWorkerNodeId(addonId, baseNodeId)
|
|
120
|
+
const opts = workerNodeId
|
|
121
|
+
? { nodeID: workerNodeId, timeout: REMOTE_TIMEOUT_MS }
|
|
122
|
+
: { timeout: REMOTE_TIMEOUT_MS }
|
|
123
|
+
if (input.target === 'settings') {
|
|
124
|
+
if (input.method == null) {
|
|
125
|
+
throw new Error(`AddonCallGateway: settings call to "${addonId}" missing method`)
|
|
126
|
+
}
|
|
127
|
+
return this.deps.broker.call(
|
|
128
|
+
`${addonId}.settings.${input.method}`,
|
|
129
|
+
(input.args ?? {}) as Record<string, unknown>,
|
|
130
|
+
opts,
|
|
131
|
+
)
|
|
132
|
+
}
|
|
133
|
+
// routes/custom are hub-local-child surfaces (mounted / invoked on the
|
|
134
|
+
// owning node); they are not proxied to a remote agent through this gateway.
|
|
135
|
+
throw new Error(`AddonCallGateway: target "${input.target}" not supported for remote agent "${baseNodeId}"`)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Resolve the Moleculer nodeID that actually hosts an addon's service.
|
|
140
|
+
* Forkable addons register under `${baseNodeId}/${addonId}`; in-process
|
|
141
|
+
* addons under the base nodeId. The registry is the ground truth — baseNodeId
|
|
142
|
+
* is a hint. (Moved verbatim from `addon-settings-provider.ts`.)
|
|
143
|
+
*/
|
|
144
|
+
private resolveWorkerNodeId(addonId: string, baseNodeId: string): string | null {
|
|
145
|
+
const registry = this.deps.broker.registry
|
|
146
|
+
const services = (registry as unknown as {
|
|
147
|
+
getServiceList: (opts: { onlyAvailable: boolean }) => readonly { name: string; nodeID: string }[]
|
|
148
|
+
}).getServiceList({ onlyAvailable: true })
|
|
149
|
+
const exactNode = `${baseNodeId}/${addonId}`
|
|
150
|
+
const preferred = services.find((s) => s.name === addonId && s.nodeID === exactNode)
|
|
151
|
+
if (preferred) return preferred.nodeID
|
|
152
|
+
const anyForBase = services.find((s) => s.name === addonId && s.nodeID === baseNodeId)
|
|
153
|
+
if (anyForBase) return anyForBase.nodeID
|
|
154
|
+
const anyWithName = services.find((s) => s.name === addonId)
|
|
155
|
+
return anyWithName?.nodeID ?? null
|
|
156
|
+
}
|
|
157
|
+
}
|
|
@@ -1022,6 +1022,7 @@ export class AddonPackageService {
|
|
|
1022
1022
|
readonly packageName: string
|
|
1023
1023
|
readonly version?: string
|
|
1024
1024
|
readonly requestedBy?: string
|
|
1025
|
+
readonly deferRestart?: boolean
|
|
1025
1026
|
}): Promise<{
|
|
1026
1027
|
packageName: string
|
|
1027
1028
|
fromVersion: string
|
|
@@ -1052,6 +1053,14 @@ export class AddonPackageService {
|
|
|
1052
1053
|
const args = ['install', '--prefix', appRoot, spec, '--no-save', ...buildNpmRegistryArgs(registry)]
|
|
1053
1054
|
await execFileAsync('npm', args, { timeout: 180_000 })
|
|
1054
1055
|
|
|
1056
|
+
if (input.deferRestart === true) {
|
|
1057
|
+
this.logger.info(
|
|
1058
|
+
`updateFrameworkPackage(${packageName}@${toVersion}): install done, restart deferred`,
|
|
1059
|
+
)
|
|
1060
|
+
// Sentinel: 0 signals "no restart scheduled" to the caller
|
|
1061
|
+
return { packageName, fromVersion, toVersion, restartingAt: 0 }
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1055
1064
|
const restartingAt = Date.now()
|
|
1056
1065
|
const markerPayload: PendingRestartMarkerPayload = {
|
|
1057
1066
|
kind: 'framework-update',
|