@camstack/server 0.1.6 → 0.1.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -3
- package/src/__tests__/addon-upload.spec.ts +58 -0
- package/src/__tests__/bulk-update-coordinator.spec.ts +286 -0
- package/src/__tests__/cap-ownership-authority.spec.ts +400 -0
- package/src/__tests__/cap-providers/cap-providers-location-import.spec.ts +186 -0
- package/src/__tests__/cap-providers/integrations-delete-cascade.spec.ts +243 -0
- package/src/__tests__/cap-providers-bulk-update.spec.ts +388 -0
- package/src/__tests__/cap-route-adapter.spec.ts +289 -0
- package/src/__tests__/cap-routers/broker-routing.router.spec.ts +169 -0
- package/src/__tests__/cap-routers/cap-route-error-formatter.spec.ts +123 -0
- package/src/__tests__/cap-routers/capabilities-node.spec.ts +55 -0
- package/src/__tests__/cap-routers/device-link-overlay.spec.ts +132 -0
- package/src/__tests__/dev-bootstrap-shm-ring.spec.ts +30 -0
- package/src/__tests__/device-settings-contribution-dispatch.spec.ts +249 -0
- package/src/__tests__/framework-installer-defer-restart.spec.ts +165 -0
- package/src/__tests__/moleculer/uds-readiness.spec.ts +143 -0
- package/src/__tests__/moleculer/uds-topology.spec.ts +390 -0
- package/src/__tests__/moleculer/uds-unowned-call.spec.ts +329 -0
- package/src/__tests__/moleculer-register-node-idempotency.spec.ts +39 -4
- package/src/__tests__/native-cap-route.spec.ts +404 -0
- package/src/__tests__/oauth2-account-linking.spec.ts +85 -0
- package/src/__tests__/uds-addon-call-wiring.spec.ts +237 -0
- package/src/__tests__/uds-log-ingest.spec.ts +183 -0
- package/src/api/addon-upload.ts +27 -1
- package/src/api/capabilities.router.ts +1 -1
- package/src/api/core/__tests__/integration-markers.spec.ts +10 -0
- package/src/api/core/bulk-update-coordinator.ts +302 -0
- package/src/api/core/cap-providers.ts +211 -9
- package/src/api/core/capabilities.router.ts +26 -3
- package/src/api/core/logs.router.ts +4 -0
- package/src/api/oauth2/oauth2-routes.ts +5 -1
- package/src/api/trpc/__tests__/client-ip.spec.ts +146 -0
- package/src/api/trpc/__tests__/webrtc-session-ua-enrich.spec.ts +128 -0
- package/src/api/trpc/cap-mount-helpers.ts +12 -1
- package/src/api/trpc/cap-route-error-formatter.ts +163 -0
- package/src/api/trpc/client-ip.ts +147 -0
- package/src/api/trpc/generated-cap-mounts.ts +299 -8
- package/src/api/trpc/generated-cap-routers.ts +2384 -302
- package/src/api/trpc/trpc.middleware.ts +5 -1
- package/src/api/trpc/trpc.router.ts +84 -3
- package/src/boot/__tests__/integration-id-backfill.spec.ts +116 -0
- package/src/boot/integration-id-backfill.ts +109 -0
- package/src/core/addon/__tests__/addon-row-manifest.spec.ts +62 -0
- package/src/core/addon/addon-call-gateway.ts +157 -0
- package/src/core/addon/addon-package.service.ts +9 -0
- package/src/core/addon/addon-registry.service.ts +453 -107
- package/src/core/addon/addon-row-manifest.ts +29 -0
- package/src/core/addon/addon-settings-provider.ts +40 -116
- package/src/core/capability/capability.service.ts +9 -0
- package/src/core/logging/logging.service.ts +7 -2
- package/src/core/moleculer/cap-call-fn.spec.ts +166 -0
- package/src/core/moleculer/cap-call-fn.ts +103 -0
- package/src/core/moleculer/cap-route-authority.ts +182 -0
- package/src/core/moleculer/moleculer.service.ts +408 -36
- package/src/core/network/network-quality.service.spec.ts +2 -1
- package/src/main.ts +137 -12
- package/src/core/storage/settings-store.spec.ts +0 -213
- package/src/core/storage/settings-store.ts +0 -2
- package/src/core/storage/sql-schema.spec.ts +0 -140
- package/src/core/storage/sql-schema.ts +0 -3
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* F3 backend wiring — forked-addon routes + custom actions over UDS.
|
|
3
|
+
*
|
|
4
|
+
* Verifies the production wiring `AddonRegistryService` builds, end-to-end over
|
|
5
|
+
* a REAL LocalChildRegistry + LocalChildClient pair, with the child running the
|
|
6
|
+
* REAL addon-runner-side dispatcher (`createChildAddonCallDispatch`):
|
|
7
|
+
*
|
|
8
|
+
* - Custom actions: a `CustomActionRegistry` whose dispatcher is the F3
|
|
9
|
+
* `callAddonOnChild({target:'custom', action, args})` reaches the child's
|
|
10
|
+
* real custom-action handler and returns its result (the path
|
|
11
|
+
* `registerForkedAddonCustomActions` builds via `dispatchForkedCustomAction`).
|
|
12
|
+
* - Routes: `callAddonOnChild({target:'routes'})` returns handler-stripped
|
|
13
|
+
* descriptors; the per-route bridge handler dispatches through the
|
|
14
|
+
* `addon-routes` `invoke()` cap method (the path `mountForkedAddonRoutes`
|
|
15
|
+
* builds), and the captured envelope round-trips.
|
|
16
|
+
*
|
|
17
|
+
* The child-side `onAddonCall` handler is a faithful inline of the addon-runner
|
|
18
|
+
* dispatcher (resolve route provider / custom registry; strip route handlers).
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { describe, it, expect, afterEach } from 'vitest'
|
|
22
|
+
import { z } from 'zod'
|
|
23
|
+
import {
|
|
24
|
+
LocalChildRegistry,
|
|
25
|
+
LocalChildClient,
|
|
26
|
+
CustomActionRegistry,
|
|
27
|
+
createLocalTransport,
|
|
28
|
+
} from '@camstack/kernel'
|
|
29
|
+
import type { AddonCallInput } from '@camstack/kernel'
|
|
30
|
+
import { buildAddonRouteProvider, customAction, defineCustomActions } from '@camstack/types'
|
|
31
|
+
import type { IAddonHttpRoute, CustomActionsSpec } from '@camstack/types'
|
|
32
|
+
|
|
33
|
+
const nid = (): string => `f3-wiring-${process.pid}-${Math.random().toString(36).slice(2)}`
|
|
34
|
+
|
|
35
|
+
describe('F3 — forked-addon routes + custom actions over UDS (backend wiring)', () => {
|
|
36
|
+
let registry: LocalChildRegistry | null = null
|
|
37
|
+
const clients: LocalChildClient[] = []
|
|
38
|
+
|
|
39
|
+
afterEach(async () => {
|
|
40
|
+
for (const c of clients) await c.close().catch(() => {})
|
|
41
|
+
clients.length = 0
|
|
42
|
+
await registry?.close().catch(() => {})
|
|
43
|
+
registry = null
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
/** Stand up a hub-side registry + a child running the real addon-call dispatcher. */
|
|
47
|
+
async function standUp(opts: {
|
|
48
|
+
addonId: string
|
|
49
|
+
routes?: readonly IAddonHttpRoute[]
|
|
50
|
+
catalog?: CustomActionsSpec
|
|
51
|
+
handlers?: Record<string, (input: unknown) => Promise<unknown>>
|
|
52
|
+
}): Promise<{ childCustomActions: CustomActionRegistry }> {
|
|
53
|
+
const nodeId = nid()
|
|
54
|
+
registry = new LocalChildRegistry(createLocalTransport().createServer(nodeId))
|
|
55
|
+
await registry.start()
|
|
56
|
+
|
|
57
|
+
// The runner-side custom-action registry — populated as the addon-runner does.
|
|
58
|
+
const childCustomActions = new CustomActionRegistry()
|
|
59
|
+
if (opts.catalog && opts.handlers) {
|
|
60
|
+
const handlers = opts.handlers
|
|
61
|
+
childCustomActions.registerAddon(opts.addonId, opts.catalog, (action, input) => {
|
|
62
|
+
const fn = handlers[action]
|
|
63
|
+
if (!fn) throw new Error(`no handler for ${action}`)
|
|
64
|
+
return fn(input)
|
|
65
|
+
})
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const routeProvider = opts.routes ? buildAddonRouteProvider(opts.addonId, opts.routes) : null
|
|
69
|
+
|
|
70
|
+
const child = new LocalChildClient({
|
|
71
|
+
nodeId,
|
|
72
|
+
childId: opts.addonId,
|
|
73
|
+
caps: [{ capName: 'addon-routes', mode: 'collection' }],
|
|
74
|
+
// Cap dispatch — resolve the `addon-routes` cap's `invoke`/`getRoutes`
|
|
75
|
+
// method on the live route provider (mirrors the runner's
|
|
76
|
+
// createChildCapDispatch resolving allSingletonProviders['addon-routes']).
|
|
77
|
+
dispatch: async (call) => {
|
|
78
|
+
if (call.capName === 'addon-routes' && routeProvider !== null) {
|
|
79
|
+
const fn = Reflect.get(routeProvider, call.method)
|
|
80
|
+
if (typeof fn === 'function') return fn.call(routeProvider, call.args)
|
|
81
|
+
}
|
|
82
|
+
return null
|
|
83
|
+
},
|
|
84
|
+
})
|
|
85
|
+
// Inline addon-runner dispatcher: routes → handler-stripped descriptors,
|
|
86
|
+
// custom → the child custom-action registry. Mirrors
|
|
87
|
+
// `createChildAddonCallDispatch` in the kernel.
|
|
88
|
+
child.onAddonCall(async (call: AddonCallInput) => {
|
|
89
|
+
if (call.target === 'routes') {
|
|
90
|
+
if (routeProvider === null) throw new Error(`addon "${call.addonId}" has no routes`)
|
|
91
|
+
const live = routeProvider.getRoutes()
|
|
92
|
+
return live.map((r) => ({
|
|
93
|
+
method: r.method,
|
|
94
|
+
path: r.path,
|
|
95
|
+
...(r.access !== undefined ? { access: r.access } : {}),
|
|
96
|
+
...(r.description !== undefined ? { description: r.description } : {}),
|
|
97
|
+
}))
|
|
98
|
+
}
|
|
99
|
+
const action = call.action
|
|
100
|
+
if (typeof action !== 'string') throw new Error('missing action')
|
|
101
|
+
const entry = childCustomActions.resolve(call.addonId, action)
|
|
102
|
+
if (entry === null) throw new Error(`no custom action "${action}"`)
|
|
103
|
+
return entry.handler(call.args)
|
|
104
|
+
})
|
|
105
|
+
clients.push(child)
|
|
106
|
+
await child.start()
|
|
107
|
+
|
|
108
|
+
return { childCustomActions }
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
it('custom action: the registerForkedAddonCustomActions dispatch path reaches the child handler', async () => {
|
|
112
|
+
const catalog = defineCustomActions({
|
|
113
|
+
runBenchmark: customAction(
|
|
114
|
+
z.object({ iterations: z.number() }),
|
|
115
|
+
z.object({ ran: z.number() }),
|
|
116
|
+
{ kind: 'mutation' },
|
|
117
|
+
),
|
|
118
|
+
})
|
|
119
|
+
await standUp({
|
|
120
|
+
addonId: 'benchmark',
|
|
121
|
+
catalog,
|
|
122
|
+
handlers: {
|
|
123
|
+
runBenchmark: async (input) => {
|
|
124
|
+
const args = input as { iterations: number }
|
|
125
|
+
return { ran: args.iterations }
|
|
126
|
+
},
|
|
127
|
+
},
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
// Hub-side registry — the dispatcher mirrors `dispatchForkedCustomAction`.
|
|
131
|
+
const hubRegistry = new CustomActionRegistry()
|
|
132
|
+
hubRegistry.registerAddon('benchmark', catalog, (action, input) =>
|
|
133
|
+
registry!.callAddonOnChild('benchmark', {
|
|
134
|
+
addonId: 'benchmark',
|
|
135
|
+
target: 'custom',
|
|
136
|
+
action,
|
|
137
|
+
args: input,
|
|
138
|
+
}),
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
const entry = hubRegistry.resolve('benchmark', 'runBenchmark')
|
|
142
|
+
expect(entry).not.toBeNull()
|
|
143
|
+
const result = await entry!.handler({ iterations: 9 })
|
|
144
|
+
expect(result).toEqual({ ran: 9 })
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
it('routes: callAddonOnChild(routes) returns stripped descriptors and invoke() round-trips', async () => {
|
|
148
|
+
const routes: IAddonHttpRoute[] = [
|
|
149
|
+
{
|
|
150
|
+
method: 'GET',
|
|
151
|
+
path: '/hello/:name',
|
|
152
|
+
access: 'public',
|
|
153
|
+
handler: async (req, reply) => {
|
|
154
|
+
reply.code(200)
|
|
155
|
+
reply.send({ greeting: `hi ${req.params.name}` })
|
|
156
|
+
},
|
|
157
|
+
},
|
|
158
|
+
]
|
|
159
|
+
await standUp({ addonId: 'export-alexa', routes })
|
|
160
|
+
|
|
161
|
+
// (a) routes come back handler-stripped (the `mountForkedAddonRoutes` fetch).
|
|
162
|
+
const rawRoutes = await registry!.callAddonOnChild('export-alexa', {
|
|
163
|
+
addonId: 'export-alexa',
|
|
164
|
+
target: 'routes',
|
|
165
|
+
})
|
|
166
|
+
expect(rawRoutes).toEqual([{ method: 'GET', path: '/hello/:name', access: 'public' }])
|
|
167
|
+
|
|
168
|
+
// (b) dispatch through the addon-routes cap proxy's `invoke` cap method —
|
|
169
|
+
// the bridge handler `mountForkedAddonRoutes` synthesizes calls this.
|
|
170
|
+
const routesCapProxy = {
|
|
171
|
+
invoke: (input: unknown) =>
|
|
172
|
+
registry!.callCapOnChild('export-alexa', {
|
|
173
|
+
capName: 'addon-routes',
|
|
174
|
+
method: 'invoke',
|
|
175
|
+
args: input,
|
|
176
|
+
}),
|
|
177
|
+
}
|
|
178
|
+
const envelope = await routesCapProxy.invoke({
|
|
179
|
+
method: 'GET',
|
|
180
|
+
path: '/hello/world',
|
|
181
|
+
params: { name: 'world' },
|
|
182
|
+
query: {},
|
|
183
|
+
body: undefined,
|
|
184
|
+
headers: {},
|
|
185
|
+
})
|
|
186
|
+
expect(envelope).toMatchObject({ status: 200, body: { greeting: 'hi world' } })
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
it('auth-oidc shape: a forked addon with a SYNC array getRoutes + async handlers mounts via the strip path without serializing a function', async () => {
|
|
190
|
+
// Reproduces the regression: `auth-oidc` declares a manual route provider
|
|
191
|
+
// `{ id, getRoutes: () => routes }` (NO `buildAddonRouteProvider`, NO
|
|
192
|
+
// `invoke`). It forks like every non-core addon, so the hub resolves an
|
|
193
|
+
// async UDS cap proxy whose `getRoutes()` returns a Promise. The mount path
|
|
194
|
+
// MUST NOT register that proxy directly (→ `getRoutes(...).map is not a
|
|
195
|
+
// function`) nor dispatch its `getRoutes` cap method over UDS (the child's
|
|
196
|
+
// `getRoutes()` returns the live array WITH async `handler` functions →
|
|
197
|
+
// MsgPack "Unrecognized object: [object AsyncFunction]"). It uses the
|
|
198
|
+
// handler-stripped `callAddonOnChild(target:'routes')` bridge instead.
|
|
199
|
+
const routes: IAddonHttpRoute[] = [
|
|
200
|
+
{
|
|
201
|
+
method: 'GET',
|
|
202
|
+
path: '/:providerId/start',
|
|
203
|
+
access: 'public',
|
|
204
|
+
description: 'Begin OIDC redirect login flow',
|
|
205
|
+
handler: async (req, reply) => {
|
|
206
|
+
reply.code(302)
|
|
207
|
+
reply.send('')
|
|
208
|
+
},
|
|
209
|
+
},
|
|
210
|
+
{
|
|
211
|
+
method: 'GET',
|
|
212
|
+
path: '/:providerId/callback',
|
|
213
|
+
access: 'public',
|
|
214
|
+
handler: async (req, reply) => {
|
|
215
|
+
reply.code(200)
|
|
216
|
+
reply.send('ok')
|
|
217
|
+
},
|
|
218
|
+
},
|
|
219
|
+
]
|
|
220
|
+
await standUp({ addonId: 'auth-oidc', routes })
|
|
221
|
+
|
|
222
|
+
// The handler-stripped descriptors cross the real UDS pair. If the strip
|
|
223
|
+
// path leaked a `handler` function, MsgPack `encodeFrame` would throw
|
|
224
|
+
// "Unrecognized object: [object AsyncFunction]" before this resolves.
|
|
225
|
+
const rawRoutes = await registry!.callAddonOnChild('auth-oidc', {
|
|
226
|
+
addonId: 'auth-oidc',
|
|
227
|
+
target: 'routes',
|
|
228
|
+
})
|
|
229
|
+
expect(rawRoutes).toEqual([
|
|
230
|
+
{ method: 'GET', path: '/:providerId/start', access: 'public', description: 'Begin OIDC redirect login flow' },
|
|
231
|
+
{ method: 'GET', path: '/:providerId/callback', access: 'public' },
|
|
232
|
+
])
|
|
233
|
+
// Coarse reachability gate the route-mount fallback uses: the child is
|
|
234
|
+
// connected even though we only checked the addon-call surface.
|
|
235
|
+
expect(registry!.isChildKnown('auth-oidc')).toBe(true)
|
|
236
|
+
})
|
|
237
|
+
})
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* UDS child log ingest — unit test for Task B2.
|
|
3
|
+
*
|
|
4
|
+
* Verifies that wiring `LocalChildRegistry.onChildLog` to
|
|
5
|
+
* `LoggingService.writeFromWorker` correctly maps a `ChildLogMessage`
|
|
6
|
+
* produced over a UDS channel to the `writeFromWorker` entry shape.
|
|
7
|
+
*
|
|
8
|
+
* We test the helper `udsChildLogToWorkerEntry` (extracted from the
|
|
9
|
+
* hub/agent wiring) directly, plus the full registry+handler pairing so
|
|
10
|
+
* the glue code exercises the real `onChildLog` callback path.
|
|
11
|
+
*/
|
|
12
|
+
import { describe, it, expect, vi } from 'vitest'
|
|
13
|
+
import type { ChildLogMessage } from '@camstack/kernel'
|
|
14
|
+
import { udsChildLogToWorkerEntry } from '@camstack/kernel'
|
|
15
|
+
|
|
16
|
+
// --------------------------------------------------------------------------
|
|
17
|
+
// Minimal `WriteFromWorkerEntry` shape mirrored from LoggingService signature
|
|
18
|
+
// --------------------------------------------------------------------------
|
|
19
|
+
interface WriteFromWorkerEntry {
|
|
20
|
+
addonId: string
|
|
21
|
+
nodeId?: string
|
|
22
|
+
level: string
|
|
23
|
+
message: string
|
|
24
|
+
scope?: string
|
|
25
|
+
tags?: Record<string, string | number | undefined>
|
|
26
|
+
meta?: Record<string, unknown>
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// --------------------------------------------------------------------------
|
|
30
|
+
// Helper: build a minimal ChildLogMessage
|
|
31
|
+
// --------------------------------------------------------------------------
|
|
32
|
+
function makeLogMessage(overrides?: Partial<ChildLogMessage>): ChildLogMessage {
|
|
33
|
+
return {
|
|
34
|
+
kind: 'log',
|
|
35
|
+
level: 'info',
|
|
36
|
+
message: 'hello from child',
|
|
37
|
+
addonId: 'stream-broker',
|
|
38
|
+
...overrides,
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// --------------------------------------------------------------------------
|
|
43
|
+
// Tests for udsChildLogToWorkerEntry
|
|
44
|
+
// --------------------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
describe('udsChildLogToWorkerEntry', () => {
|
|
47
|
+
it('maps the required fields — addonId, level, message', () => {
|
|
48
|
+
const entry = makeLogMessage()
|
|
49
|
+
const result = udsChildLogToWorkerEntry('stream-broker', entry)
|
|
50
|
+
|
|
51
|
+
expect(result.addonId).toBe('stream-broker')
|
|
52
|
+
expect(result.level).toBe('info')
|
|
53
|
+
expect(result.message).toBe('hello from child')
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('uses entry.nodeId when present', () => {
|
|
57
|
+
const entry = makeLogMessage({ nodeId: 'hub/stream-broker' })
|
|
58
|
+
const result = udsChildLogToWorkerEntry('stream-broker', entry)
|
|
59
|
+
|
|
60
|
+
expect(result.nodeId).toBe('hub/stream-broker')
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('falls back to childId when nodeId is absent', () => {
|
|
64
|
+
const entry = makeLogMessage()
|
|
65
|
+
const result = udsChildLogToWorkerEntry('stream-broker', entry)
|
|
66
|
+
|
|
67
|
+
expect(result.nodeId).toBe('stream-broker')
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('omits scope when not present in entry', () => {
|
|
71
|
+
const entry = makeLogMessage()
|
|
72
|
+
const result = udsChildLogToWorkerEntry('stream-broker', entry)
|
|
73
|
+
|
|
74
|
+
expect(Object.prototype.hasOwnProperty.call(result, 'scope')).toBe(false)
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('includes scope when present in entry', () => {
|
|
78
|
+
const entry = makeLogMessage({ scope: 'rtsp' })
|
|
79
|
+
const result = udsChildLogToWorkerEntry('stream-broker', entry)
|
|
80
|
+
|
|
81
|
+
expect(result.scope).toBe('rtsp')
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('omits tags when not present in entry', () => {
|
|
85
|
+
const entry = makeLogMessage()
|
|
86
|
+
const result = udsChildLogToWorkerEntry('stream-broker', entry)
|
|
87
|
+
|
|
88
|
+
expect(Object.prototype.hasOwnProperty.call(result, 'tags')).toBe(false)
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('includes tags when present in entry', () => {
|
|
92
|
+
const entry = makeLogMessage({ tags: { deviceId: 5, streamId: '5/main' } })
|
|
93
|
+
const result = udsChildLogToWorkerEntry('stream-broker', entry)
|
|
94
|
+
|
|
95
|
+
expect(result.tags).toEqual({ deviceId: 5, streamId: '5/main' })
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('omits meta when not present in entry', () => {
|
|
99
|
+
const entry = makeLogMessage()
|
|
100
|
+
const result = udsChildLogToWorkerEntry('stream-broker', entry)
|
|
101
|
+
|
|
102
|
+
expect(Object.prototype.hasOwnProperty.call(result, 'meta')).toBe(false)
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('includes meta when present in entry', () => {
|
|
106
|
+
const entry = makeLogMessage({ meta: { rtt: 42 } })
|
|
107
|
+
const result = udsChildLogToWorkerEntry('stream-broker', entry)
|
|
108
|
+
|
|
109
|
+
expect(result.meta).toEqual({ rtt: 42 })
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it('produces the same shape that $hub.log onLog produces', () => {
|
|
113
|
+
// The existing onLog wiring in moleculer.service.ts maps:
|
|
114
|
+
// { addonId, nodeId, level, message, scope?, tags?, meta? }
|
|
115
|
+
// Verify we produce an identical structure for a fully-populated entry.
|
|
116
|
+
const entry = makeLogMessage({
|
|
117
|
+
addonId: 'ptz-provider',
|
|
118
|
+
nodeId: 'hub/ptz-provider',
|
|
119
|
+
level: 'warn',
|
|
120
|
+
message: 'PTZ timeout',
|
|
121
|
+
scope: 'ptz',
|
|
122
|
+
tags: { deviceId: 3 },
|
|
123
|
+
meta: { timeout: 5000 },
|
|
124
|
+
})
|
|
125
|
+
const result = udsChildLogToWorkerEntry('hub/ptz-provider', entry)
|
|
126
|
+
|
|
127
|
+
const expected: WriteFromWorkerEntry = {
|
|
128
|
+
addonId: 'ptz-provider',
|
|
129
|
+
nodeId: 'hub/ptz-provider',
|
|
130
|
+
level: 'warn',
|
|
131
|
+
message: 'PTZ timeout',
|
|
132
|
+
scope: 'ptz',
|
|
133
|
+
tags: { deviceId: 3 },
|
|
134
|
+
meta: { timeout: 5000 },
|
|
135
|
+
}
|
|
136
|
+
expect(result).toEqual(expected)
|
|
137
|
+
})
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
// --------------------------------------------------------------------------
|
|
141
|
+
// Tests for the registry+handler wiring (the onChildLog callback path)
|
|
142
|
+
// --------------------------------------------------------------------------
|
|
143
|
+
|
|
144
|
+
describe('onChildLog handler wiring', () => {
|
|
145
|
+
it('calls writeFromWorker when a ChildLogMessage arrives', () => {
|
|
146
|
+
// Fake logging service — typed minimal interface
|
|
147
|
+
const fakeLogging = {
|
|
148
|
+
writeFromWorker: vi.fn<[WriteFromWorkerEntry], void>(),
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// The actual wiring closure (mirrors hub & agent wiring)
|
|
152
|
+
const handler = (childId: string, entry: ChildLogMessage): void => {
|
|
153
|
+
fakeLogging.writeFromWorker(udsChildLogToWorkerEntry(childId, entry))
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const entry = makeLogMessage({ addonId: 'motion-detector', nodeId: 'hub/motion-detector' })
|
|
157
|
+
handler('hub/motion-detector', entry)
|
|
158
|
+
|
|
159
|
+
expect(fakeLogging.writeFromWorker).toHaveBeenCalledOnce()
|
|
160
|
+
const called = fakeLogging.writeFromWorker.mock.calls[0]![0]
|
|
161
|
+
expect(called.addonId).toBe('motion-detector')
|
|
162
|
+
expect(called.nodeId).toBe('hub/motion-detector')
|
|
163
|
+
expect(called.level).toBe('info')
|
|
164
|
+
expect(called.message).toBe('hello from child')
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
it('invokes writeFromWorker once per log entry', () => {
|
|
168
|
+
const fakeLogging = {
|
|
169
|
+
writeFromWorker: vi.fn<[WriteFromWorkerEntry], void>(),
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const handler = (childId: string, entry: ChildLogMessage): void => {
|
|
173
|
+
fakeLogging.writeFromWorker(udsChildLogToWorkerEntry(childId, entry))
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
handler('child-a', makeLogMessage({ addonId: 'child-a', level: 'debug', message: 'first' }))
|
|
177
|
+
handler('child-b', makeLogMessage({ addonId: 'child-b', level: 'error', message: 'second' }))
|
|
178
|
+
|
|
179
|
+
expect(fakeLogging.writeFromWorker).toHaveBeenCalledTimes(2)
|
|
180
|
+
expect(fakeLogging.writeFromWorker.mock.calls[0]![0].addonId).toBe('child-a')
|
|
181
|
+
expect(fakeLogging.writeFromWorker.mock.calls[1]![0].addonId).toBe('child-b')
|
|
182
|
+
})
|
|
183
|
+
})
|
package/src/api/addon-upload.ts
CHANGED
|
@@ -7,6 +7,8 @@ import type { IScopedLogger, TokenScope } from '@camstack/types'
|
|
|
7
7
|
import { checkScopeAccess } from './trpc/scope-access.js'
|
|
8
8
|
import type { AddonBridgeService } from '../core/addon-bridge/addon-bridge.service'
|
|
9
9
|
import type { AddonRegistryService } from '../core/addon/addon-registry.service'
|
|
10
|
+
import type { AddonPackageService } from '../core/addon/addon-package.service'
|
|
11
|
+
import { isFrameworkPackage } from '../core/addon/addon-package.service.js'
|
|
10
12
|
import type { AuthService } from '../core/auth/auth.service'
|
|
11
13
|
import type { MoleculerService } from '../core/moleculer/moleculer.service'
|
|
12
14
|
|
|
@@ -112,6 +114,7 @@ export async function registerAddonUploadRoute(
|
|
|
112
114
|
authService: AuthService,
|
|
113
115
|
moleculer: MoleculerService,
|
|
114
116
|
addonRegistry: AddonRegistryService,
|
|
117
|
+
addonPackageService: AddonPackageService,
|
|
115
118
|
logger: IScopedLogger,
|
|
116
119
|
): Promise<void> {
|
|
117
120
|
await fastify.register(import('@fastify/multipart'), {
|
|
@@ -197,7 +200,7 @@ export async function registerAddonUploadRoute(
|
|
|
197
200
|
// the package's addons (i.e. anything not marked hub-only). Any other
|
|
198
201
|
// explicit `nodeId` value routes only to that agent via `$agent.deploy`.
|
|
199
202
|
if (!nodeId || nodeId === 'hub') {
|
|
200
|
-
return installToHub(reply, addonBridge, addonRegistry, moleculer, logger, data.filename, buffer)
|
|
203
|
+
return installToHub(reply, addonBridge, addonRegistry, addonPackageService, moleculer, logger, data.filename, buffer)
|
|
201
204
|
}
|
|
202
205
|
const agentAddonId = addonIdHint ?? manifest.name
|
|
203
206
|
return deployToAgent(reply, moleculer, nodeId, agentAddonId, buffer)
|
|
@@ -330,6 +333,7 @@ async function installToHub(
|
|
|
330
333
|
reply: FastifyReply,
|
|
331
334
|
addonBridge: AddonBridgeService,
|
|
332
335
|
addonRegistry: AddonRegistryService,
|
|
336
|
+
addonPackageService: AddonPackageService,
|
|
333
337
|
moleculer: MoleculerService,
|
|
334
338
|
logger: IScopedLogger,
|
|
335
339
|
filename: string,
|
|
@@ -360,6 +364,28 @@ async function installToHub(
|
|
|
360
364
|
const addonsDir = path.resolve(process.env['CAMSTACK_DATA'] ?? 'camstack-data', 'addons')
|
|
361
365
|
fs.writeFileSync(path.join(addonsDir, result.name, '.install-source'), 'upload')
|
|
362
366
|
|
|
367
|
+
// Framework / system packages (`camstack.system: true`, e.g. @camstack/core)
|
|
368
|
+
// ship hub builtins (sqlite-settings, filesystem-storage, …) that CANNOT be
|
|
369
|
+
// hot-reloaded in place — an in-process `restartAddon` leaves them
|
|
370
|
+
// uninitialized ("SqliteSettingsBackend not initialized"), breaking auth +
|
|
371
|
+
// settings. The new code is now on disk; a clean server restart reloads it
|
|
372
|
+
// and re-runs boot initialization. The 10s restart grace lets this response
|
|
373
|
+
// flush before the process exits, so the CLI sees a clean confirmation.
|
|
374
|
+
if (isFrameworkPackage(result.name)) {
|
|
375
|
+
logger.info('framework package deployed — scheduling server restart', {
|
|
376
|
+
meta: { packageName: result.name, packageVersion: result.version },
|
|
377
|
+
})
|
|
378
|
+
addonPackageService.restartServer(`addon-upload: ${result.name}@${result.version}`)
|
|
379
|
+
return reply.send({
|
|
380
|
+
success: true,
|
|
381
|
+
name: result.name,
|
|
382
|
+
version: result.version,
|
|
383
|
+
requiresRestart: true,
|
|
384
|
+
restarting: true,
|
|
385
|
+
message: 'Framework package installed — server is restarting to load it',
|
|
386
|
+
})
|
|
387
|
+
}
|
|
388
|
+
|
|
363
389
|
// `addonRegistry.loadNewAddons()` already runs its own fresh filesystem
|
|
364
390
|
// scan, diff'ing against existing `addonEntries` — it updates metadata
|
|
365
391
|
// for known addons without touching their live instances + initializes
|
|
@@ -113,7 +113,7 @@ export function createCapabilitiesRouter(
|
|
|
113
113
|
|
|
114
114
|
if (!requiresRestart) {
|
|
115
115
|
// Hot-swap at runtime
|
|
116
|
-
await registry.setActiveSingleton(capability, addonId
|
|
116
|
+
await registry.setActiveSingleton(capability, addonId)
|
|
117
117
|
}
|
|
118
118
|
|
|
119
119
|
// Persist preference
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { INTEGRATION_CAP_MARKERS } from '../cap-providers.js'
|
|
3
|
+
|
|
4
|
+
describe('integration cap markers', () => {
|
|
5
|
+
it('recognises device-adoption (not the old ha-discovery)', () => {
|
|
6
|
+
expect(INTEGRATION_CAP_MARKERS.has('device-adoption')).toBe(true)
|
|
7
|
+
expect(INTEGRATION_CAP_MARKERS.has('ha-discovery')).toBe(false)
|
|
8
|
+
expect(INTEGRATION_CAP_MARKERS.has('device-provider')).toBe(true)
|
|
9
|
+
})
|
|
10
|
+
})
|