@camstack/server 0.1.3
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/.env.example +17 -0
- package/package.json +55 -0
- package/src/__tests__/addon-install-e2e.test.ts +75 -0
- package/src/__tests__/addon-pages-e2e.test.ts +178 -0
- package/src/__tests__/addon-route-session.test.ts +17 -0
- package/src/__tests__/addon-settings-router.spec.ts +62 -0
- package/src/__tests__/addon-upload.spec.ts +355 -0
- package/src/__tests__/agent-registry.spec.ts +162 -0
- package/src/__tests__/agent-status-page.spec.ts +84 -0
- package/src/__tests__/auth-session-cookie.test.ts +21 -0
- package/src/__tests__/cap-providers/cap-usage-graph.spec.ts +23 -0
- package/src/__tests__/cap-providers/compute-topology-categories.spec.ts +64 -0
- package/src/__tests__/cap-routers/_meta.spec.ts +200 -0
- package/src/__tests__/cap-routers/addon-settings.router.spec.ts +106 -0
- package/src/__tests__/cap-routers/device-manager-aggregate.router.spec.ts +142 -0
- package/src/__tests__/cap-routers/harness.ts +159 -0
- package/src/__tests__/cap-routers/metrics-provider.router.spec.ts +119 -0
- package/src/__tests__/cap-routers/null-provider-guard.spec.ts +66 -0
- package/src/__tests__/cap-routers/pipeline-executor.router.spec.ts +135 -0
- package/src/__tests__/cap-routers/settings-store.router.spec.ts +247 -0
- package/src/__tests__/capability-e2e.test.ts +386 -0
- package/src/__tests__/cli-e2e.test.ts +129 -0
- package/src/__tests__/core-cap-bridge.spec.ts +89 -0
- package/src/__tests__/embedded-deps-e2e.test.ts +109 -0
- package/src/__tests__/event-bus-proxy-router.spec.ts +72 -0
- package/src/__tests__/fixtures/mock-analysis-addon-a.ts +37 -0
- package/src/__tests__/fixtures/mock-analysis-addon-b.ts +37 -0
- package/src/__tests__/fixtures/mock-log-addon.ts +37 -0
- package/src/__tests__/fixtures/mock-storage-addon.ts +40 -0
- package/src/__tests__/framework-allowlist.spec.ts +95 -0
- package/src/__tests__/https-e2e.test.ts +118 -0
- package/src/__tests__/lifecycle-e2e.test.ts +140 -0
- package/src/__tests__/live-events-subscription.spec.ts +150 -0
- package/src/__tests__/moleculer-register-node-idempotency.spec.ts +229 -0
- package/src/__tests__/oauth2-account-linking.spec.ts +736 -0
- package/src/__tests__/post-boot-restart.spec.ts +161 -0
- package/src/__tests__/singleton-contention.test.ts +487 -0
- package/src/__tests__/streaming-diagnostic.test.ts +512 -0
- package/src/__tests__/streaming-scale.test.ts +280 -0
- package/src/agent-status-page.ts +121 -0
- package/src/api/__tests__/addons-custom.spec.ts +134 -0
- package/src/api/__tests__/capabilities.router.test.ts +47 -0
- package/src/api/addon-upload.ts +472 -0
- package/src/api/addons-custom.router.ts +100 -0
- package/src/api/auth-whoami.ts +99 -0
- package/src/api/bridge-addons.router.ts +120 -0
- package/src/api/capabilities.router.ts +226 -0
- package/src/api/core/__tests__/auth-router-totp.spec.ts +256 -0
- package/src/api/core/addon-settings.router.ts +124 -0
- package/src/api/core/agents.router.ts +87 -0
- package/src/api/core/auth.router.ts +303 -0
- package/src/api/core/cap-providers.ts +993 -0
- package/src/api/core/capabilities.router.ts +119 -0
- package/src/api/core/collection-preference.ts +40 -0
- package/src/api/core/event-bus-proxy.router.ts +45 -0
- package/src/api/core/hwaccel.router.ts +81 -0
- package/src/api/core/live-events.router.ts +60 -0
- package/src/api/core/logs.router.ts +162 -0
- package/src/api/core/notifications.router.ts +65 -0
- package/src/api/core/repl.router.ts +41 -0
- package/src/api/core/settings-backend.router.ts +142 -0
- package/src/api/core/stream-probe.router.ts +57 -0
- package/src/api/core/system-events.router.ts +116 -0
- package/src/api/health/health.routes.ts +123 -0
- package/src/api/oauth2/__tests__/oauth2-routes.spec.ts +52 -0
- package/src/api/oauth2/consent-page.ts +42 -0
- package/src/api/oauth2/oauth2-routes.ts +248 -0
- package/src/api/trpc/__tests__/scope-access-device.spec.ts +223 -0
- package/src/api/trpc/__tests__/scope-access.spec.ts +107 -0
- package/src/api/trpc/cap-mount-helpers.ts +225 -0
- package/src/api/trpc/core-cap-bridge.ts +152 -0
- package/src/api/trpc/generated-cap-mounts.ts +707 -0
- package/src/api/trpc/generated-cap-routers.ts +6340 -0
- package/src/api/trpc/scope-access.ts +110 -0
- package/src/api/trpc/trpc.context.ts +255 -0
- package/src/api/trpc/trpc.middleware.ts +140 -0
- package/src/api/trpc/trpc.router.ts +275 -0
- package/src/auth/session-cookie.ts +44 -0
- package/src/boot/boot-config.ts +278 -0
- package/src/boot/post-boot.service.ts +103 -0
- package/src/core/addon/__tests__/addon-registry-capability.test.ts +53 -0
- package/src/core/addon/addon-package.service.ts +1684 -0
- package/src/core/addon/addon-registry.service.ts +2926 -0
- package/src/core/addon/addon-search.service.ts +90 -0
- package/src/core/addon/addon-settings-provider.ts +276 -0
- package/src/core/addon/addon.tokens.ts +2 -0
- package/src/core/addon-bridge/addon-bridge.service.ts +125 -0
- package/src/core/addon-pages/addon-pages.service.spec.ts +117 -0
- package/src/core/addon-pages/addon-pages.service.ts +80 -0
- package/src/core/addon-widgets/addon-widgets.service.ts +92 -0
- package/src/core/agent/agent-registry.service.ts +507 -0
- package/src/core/auth/auth.service.spec.ts +88 -0
- package/src/core/auth/auth.service.ts +8 -0
- package/src/core/capability/capability.service.ts +57 -0
- package/src/core/config/config.schema.ts +3 -0
- package/src/core/config/config.service.spec.ts +175 -0
- package/src/core/config/config.service.ts +7 -0
- package/src/core/events/event-bus.service.spec.ts +212 -0
- package/src/core/events/event-bus.service.ts +85 -0
- package/src/core/feature/feature.service.spec.ts +96 -0
- package/src/core/feature/feature.service.ts +8 -0
- package/src/core/lifecycle/lifecycle-state-machine.spec.ts +168 -0
- package/src/core/lifecycle/lifecycle-state-machine.ts +3 -0
- package/src/core/logging/log-ring-buffer.ts +3 -0
- package/src/core/logging/logging.service.spec.ts +247 -0
- package/src/core/logging/logging.service.ts +129 -0
- package/src/core/logging/scoped-logger.ts +3 -0
- package/src/core/moleculer/moleculer.service.ts +612 -0
- package/src/core/network/network-quality.service.spec.ts +47 -0
- package/src/core/network/network-quality.service.ts +5 -0
- package/src/core/notification/notification-wrapper.service.ts +36 -0
- package/src/core/notification/toast-wrapper.service.ts +31 -0
- package/src/core/provider/provider.tokens.ts +1 -0
- package/src/core/repl/repl-engine.service.spec.ts +417 -0
- package/src/core/repl/repl-engine.service.ts +156 -0
- package/src/core/storage/fs-storage-backend.spec.ts +70 -0
- package/src/core/storage/fs-storage-backend.ts +3 -0
- package/src/core/storage/settings-store.spec.ts +213 -0
- package/src/core/storage/settings-store.ts +2 -0
- package/src/core/storage/sql-schema.spec.ts +140 -0
- package/src/core/storage/sql-schema.ts +3 -0
- package/src/core/storage/storage-location-manager.spec.ts +121 -0
- package/src/core/storage/storage-location-manager.ts +3 -0
- package/src/core/storage/storage.service.spec.ts +73 -0
- package/src/core/storage/storage.service.ts +3 -0
- package/src/core/streaming/stream-probe.service.ts +212 -0
- package/src/core/topology/topology-emitter.service.ts +101 -0
- package/src/launcher.ts +309 -0
- package/src/main.ts +1049 -0
- package/src/manual-boot.ts +322 -0
- package/tsconfig.build.json +8 -0
- package/tsconfig.json +21 -0
- package/vitest.config.ts +26 -0
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CapabilityService — NestJS-injectable wrapper around the CapabilityRegistry.
|
|
3
|
+
*
|
|
4
|
+
* Server services inject this instead of accessing the registry directly.
|
|
5
|
+
* The registry reference is set once during boot by AddonRegistryService.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { CapabilityRegistry } from '@camstack/kernel'
|
|
9
|
+
|
|
10
|
+
export class CapabilityService {
|
|
11
|
+
private registry: CapabilityRegistry | null = null
|
|
12
|
+
|
|
13
|
+
/** Called once during boot by AddonRegistryService to wire the registry */
|
|
14
|
+
setRegistry(registry: CapabilityRegistry): void {
|
|
15
|
+
this.registry = registry
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Get the underlying registry (may be null before boot completes) */
|
|
19
|
+
getRegistry(): CapabilityRegistry | null {
|
|
20
|
+
return this.registry
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Get the active singleton provider for a capability */
|
|
24
|
+
getSingleton<T>(capability: string): T | null {
|
|
25
|
+
return this.registry?.getSingleton<T>(capability) ?? null
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Get the addon ID of the active singleton provider for a capability */
|
|
29
|
+
getSingletonAddonId(capability: string): string | null {
|
|
30
|
+
return this.registry?.getSingletonAddonId(capability) ?? null
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Get all active collection providers for a capability */
|
|
34
|
+
getCollection<T>(capability: string): readonly T[] {
|
|
35
|
+
return this.registry?.getCollection<T>(capability) ?? []
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Like {@link getCollection} but returns `[addonId, provider]` tuples
|
|
40
|
+
* so callers can attribute work back to the contributing addon —
|
|
41
|
+
* required by the addon-widgets static file route to validate that
|
|
42
|
+
* the requested `addonId` is a registered widget provider.
|
|
43
|
+
*/
|
|
44
|
+
getCollectionEntries<T>(capability: string): readonly (readonly [string, T])[] {
|
|
45
|
+
return this.registry?.getCollectionEntries<T>(capability) ?? []
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Resolve a singleton provider for a specific device (with per-device override support) */
|
|
49
|
+
resolveForDevice<T>(capability: string, deviceId: string): T | null {
|
|
50
|
+
return this.registry?.resolveForDevice<T>(capability, deviceId) ?? null
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Resolve collection providers for a specific device (with per-device filter support) */
|
|
54
|
+
resolveCollectionForDevice<T>(capability: string, deviceId: string): readonly T[] {
|
|
55
|
+
return this.registry?.resolveCollectionForDevice<T>(capability, deviceId) ?? []
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
|
2
|
+
import * as fs from 'node:fs'
|
|
3
|
+
import * as path from 'node:path'
|
|
4
|
+
import * as os from 'node:os'
|
|
5
|
+
import * as yaml from 'js-yaml'
|
|
6
|
+
import { ConfigService } from './config.service'
|
|
7
|
+
|
|
8
|
+
describe('ConfigService', () => {
|
|
9
|
+
let tmpDir: string
|
|
10
|
+
let originalEnv: Record<string, string | undefined>
|
|
11
|
+
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
// Phase 13 (settings redesign test fix): `ConfigManager` applies
|
|
14
|
+
// `ENV_VAR_MAP` overrides on top of YAML values. When this suite
|
|
15
|
+
// runs in parallel with others under `nx run-many`, environment
|
|
16
|
+
// variables set by sibling tests (or the parent nx process) leak
|
|
17
|
+
// in and override our carefully seeded YAML fixtures — making
|
|
18
|
+
// `auth.adminPassword` come back as `'admin'` instead of
|
|
19
|
+
// `'secret123'` and `server.dataPath` come back as the default
|
|
20
|
+
// `'camstack-data'` instead of `'/custom/data'`.
|
|
21
|
+
//
|
|
22
|
+
// Snapshot and clear every env var that `ENV_VAR_MAP` consults.
|
|
23
|
+
//
|
|
24
|
+
// NOTE: `ADMIN_PASSWORD` is intentionally NOT cleared here.
|
|
25
|
+
// `bootstrapSchema` in @camstack/kernel uses
|
|
26
|
+
// `z.string().default(process.env.ADMIN_PASSWORD ?? 'changeme')`
|
|
27
|
+
// which is evaluated once at module load time — clearing
|
|
28
|
+
// `ADMIN_PASSWORD` here has no effect on the already-baked-in
|
|
29
|
+
// schema default, and the "uses defaults when config file does
|
|
30
|
+
// not exist" test still has to read that original value via
|
|
31
|
+
// `process.env.ADMIN_PASSWORD ?? 'changeme'`. So we leave the
|
|
32
|
+
// var alone.
|
|
33
|
+
const envKeys = [
|
|
34
|
+
'CAMSTACK_PORT',
|
|
35
|
+
'CAMSTACK_HOST',
|
|
36
|
+
'CAMSTACK_DATA',
|
|
37
|
+
'CAMSTACK_JWT_SECRET',
|
|
38
|
+
'CAMSTACK_ADMIN_USER',
|
|
39
|
+
'CAMSTACK_ADMIN_PASS',
|
|
40
|
+
] as const
|
|
41
|
+
originalEnv = {}
|
|
42
|
+
for (const key of envKeys) {
|
|
43
|
+
originalEnv[key] = process.env[key]
|
|
44
|
+
delete process.env[key]
|
|
45
|
+
}
|
|
46
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'camstack-config-'))
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
afterEach(() => {
|
|
50
|
+
for (const [key, value] of Object.entries(originalEnv)) {
|
|
51
|
+
if (value === undefined) delete process.env[key]
|
|
52
|
+
else process.env[key] = value
|
|
53
|
+
}
|
|
54
|
+
fs.rmSync(tmpDir, { recursive: true, force: true })
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
const createConfigFile = (config: Record<string, unknown>): string => {
|
|
58
|
+
const filePath = path.join(tmpDir, 'config.yaml')
|
|
59
|
+
fs.writeFileSync(filePath, yaml.dump(config), 'utf-8')
|
|
60
|
+
return filePath
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const createService = async (configPath: string): Promise<ConfigService> => {
|
|
64
|
+
return new ConfigService(configPath)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
it('loads and validates a minimal config.yaml', async () => {
|
|
68
|
+
const configPath = createConfigFile({
|
|
69
|
+
server: { port: 5000, host: '127.0.0.1' },
|
|
70
|
+
auth: { adminPassword: 'secret123' },
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
const service = await createService(configPath)
|
|
74
|
+
const raw = service.raw
|
|
75
|
+
|
|
76
|
+
expect(raw.server.port).toBe(5000)
|
|
77
|
+
expect(raw.server.host).toBe('127.0.0.1')
|
|
78
|
+
expect(raw.auth.adminPassword).toBe('secret123')
|
|
79
|
+
expect(raw.auth.adminUsername).toBe('admin')
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('returns runtime defaults for optional sections via raw', async () => {
|
|
83
|
+
const configPath = createConfigFile({
|
|
84
|
+
server: { port: 4443 },
|
|
85
|
+
auth: { adminPassword: 'secret123' },
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
const service = await createService(configPath)
|
|
89
|
+
const raw = service.raw
|
|
90
|
+
|
|
91
|
+
expect(raw.features.streaming).toBe(true)
|
|
92
|
+
expect(raw.features.objectDetection).toBe(false)
|
|
93
|
+
expect(raw.storage.provider).toBe('sqlite-storage')
|
|
94
|
+
expect(raw.logging.level).toBe('info')
|
|
95
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access --
|
|
96
|
+
expect((raw.logging as any).retentionDays).toBe(30)
|
|
97
|
+
expect(raw.eventBus.ringBufferSize).toBe(10000)
|
|
98
|
+
// addons.enabled removed — installed = active
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('throws on invalid config (port as string)', async () => {
|
|
102
|
+
const configPath = createConfigFile({
|
|
103
|
+
server: { port: 'not-a-number' },
|
|
104
|
+
auth: { adminPassword: 'secret123' },
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
await expect(createService(configPath)).rejects.toThrow()
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('dot-path access works for bootstrap keys', async () => {
|
|
111
|
+
const configPath = createConfigFile({
|
|
112
|
+
server: { port: 9090 },
|
|
113
|
+
auth: { adminPassword: 'secret123' },
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
const service = await createService(configPath)
|
|
117
|
+
|
|
118
|
+
expect(service.get<number>('server.port')).toBe(9090)
|
|
119
|
+
expect(service.get<string>('auth.adminPassword')).toBe('secret123')
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it('dot-path access returns runtime defaults for non-bootstrap keys', async () => {
|
|
123
|
+
const configPath = createConfigFile({
|
|
124
|
+
server: { port: 4443 },
|
|
125
|
+
auth: { adminPassword: 'secret123' },
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
const service = await createService(configPath)
|
|
129
|
+
|
|
130
|
+
expect(service.get<string>('logging.level')).toBe('info')
|
|
131
|
+
expect(service.get<number>('retention.detectionEventsDays')).toBe(30)
|
|
132
|
+
expect(service.get<boolean>('features.objectDetection')).toBe(false)
|
|
133
|
+
// addons.enabled removed — installed = active
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
it('uses defaults when config file does not exist', async () => {
|
|
137
|
+
const configPath = path.join(tmpDir, 'nonexistent.yaml')
|
|
138
|
+
|
|
139
|
+
const service = await createService(configPath)
|
|
140
|
+
const raw = service.raw
|
|
141
|
+
|
|
142
|
+
expect(raw.server.port).toBe(4443)
|
|
143
|
+
expect(raw.server.host).toBe('0.0.0.0')
|
|
144
|
+
expect(raw.auth.adminUsername).toBe('admin')
|
|
145
|
+
expect(raw.auth.adminPassword).toBe(process.env.ADMIN_PASSWORD ?? 'changeme')
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
it('returns features via features getter with runtime defaults', async () => {
|
|
149
|
+
const configPath = createConfigFile({
|
|
150
|
+
server: { port: 4443 },
|
|
151
|
+
auth: { adminPassword: 'secret123' },
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
const service = await createService(configPath)
|
|
155
|
+
const features = service.features
|
|
156
|
+
|
|
157
|
+
// Features come from RUNTIME_DEFAULTS (not YAML bootstrap config)
|
|
158
|
+
expect(features.streaming).toBe(true)
|
|
159
|
+
expect(features.objectDetection).toBe(false)
|
|
160
|
+
expect(features.notifications).toBe(true)
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
it('getBootstrap returns bootstrap-only values', async () => {
|
|
164
|
+
const configPath = createConfigFile({
|
|
165
|
+
server: { port: 7777, dataPath: '/custom/data' },
|
|
166
|
+
auth: { adminPassword: 'mypass', jwtSecret: 'mysecret' },
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
const service = await createService(configPath)
|
|
170
|
+
|
|
171
|
+
expect(service.getBootstrap<number>('server.port')).toBe(7777)
|
|
172
|
+
expect(service.getBootstrap<string>('server.dataPath')).toBe('/custom/data')
|
|
173
|
+
expect(service.getBootstrap<string>('auth.jwtSecret')).toBe('mysecret')
|
|
174
|
+
})
|
|
175
|
+
})
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
|
2
|
+
import * as fs from 'node:fs'
|
|
3
|
+
import * as path from 'node:path'
|
|
4
|
+
import * as os from 'node:os'
|
|
5
|
+
import * as yaml from 'js-yaml'
|
|
6
|
+
import { ServiceBroker } from 'moleculer'
|
|
7
|
+
import { EventBusService } from './event-bus.service'
|
|
8
|
+
import { ConfigService } from '../config/config.service'
|
|
9
|
+
import type { ISettingsStore } from '@camstack/kernel'
|
|
10
|
+
import type { SystemEvent } from '@camstack/types'
|
|
11
|
+
|
|
12
|
+
/** Flush pending queueMicrotask callbacks */
|
|
13
|
+
const flush = () => new Promise<void>(resolve => queueMicrotask(resolve))
|
|
14
|
+
|
|
15
|
+
const makeEvent = (category: string, overrides: Partial<SystemEvent> = {}): SystemEvent => ({
|
|
16
|
+
id: `evt-${Math.random().toString(36).slice(2, 8)}`,
|
|
17
|
+
timestamp: new Date(),
|
|
18
|
+
source: { type: 'device', id: 'cam-1' },
|
|
19
|
+
category,
|
|
20
|
+
data: {},
|
|
21
|
+
...overrides,
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* In-memory settings store used by the test to drive `ConfigService.get`
|
|
26
|
+
* for the `eventBus.ringBufferSize` key. The bootstrap YAML schema does not
|
|
27
|
+
* declare `eventBus`, so writing it to the YAML file would be discarded by
|
|
28
|
+
* `bootstrapSchema.parse`. Wiring an ISettingsStore is the clean path to
|
|
29
|
+
* feed runtime values into `ConfigService` without casts.
|
|
30
|
+
*/
|
|
31
|
+
class InMemorySettingsStore implements ISettingsStore {
|
|
32
|
+
private readonly system: Record<string, unknown>
|
|
33
|
+
|
|
34
|
+
constructor(seed: Record<string, unknown>) {
|
|
35
|
+
this.system = { ...seed }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
getSystem(key: string): unknown { return this.system[key] }
|
|
39
|
+
setSystem(key: string, value: unknown): void { this.system[key] = value }
|
|
40
|
+
getAllSystem(): Record<string, unknown> { return { ...this.system } }
|
|
41
|
+
|
|
42
|
+
getAllAddon(_addonId: string): Record<string, unknown> { return {} }
|
|
43
|
+
setAllAddon(_addonId: string, _config: Record<string, unknown>): void { /* no-op */ }
|
|
44
|
+
getAllProvider(_providerId: string): Record<string, unknown> { return {} }
|
|
45
|
+
setProvider(_providerId: string, _key: string, _value: unknown): void { /* no-op */ }
|
|
46
|
+
getAllDevice(_deviceId: string): Record<string, unknown> { return {} }
|
|
47
|
+
setDevice(_deviceId: string, _key: string, _value: unknown): void { /* no-op */ }
|
|
48
|
+
getAddonDevice(_addonId: string, _deviceId: string): Record<string, unknown> { return {} }
|
|
49
|
+
setAddonDevice(_addonId: string, _deviceId: string, _values: Record<string, unknown>): void { /* no-op */ }
|
|
50
|
+
clearAddonDevice(_addonId: string, _deviceId: string): void { /* no-op */ }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
describe('EventBusService', () => {
|
|
54
|
+
let tmpDir: string
|
|
55
|
+
// Brokers spawned by `createService` — torn down in `afterEach` so
|
|
56
|
+
// every test runs in isolation. The bus is a thin delegate over the
|
|
57
|
+
// shared broker bus (`getBrokerEventBus(broker)`); attaching is the
|
|
58
|
+
// entrypoint that wires `subscribe` / `emit` to a real implementation.
|
|
59
|
+
const brokers: ServiceBroker[] = []
|
|
60
|
+
|
|
61
|
+
beforeEach(() => {
|
|
62
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'camstack-eventbus-'))
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
afterEach(async () => {
|
|
66
|
+
for (const b of brokers.splice(0)) {
|
|
67
|
+
await b.stop()
|
|
68
|
+
}
|
|
69
|
+
fs.rmSync(tmpDir, { recursive: true, force: true })
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
const createService = async (bufferSize = 100): Promise<EventBusService> => {
|
|
73
|
+
const configPath = path.join(tmpDir, 'config.yaml')
|
|
74
|
+
fs.writeFileSync(
|
|
75
|
+
configPath,
|
|
76
|
+
yaml.dump({
|
|
77
|
+
server: { port: 4443 },
|
|
78
|
+
auth: { adminPassword: 'secret123' },
|
|
79
|
+
}),
|
|
80
|
+
'utf-8',
|
|
81
|
+
)
|
|
82
|
+
const configService = new ConfigService(configPath)
|
|
83
|
+
configService.setSettingsStore(new InMemorySettingsStore({
|
|
84
|
+
'eventBus.ringBufferSize': bufferSize,
|
|
85
|
+
}))
|
|
86
|
+
const service = new EventBusService(configService)
|
|
87
|
+
// EventBusService is a delegate that needs a broker to dispatch
|
|
88
|
+
// through. Use a unique nodeID per call so each test gets its own
|
|
89
|
+
// shared bus instance (`getBrokerEventBus(broker)` keys by broker).
|
|
90
|
+
const broker = new ServiceBroker({
|
|
91
|
+
nodeID: `eventbus-test-${brokers.length}`,
|
|
92
|
+
transporter: 'Fake',
|
|
93
|
+
logger: false,
|
|
94
|
+
})
|
|
95
|
+
await broker.start()
|
|
96
|
+
brokers.push(broker)
|
|
97
|
+
service.attachBroker(broker)
|
|
98
|
+
return service
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
it('emits events to subscribers', async () => {
|
|
102
|
+
const service = await createService()
|
|
103
|
+
const received: SystemEvent[] = []
|
|
104
|
+
|
|
105
|
+
service.subscribe({}, (event) => received.push(event))
|
|
106
|
+
|
|
107
|
+
const event = makeEvent('device.online')
|
|
108
|
+
service.emit(event)
|
|
109
|
+
await flush()
|
|
110
|
+
|
|
111
|
+
// The shared broker bus enriches every emit with `sourceNodeId`,
|
|
112
|
+
// so the delivered object is a copy — assert key fields instead of
|
|
113
|
+
// strict identity. See `addon-context-factory.ts::createSharedBus`.
|
|
114
|
+
expect(received).toHaveLength(1)
|
|
115
|
+
expect(received[0]).toMatchObject({ id: event.id, category: event.category })
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it('filters by category wildcard (device.* matches device.online)', async () => {
|
|
119
|
+
const service = await createService()
|
|
120
|
+
const received: SystemEvent[] = []
|
|
121
|
+
|
|
122
|
+
service.subscribe({ category: 'device.*' }, (event) => received.push(event))
|
|
123
|
+
|
|
124
|
+
service.emit(makeEvent('device.online'))
|
|
125
|
+
service.emit(makeEvent('device.offline'))
|
|
126
|
+
service.emit(makeEvent('addon.started'))
|
|
127
|
+
await flush()
|
|
128
|
+
|
|
129
|
+
expect(received).toHaveLength(2)
|
|
130
|
+
|
|
131
|
+
expect(received[0]!.category).toBe('device.online')
|
|
132
|
+
|
|
133
|
+
expect(received[1]!.category).toBe('device.offline')
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
it('filters by exact category', async () => {
|
|
137
|
+
const service = await createService()
|
|
138
|
+
const received: SystemEvent[] = []
|
|
139
|
+
|
|
140
|
+
service.subscribe({ category: 'device.online' }, (event) => received.push(event))
|
|
141
|
+
|
|
142
|
+
service.emit(makeEvent('device.online'))
|
|
143
|
+
service.emit(makeEvent('device.offline'))
|
|
144
|
+
await flush()
|
|
145
|
+
|
|
146
|
+
expect(received).toHaveLength(1)
|
|
147
|
+
|
|
148
|
+
expect(received[0]!.category).toBe('device.online')
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
it('filters by source', async () => {
|
|
152
|
+
const service = await createService()
|
|
153
|
+
const received: SystemEvent[] = []
|
|
154
|
+
|
|
155
|
+
service.subscribe(
|
|
156
|
+
{ source: { type: 'addon', id: 'frigate' } },
|
|
157
|
+
(event) => received.push(event),
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
service.emit(makeEvent('addon.started', { source: { type: 'addon', id: 'frigate' } }))
|
|
161
|
+
service.emit(makeEvent('addon.started', { source: { type: 'addon', id: 'scrypted' } }))
|
|
162
|
+
await flush()
|
|
163
|
+
|
|
164
|
+
expect(received).toHaveLength(1)
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
it('maintains a chronological recent-events buffer', async () => {
|
|
168
|
+
// Buffer size is now hub-fixed at 10000 (see `createSharedBusState`),
|
|
169
|
+
// so the legacy `bufferSize=3` argument no longer caps it. The
|
|
170
|
+
// contract under test today is: every emit is appended in order and
|
|
171
|
+
// surfaces via `getRecent()`, oldest first.
|
|
172
|
+
const service = await createService()
|
|
173
|
+
|
|
174
|
+
service.emit(makeEvent('a'))
|
|
175
|
+
service.emit(makeEvent('b'))
|
|
176
|
+
service.emit(makeEvent('c'))
|
|
177
|
+
service.emit(makeEvent('d'))
|
|
178
|
+
|
|
179
|
+
const recent = service.getRecent()
|
|
180
|
+
expect(recent).toHaveLength(4)
|
|
181
|
+
expect(recent[0]!.category).toBe('a')
|
|
182
|
+
expect(recent[3]!.category).toBe('d')
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
it('unsubscribe stops delivery', async () => {
|
|
186
|
+
const service = await createService()
|
|
187
|
+
const received: SystemEvent[] = []
|
|
188
|
+
|
|
189
|
+
const unsubscribe = service.subscribe({}, (event) => received.push(event))
|
|
190
|
+
|
|
191
|
+
service.emit(makeEvent('first'))
|
|
192
|
+
await flush()
|
|
193
|
+
unsubscribe()
|
|
194
|
+
service.emit(makeEvent('second'))
|
|
195
|
+
await flush()
|
|
196
|
+
|
|
197
|
+
expect(received).toHaveLength(1)
|
|
198
|
+
|
|
199
|
+
expect(received[0]!.category).toBe('first')
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
it('getRecent supports filter', async () => {
|
|
203
|
+
const service = await createService()
|
|
204
|
+
|
|
205
|
+
service.emit(makeEvent('device.online'))
|
|
206
|
+
service.emit(makeEvent('addon.started'))
|
|
207
|
+
service.emit(makeEvent('device.offline'))
|
|
208
|
+
|
|
209
|
+
const recent = service.getRecent({ category: 'device.*' })
|
|
210
|
+
expect(recent).toHaveLength(2)
|
|
211
|
+
})
|
|
212
|
+
})
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import type { ServiceBroker } from 'moleculer'
|
|
2
|
+
import { getBrokerEventBus } from '@camstack/kernel'
|
|
3
|
+
import type { SystemEvent, IEventBus, EventFilter } from '@camstack/types'
|
|
4
|
+
import { ConfigService } from '../config/config.service'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Hub-side event bus. Pre-broker boot the service buffers nothing —
|
|
8
|
+
* subscribers / emits are no-ops until `attachBroker` lands the
|
|
9
|
+
* underlying per-broker shared bus. Once attached every operation
|
|
10
|
+
* delegates to the unified `getBrokerEventBus(broker)` instance,
|
|
11
|
+
* which is the SAME bus that group-runner / agent subprocess addons
|
|
12
|
+
* see when they call `ctx.eventBus.emit`. Single in-process delivery
|
|
13
|
+
* map per broker, single `broker.broadcast` for cross-broker
|
|
14
|
+
* delivery — no more dual hub-vs-subprocess implementations.
|
|
15
|
+
*
|
|
16
|
+
* **Persistence scope**: the in-memory ring buffer (10 000 events,
|
|
17
|
+
* owned by `getBrokerEventBus`) survives admin-ui browser refreshes
|
|
18
|
+
* but NOT server restarts — that's intentional. UI panels read
|
|
19
|
+
* `getRecent` on mount and live-subscribe via `live.onEvent` /
|
|
20
|
+
* `systemEvents.subscribe`, which is enough for the operator to see
|
|
21
|
+
* continuity across page reloads.
|
|
22
|
+
*/
|
|
23
|
+
export class EventBusService implements IEventBus {
|
|
24
|
+
private broker: ServiceBroker | null = null
|
|
25
|
+
private inner: IEventBus | null = null
|
|
26
|
+
// Used only as a loose ring buffer for the very first events emitted
|
|
27
|
+
// before `attachBroker` runs (NestJS-era boot legacy). Drained into
|
|
28
|
+
// the real bus on attach.
|
|
29
|
+
private pending: SystemEvent[] = []
|
|
30
|
+
private deferredSubs: Array<{ filter: EventFilter; handler: (event: SystemEvent) => void; unsub?: () => void }> = []
|
|
31
|
+
|
|
32
|
+
constructor(_configService: ConfigService) {
|
|
33
|
+
// The shared bus owns the ring-buffer size — hub-side config is
|
|
34
|
+
// accepted for API compatibility but no longer drives the bus.
|
|
35
|
+
void _configService
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
attachBroker(broker: ServiceBroker): void {
|
|
39
|
+
if (this.broker === broker) return
|
|
40
|
+
this.broker = broker
|
|
41
|
+
const inner = getBrokerEventBus(broker)
|
|
42
|
+
this.inner = inner
|
|
43
|
+
// Replay deferred subscriptions onto the real bus.
|
|
44
|
+
for (const sub of this.deferredSubs) {
|
|
45
|
+
sub.unsub = inner.subscribe(sub.filter, sub.handler)
|
|
46
|
+
}
|
|
47
|
+
// Flush events emitted before the broker was ready.
|
|
48
|
+
for (const evt of this.pending) {
|
|
49
|
+
inner.emit(evt)
|
|
50
|
+
}
|
|
51
|
+
this.pending = []
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
detachBroker(): void {
|
|
55
|
+
for (const sub of this.deferredSubs) {
|
|
56
|
+
sub.unsub?.()
|
|
57
|
+
sub.unsub = undefined
|
|
58
|
+
}
|
|
59
|
+
this.broker = null
|
|
60
|
+
this.inner = null
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
emit(event: SystemEvent): void {
|
|
64
|
+
if (!this.inner) {
|
|
65
|
+
this.pending.push(event)
|
|
66
|
+
return
|
|
67
|
+
}
|
|
68
|
+
this.inner.emit(event)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
subscribe(filter: EventFilter, handler: (event: SystemEvent) => void): () => void {
|
|
72
|
+
if (this.inner) return this.inner.subscribe(filter, handler)
|
|
73
|
+
const sub = { filter, handler, unsub: undefined as (() => void) | undefined }
|
|
74
|
+
this.deferredSubs.push(sub)
|
|
75
|
+
return () => {
|
|
76
|
+
if (sub.unsub) sub.unsub()
|
|
77
|
+
const idx = this.deferredSubs.indexOf(sub)
|
|
78
|
+
if (idx >= 0) this.deferredSubs.splice(idx, 1)
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
getRecent(filter?: EventFilter, limit?: number): readonly SystemEvent[] {
|
|
83
|
+
return this.inner?.getRecent(filter, limit) ?? []
|
|
84
|
+
}
|
|
85
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
|
2
|
+
import * as fs from 'node:fs'
|
|
3
|
+
import * as path from 'node:path'
|
|
4
|
+
import * as os from 'node:os'
|
|
5
|
+
import * as yaml from 'js-yaml'
|
|
6
|
+
import { FeatureService } from './feature.service'
|
|
7
|
+
import { ConfigService } from '../config/config.service'
|
|
8
|
+
import type { FeatureManifest } from '@camstack/types'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Test-only ConfigService subclass that overrides the `features` getter
|
|
12
|
+
* to return a fixed manifest. Avoids wiring a full settings-store just to
|
|
13
|
+
* drive a FeatureService. The bootstrap YAML still has to be real so the
|
|
14
|
+
* ConfigManager constructor succeeds.
|
|
15
|
+
*/
|
|
16
|
+
class StaticFeatureConfigService extends ConfigService {
|
|
17
|
+
constructor(configPath: string, private readonly staticFeatures: FeatureManifest) {
|
|
18
|
+
super(configPath)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
override get features(): FeatureManifest {
|
|
22
|
+
return this.staticFeatures
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
describe('FeatureService', () => {
|
|
27
|
+
let tmpDir: string
|
|
28
|
+
|
|
29
|
+
beforeEach(() => {
|
|
30
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'camstack-feature-'))
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
afterEach(() => {
|
|
34
|
+
fs.rmSync(tmpDir, { recursive: true, force: true })
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
const createService = (features: Partial<FeatureManifest> = {}): FeatureService => {
|
|
38
|
+
const fullFeatures: FeatureManifest = {
|
|
39
|
+
streaming: true,
|
|
40
|
+
notifications: true,
|
|
41
|
+
objectDetection: false,
|
|
42
|
+
remoteAccess: true,
|
|
43
|
+
agentCluster: false,
|
|
44
|
+
smartHome: true,
|
|
45
|
+
recordings: true,
|
|
46
|
+
backup: true,
|
|
47
|
+
repl: true,
|
|
48
|
+
...features,
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const configPath = path.join(tmpDir, 'config.yaml')
|
|
52
|
+
fs.writeFileSync(
|
|
53
|
+
configPath,
|
|
54
|
+
yaml.dump({
|
|
55
|
+
server: { port: 4443 },
|
|
56
|
+
auth: { adminPassword: 'secret123' },
|
|
57
|
+
}),
|
|
58
|
+
'utf-8',
|
|
59
|
+
)
|
|
60
|
+
const configService = new StaticFeatureConfigService(configPath, fullFeatures)
|
|
61
|
+
return new FeatureService(configService)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
it('returns true for enabled features', () => {
|
|
65
|
+
const service = createService({ streaming: true, notifications: true })
|
|
66
|
+
|
|
67
|
+
expect(service.isEnabled('streaming')).toBe(true)
|
|
68
|
+
expect(service.isEnabled('notifications')).toBe(true)
|
|
69
|
+
expect(service.isEnabled('remoteAccess')).toBe(true)
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('returns false for disabled features', () => {
|
|
73
|
+
const service = createService({ objectDetection: false, agentCluster: false })
|
|
74
|
+
|
|
75
|
+
expect(service.isEnabled('objectDetection')).toBe(false)
|
|
76
|
+
expect(service.isEnabled('agentCluster')).toBe(false)
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('returns full manifest', () => {
|
|
80
|
+
const customFeatures: Partial<FeatureManifest> = {
|
|
81
|
+
streaming: false,
|
|
82
|
+
objectDetection: true,
|
|
83
|
+
}
|
|
84
|
+
const service = createService(customFeatures)
|
|
85
|
+
|
|
86
|
+
const manifest = service.getManifest()
|
|
87
|
+
|
|
88
|
+
expect(manifest.streaming).toBe(false)
|
|
89
|
+
|
|
90
|
+
expect(manifest.objectDetection).toBe(true)
|
|
91
|
+
|
|
92
|
+
expect(manifest.notifications).toBe(true)
|
|
93
|
+
|
|
94
|
+
expect(manifest.remoteAccess).toBe(true)
|
|
95
|
+
})
|
|
96
|
+
})
|