@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.
Files changed (133) hide show
  1. package/.env.example +17 -0
  2. package/package.json +55 -0
  3. package/src/__tests__/addon-install-e2e.test.ts +75 -0
  4. package/src/__tests__/addon-pages-e2e.test.ts +178 -0
  5. package/src/__tests__/addon-route-session.test.ts +17 -0
  6. package/src/__tests__/addon-settings-router.spec.ts +62 -0
  7. package/src/__tests__/addon-upload.spec.ts +355 -0
  8. package/src/__tests__/agent-registry.spec.ts +162 -0
  9. package/src/__tests__/agent-status-page.spec.ts +84 -0
  10. package/src/__tests__/auth-session-cookie.test.ts +21 -0
  11. package/src/__tests__/cap-providers/cap-usage-graph.spec.ts +23 -0
  12. package/src/__tests__/cap-providers/compute-topology-categories.spec.ts +64 -0
  13. package/src/__tests__/cap-routers/_meta.spec.ts +200 -0
  14. package/src/__tests__/cap-routers/addon-settings.router.spec.ts +106 -0
  15. package/src/__tests__/cap-routers/device-manager-aggregate.router.spec.ts +142 -0
  16. package/src/__tests__/cap-routers/harness.ts +159 -0
  17. package/src/__tests__/cap-routers/metrics-provider.router.spec.ts +119 -0
  18. package/src/__tests__/cap-routers/null-provider-guard.spec.ts +66 -0
  19. package/src/__tests__/cap-routers/pipeline-executor.router.spec.ts +135 -0
  20. package/src/__tests__/cap-routers/settings-store.router.spec.ts +247 -0
  21. package/src/__tests__/capability-e2e.test.ts +386 -0
  22. package/src/__tests__/cli-e2e.test.ts +129 -0
  23. package/src/__tests__/core-cap-bridge.spec.ts +89 -0
  24. package/src/__tests__/embedded-deps-e2e.test.ts +109 -0
  25. package/src/__tests__/event-bus-proxy-router.spec.ts +72 -0
  26. package/src/__tests__/fixtures/mock-analysis-addon-a.ts +37 -0
  27. package/src/__tests__/fixtures/mock-analysis-addon-b.ts +37 -0
  28. package/src/__tests__/fixtures/mock-log-addon.ts +37 -0
  29. package/src/__tests__/fixtures/mock-storage-addon.ts +40 -0
  30. package/src/__tests__/framework-allowlist.spec.ts +95 -0
  31. package/src/__tests__/https-e2e.test.ts +118 -0
  32. package/src/__tests__/lifecycle-e2e.test.ts +140 -0
  33. package/src/__tests__/live-events-subscription.spec.ts +150 -0
  34. package/src/__tests__/moleculer-register-node-idempotency.spec.ts +229 -0
  35. package/src/__tests__/oauth2-account-linking.spec.ts +736 -0
  36. package/src/__tests__/post-boot-restart.spec.ts +161 -0
  37. package/src/__tests__/singleton-contention.test.ts +487 -0
  38. package/src/__tests__/streaming-diagnostic.test.ts +512 -0
  39. package/src/__tests__/streaming-scale.test.ts +280 -0
  40. package/src/agent-status-page.ts +121 -0
  41. package/src/api/__tests__/addons-custom.spec.ts +134 -0
  42. package/src/api/__tests__/capabilities.router.test.ts +47 -0
  43. package/src/api/addon-upload.ts +472 -0
  44. package/src/api/addons-custom.router.ts +100 -0
  45. package/src/api/auth-whoami.ts +99 -0
  46. package/src/api/bridge-addons.router.ts +120 -0
  47. package/src/api/capabilities.router.ts +226 -0
  48. package/src/api/core/__tests__/auth-router-totp.spec.ts +256 -0
  49. package/src/api/core/addon-settings.router.ts +124 -0
  50. package/src/api/core/agents.router.ts +87 -0
  51. package/src/api/core/auth.router.ts +303 -0
  52. package/src/api/core/cap-providers.ts +993 -0
  53. package/src/api/core/capabilities.router.ts +119 -0
  54. package/src/api/core/collection-preference.ts +40 -0
  55. package/src/api/core/event-bus-proxy.router.ts +45 -0
  56. package/src/api/core/hwaccel.router.ts +81 -0
  57. package/src/api/core/live-events.router.ts +60 -0
  58. package/src/api/core/logs.router.ts +162 -0
  59. package/src/api/core/notifications.router.ts +65 -0
  60. package/src/api/core/repl.router.ts +41 -0
  61. package/src/api/core/settings-backend.router.ts +142 -0
  62. package/src/api/core/stream-probe.router.ts +57 -0
  63. package/src/api/core/system-events.router.ts +116 -0
  64. package/src/api/health/health.routes.ts +123 -0
  65. package/src/api/oauth2/__tests__/oauth2-routes.spec.ts +52 -0
  66. package/src/api/oauth2/consent-page.ts +42 -0
  67. package/src/api/oauth2/oauth2-routes.ts +248 -0
  68. package/src/api/trpc/__tests__/scope-access-device.spec.ts +223 -0
  69. package/src/api/trpc/__tests__/scope-access.spec.ts +107 -0
  70. package/src/api/trpc/cap-mount-helpers.ts +225 -0
  71. package/src/api/trpc/core-cap-bridge.ts +152 -0
  72. package/src/api/trpc/generated-cap-mounts.ts +707 -0
  73. package/src/api/trpc/generated-cap-routers.ts +6340 -0
  74. package/src/api/trpc/scope-access.ts +110 -0
  75. package/src/api/trpc/trpc.context.ts +255 -0
  76. package/src/api/trpc/trpc.middleware.ts +140 -0
  77. package/src/api/trpc/trpc.router.ts +275 -0
  78. package/src/auth/session-cookie.ts +44 -0
  79. package/src/boot/boot-config.ts +278 -0
  80. package/src/boot/post-boot.service.ts +103 -0
  81. package/src/core/addon/__tests__/addon-registry-capability.test.ts +53 -0
  82. package/src/core/addon/addon-package.service.ts +1684 -0
  83. package/src/core/addon/addon-registry.service.ts +2926 -0
  84. package/src/core/addon/addon-search.service.ts +90 -0
  85. package/src/core/addon/addon-settings-provider.ts +276 -0
  86. package/src/core/addon/addon.tokens.ts +2 -0
  87. package/src/core/addon-bridge/addon-bridge.service.ts +125 -0
  88. package/src/core/addon-pages/addon-pages.service.spec.ts +117 -0
  89. package/src/core/addon-pages/addon-pages.service.ts +80 -0
  90. package/src/core/addon-widgets/addon-widgets.service.ts +92 -0
  91. package/src/core/agent/agent-registry.service.ts +507 -0
  92. package/src/core/auth/auth.service.spec.ts +88 -0
  93. package/src/core/auth/auth.service.ts +8 -0
  94. package/src/core/capability/capability.service.ts +57 -0
  95. package/src/core/config/config.schema.ts +3 -0
  96. package/src/core/config/config.service.spec.ts +175 -0
  97. package/src/core/config/config.service.ts +7 -0
  98. package/src/core/events/event-bus.service.spec.ts +212 -0
  99. package/src/core/events/event-bus.service.ts +85 -0
  100. package/src/core/feature/feature.service.spec.ts +96 -0
  101. package/src/core/feature/feature.service.ts +8 -0
  102. package/src/core/lifecycle/lifecycle-state-machine.spec.ts +168 -0
  103. package/src/core/lifecycle/lifecycle-state-machine.ts +3 -0
  104. package/src/core/logging/log-ring-buffer.ts +3 -0
  105. package/src/core/logging/logging.service.spec.ts +247 -0
  106. package/src/core/logging/logging.service.ts +129 -0
  107. package/src/core/logging/scoped-logger.ts +3 -0
  108. package/src/core/moleculer/moleculer.service.ts +612 -0
  109. package/src/core/network/network-quality.service.spec.ts +47 -0
  110. package/src/core/network/network-quality.service.ts +5 -0
  111. package/src/core/notification/notification-wrapper.service.ts +36 -0
  112. package/src/core/notification/toast-wrapper.service.ts +31 -0
  113. package/src/core/provider/provider.tokens.ts +1 -0
  114. package/src/core/repl/repl-engine.service.spec.ts +417 -0
  115. package/src/core/repl/repl-engine.service.ts +156 -0
  116. package/src/core/storage/fs-storage-backend.spec.ts +70 -0
  117. package/src/core/storage/fs-storage-backend.ts +3 -0
  118. package/src/core/storage/settings-store.spec.ts +213 -0
  119. package/src/core/storage/settings-store.ts +2 -0
  120. package/src/core/storage/sql-schema.spec.ts +140 -0
  121. package/src/core/storage/sql-schema.ts +3 -0
  122. package/src/core/storage/storage-location-manager.spec.ts +121 -0
  123. package/src/core/storage/storage-location-manager.ts +3 -0
  124. package/src/core/storage/storage.service.spec.ts +73 -0
  125. package/src/core/storage/storage.service.ts +3 -0
  126. package/src/core/streaming/stream-probe.service.ts +212 -0
  127. package/src/core/topology/topology-emitter.service.ts +101 -0
  128. package/src/launcher.ts +309 -0
  129. package/src/main.ts +1049 -0
  130. package/src/manual-boot.ts +322 -0
  131. package/tsconfig.build.json +8 -0
  132. package/tsconfig.json +21 -0
  133. package/vitest.config.ts +26 -0
@@ -0,0 +1,168 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest'
2
+ import { LifecycleStateMachine } from './lifecycle-state-machine'
3
+ import type { IEventBus, SystemEvent } from '@camstack/types'
4
+ import type { IScopedLogger } from '@camstack/types'
5
+
6
+ const createMockEventBus = (): IEventBus & { emitted: SystemEvent[] } => {
7
+ const emitted: SystemEvent[] = []
8
+ return {
9
+ emitted,
10
+ emit: vi.fn((event: SystemEvent) => emitted.push(event)),
11
+ subscribe: vi.fn(() => () => {}),
12
+ getRecent: vi.fn(() => []),
13
+ }
14
+ }
15
+
16
+ const createMockLogger = (): IScopedLogger => ({
17
+ debug: vi.fn(),
18
+ info: vi.fn(),
19
+ warn: vi.fn(),
20
+ error: vi.fn(),
21
+ child: vi.fn(),
22
+ })
23
+
24
+ describe('LifecycleStateMachine', () => {
25
+ let eventBus: ReturnType<typeof createMockEventBus>
26
+ let logger: IScopedLogger
27
+ let machine: LifecycleStateMachine
28
+
29
+ beforeEach(() => {
30
+
31
+ eventBus = createMockEventBus()
32
+
33
+ logger = createMockLogger()
34
+ machine = new LifecycleStateMachine('test-element', 'device', eventBus, logger)
35
+ })
36
+
37
+ it('starts in stopped state', () => {
38
+ expect(machine.state).toBe('stopped')
39
+ expect(machine.getStatus().state).toBe('stopped')
40
+ expect(machine.getStatus().restartCount).toBe(0)
41
+ expect(machine.getStatus().uptime).toBe(0)
42
+ })
43
+
44
+ it('allows valid transition: stopped → starting → running', () => {
45
+ const result1 = machine.transition('starting')
46
+ expect(result1).toBe(true)
47
+ expect(machine.state).toBe('starting')
48
+
49
+ const result2 = machine.transition('running')
50
+ expect(result2).toBe(true)
51
+ expect(machine.state).toBe('running')
52
+ })
53
+
54
+ it('rejects invalid transition (e.g. stopped → running directly)', () => {
55
+ const result = machine.transition('running')
56
+ expect(result).toBe(false)
57
+ expect(machine.state).toBe('stopped')
58
+
59
+ expect(logger.warn).toHaveBeenCalledWith(
60
+ expect.stringContaining('Invalid state transition'),
61
+ expect.anything(),
62
+ )
63
+ })
64
+
65
+ it('emits event on bus for each valid transition', () => {
66
+ machine.transition('starting')
67
+ machine.transition('running')
68
+
69
+
70
+ expect(eventBus.emit).toHaveBeenCalledTimes(2)
71
+
72
+ expect(eventBus.emitted[0]!.category).toBe('device.state.starting')
73
+
74
+ expect(eventBus.emitted[0]!.source).toEqual({ type: 'device', id: 'test-element' })
75
+
76
+ expect(eventBus.emitted[0]!.data).toMatchObject({
77
+ from: 'stopped',
78
+ to: 'starting',
79
+ elementId: 'test-element',
80
+ })
81
+
82
+ expect(eventBus.emitted[1]!.category).toBe('device.state.running')
83
+
84
+ expect(eventBus.emitted[1]!.data).toMatchObject({
85
+ from: 'starting',
86
+ to: 'running',
87
+ })
88
+ })
89
+
90
+ it('does not emit event on invalid transition', () => {
91
+ machine.transition('running')
92
+
93
+ expect(eventBus.emit).not.toHaveBeenCalled()
94
+ })
95
+
96
+ it('tracks uptime when running', () => {
97
+ machine.transition('starting')
98
+ machine.transition('running')
99
+
100
+ const status = machine.getStatus()
101
+ expect(status.uptime).toBeGreaterThanOrEqual(0)
102
+ expect(status.startedAt).toBeDefined()
103
+ expect(status.stoppedAt).toBeUndefined()
104
+ })
105
+
106
+ it('records error message in error state', () => {
107
+ machine.transition('starting')
108
+ machine.transition('error', 'connection refused')
109
+
110
+ const status = machine.getStatus()
111
+ expect(status.state).toBe('error')
112
+ expect(status.error).toBe('connection refused')
113
+ expect(status.stoppedAt).toBeDefined()
114
+ })
115
+
116
+ it('getStatus returns complete info after full lifecycle', () => {
117
+ machine.transition('starting')
118
+ machine.transition('running')
119
+ machine.transition('stopping')
120
+ machine.transition('stopped')
121
+
122
+ const status = machine.getStatus()
123
+ expect(status.state).toBe('stopped')
124
+ expect(status.restartCount).toBe(0)
125
+ expect(status.stoppedAt).toBeDefined()
126
+ expect(status.uptime).toBe(0)
127
+ })
128
+
129
+ it('increments restart count only on subsequent starts', () => {
130
+ // First start
131
+ machine.transition('starting')
132
+ machine.transition('running')
133
+ expect(machine.getStatus().restartCount).toBe(0)
134
+
135
+ // Stop and restart
136
+ machine.transition('stopping')
137
+ machine.transition('stopped')
138
+ machine.transition('starting')
139
+ machine.transition('running')
140
+ expect(machine.getStatus().restartCount).toBe(1)
141
+
142
+ // Error and restart
143
+ machine.transition('error', 'crash')
144
+ machine.transition('starting')
145
+ machine.transition('running')
146
+ expect(machine.getStatus().restartCount).toBe(2)
147
+ })
148
+
149
+ it('allows disabled state from stopped', () => {
150
+ const result = machine.transition('disabled')
151
+ expect(result).toBe(true)
152
+ expect(machine.state).toBe('disabled')
153
+ })
154
+
155
+ it('requires going through stopped to leave disabled', () => {
156
+ machine.transition('disabled')
157
+
158
+ expect(machine.transition('starting')).toBe(false)
159
+ expect(machine.transition('stopped')).toBe(true)
160
+ expect(machine.state).toBe('stopped')
161
+ })
162
+
163
+ it('incrementRestartCount manually increments the counter', () => {
164
+ machine.incrementRestartCount()
165
+ machine.incrementRestartCount()
166
+ expect(machine.getStatus().restartCount).toBe(2)
167
+ })
168
+ })
@@ -0,0 +1,3 @@
1
+ // Re-export from @camstack/core — LifecycleStateMachine is already a plain class
2
+ export { LifecycleStateMachine } from '@camstack/core'
3
+ export type { ElementState, ElementStatus } from '@camstack/core'
@@ -0,0 +1,3 @@
1
+ // Re-export from @camstack/core
2
+ export { LogRingBuffer } from '@camstack/core'
3
+ export type { LogEntry, LogFilter, LogLevel } from '@camstack/core'
@@ -0,0 +1,247 @@
1
+
2
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest'
3
+ import * as fs from 'node:fs'
4
+ import * as path from 'node:path'
5
+ import * as os from 'node:os'
6
+ import * as yaml from 'js-yaml'
7
+ import { LogRingBuffer } from './log-ring-buffer'
8
+ import { ScopedLogger } from './scoped-logger'
9
+ import { LoggingService } from './logging.service'
10
+ import { ConfigService } from '../config/config.service'
11
+ import type { ISettingsStore } from '@camstack/kernel'
12
+ import type { LogEntry, ILogDestination } from '@camstack/types'
13
+
14
+ /**
15
+ * In-memory settings store used by the test to drive `ConfigService.get`
16
+ * for the `eventBus.ringBufferSize` key. The bootstrap YAML schema does not
17
+ * declare `eventBus`, so writing it to the YAML file would be discarded by
18
+ * `bootstrapSchema.parse`. Wiring an ISettingsStore is the clean path to
19
+ * feed runtime values into `ConfigService` without casts.
20
+ */
21
+ class InMemorySettingsStore implements ISettingsStore {
22
+ private readonly system: Record<string, unknown>
23
+
24
+ constructor(seed: Record<string, unknown>) {
25
+ this.system = { ...seed }
26
+ }
27
+
28
+ getSystem(key: string): unknown { return this.system[key] }
29
+ setSystem(key: string, value: unknown): void { this.system[key] = value }
30
+ getAllSystem(): Record<string, unknown> { return { ...this.system } }
31
+
32
+ getAllAddon(_addonId: string): Record<string, unknown> { return {} }
33
+ setAllAddon(_addonId: string, _config: Record<string, unknown>): void { /* no-op */ }
34
+ getAllProvider(_providerId: string): Record<string, unknown> { return {} }
35
+ setProvider(_providerId: string, _key: string, _value: unknown): void { /* no-op */ }
36
+ getAllDevice(_deviceId: string): Record<string, unknown> { return {} }
37
+ setDevice(_deviceId: string, _key: string, _value: unknown): void { /* no-op */ }
38
+ getAddonDevice(_addonId: string, _deviceId: string): Record<string, unknown> { return {} }
39
+ setAddonDevice(_addonId: string, _deviceId: string, _values: Record<string, unknown>): void { /* no-op */ }
40
+ clearAddonDevice(_addonId: string, _deviceId: string): void { /* no-op */ }
41
+ }
42
+
43
+ describe('ScopedLogger', () => {
44
+ it('emits entries with a single scope string', () => {
45
+ const entries: LogEntry[] = []
46
+ const writeFn = (entry: LogEntry) => entries.push(entry)
47
+ const logger = new ScopedLogger('config', writeFn)
48
+
49
+ logger.info('loaded config')
50
+
51
+ expect(entries).toHaveLength(1)
52
+ expect(entries[0]!.scope).toBe('config')
53
+ expect(entries[0]!.level).toBe('info')
54
+ expect(entries[0]!.message).toBe('loaded config')
55
+ expect(entries[0]!.timestamp).toBeInstanceOf(Date)
56
+ })
57
+
58
+ it('child() replaces the scope (no hierarchy)', () => {
59
+ const entries: LogEntry[] = []
60
+ const writeFn = (entry: LogEntry) => entries.push(entry)
61
+ const logger = new ScopedLogger('core', writeFn)
62
+ const child = logger.child('http')
63
+
64
+ child.warn('timeout', { meta: { ms: 3000 } })
65
+
66
+ expect(entries).toHaveLength(1)
67
+ expect(entries[0]!.scope).toBe('http')
68
+ expect(entries[0]!.level).toBe('warn')
69
+ expect(entries[0]!.meta).toEqual({ ms: 3000 })
70
+ })
71
+
72
+ it('creates entries for all log levels', () => {
73
+ const entries: LogEntry[] = []
74
+ const writeFn = (entry: LogEntry) => entries.push(entry)
75
+ const logger = new ScopedLogger('test', writeFn)
76
+
77
+ logger.debug('d')
78
+ logger.info('i')
79
+ logger.warn('w')
80
+ logger.error('e')
81
+
82
+ expect(entries.map((e) => e.level)).toEqual(['debug', 'info', 'warn', 'error'])
83
+ })
84
+
85
+ it('omits the scope field when the logger was created without one', () => {
86
+ const entries: LogEntry[] = []
87
+ const writeFn = (entry: LogEntry) => entries.push(entry)
88
+ const logger = new ScopedLogger(undefined, writeFn)
89
+
90
+ logger.info('no scope')
91
+
92
+ expect(entries).toHaveLength(1)
93
+ expect(entries[0]!.scope).toBeUndefined()
94
+ })
95
+ })
96
+
97
+ describe('LogRingBuffer', () => {
98
+ it('stores up to capacity and evicts oldest', () => {
99
+ const buffer = new LogRingBuffer(3)
100
+
101
+ const makeEntry = (msg: string): LogEntry => ({
102
+ timestamp: new Date(),
103
+ level: 'info',
104
+ scope: 'test',
105
+ message: msg,
106
+ })
107
+
108
+ buffer.push(makeEntry('a'))
109
+ buffer.push(makeEntry('b'))
110
+ buffer.push(makeEntry('c'))
111
+ buffer.push(makeEntry('d'))
112
+
113
+ const all = buffer.getAll()
114
+ expect(all).toHaveLength(3)
115
+ // newest first
116
+ expect(all[0]!.message).toBe('d')
117
+ expect(all[1]!.message).toBe('c')
118
+ expect(all[2]!.message).toBe('b')
119
+ })
120
+
121
+ it('filters by level', () => {
122
+ const buffer = new LogRingBuffer(100)
123
+
124
+ buffer.push({ timestamp: new Date(), level: 'debug', scope: 'test', message: 'a' })
125
+ buffer.push({ timestamp: new Date(), level: 'error', scope: 'test', message: 'b' })
126
+ buffer.push({ timestamp: new Date(), level: 'error', scope: 'test', message: 'c' })
127
+
128
+ const result = buffer.query({ level: 'error' })
129
+ expect(result).toHaveLength(2)
130
+ })
131
+
132
+ it('filters by since/until', () => {
133
+ const buffer = new LogRingBuffer(100)
134
+ const t1 = new Date('2025-01-01T00:00:00Z')
135
+ const t2 = new Date('2025-01-02T00:00:00Z')
136
+ const t3 = new Date('2025-01-03T00:00:00Z')
137
+
138
+ buffer.push({ timestamp: t1, level: 'info', scope: 'test', message: 'a' })
139
+ buffer.push({ timestamp: t2, level: 'info', scope: 'test', message: 'b' })
140
+ buffer.push({ timestamp: t3, level: 'info', scope: 'test', message: 'c' })
141
+
142
+ const result = buffer.query({ since: t2 })
143
+ expect(result).toHaveLength(2)
144
+ expect(result.map((e) => e.message)).toEqual(['c', 'b'])
145
+ })
146
+
147
+ it('filters by tags (addonId exact match)', () => {
148
+ const buffer = new LogRingBuffer(100)
149
+
150
+ buffer.push({ timestamp: new Date(), level: 'info', message: 'a', tags: { addonId: 'stream-broker' } })
151
+ buffer.push({ timestamp: new Date(), level: 'info', message: 'b', tags: { addonId: 'provider-rtsp' } })
152
+ buffer.push({ timestamp: new Date(), level: 'info', message: 'c', tags: { addonId: 'stream-broker' } })
153
+
154
+ const result = buffer.query({ tags: { addonId: 'stream-broker' } })
155
+ expect(result).toHaveLength(2)
156
+ expect(result.map((e) => e.message).sort()).toEqual(['a', 'c'])
157
+ })
158
+
159
+ it('respects limit', () => {
160
+ const buffer = new LogRingBuffer(100)
161
+
162
+ for (let i = 0; i < 10; i++) {
163
+ buffer.push({ timestamp: new Date(), level: 'info', scope: 'test', message: `msg-${i}` })
164
+ }
165
+
166
+ const result = buffer.query({ limit: 3 })
167
+ expect(result).toHaveLength(3)
168
+ })
169
+ })
170
+
171
+ describe('LoggingService', () => {
172
+ let tmpDir: string
173
+
174
+ beforeEach(() => {
175
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'camstack-logging-'))
176
+ })
177
+
178
+ afterEach(() => {
179
+ fs.rmSync(tmpDir, { recursive: true, force: true })
180
+ })
181
+
182
+ const createService = (bufferSize = 100): LoggingService => {
183
+ const configPath = path.join(tmpDir, 'config.yaml')
184
+ fs.writeFileSync(
185
+ configPath,
186
+ yaml.dump({
187
+ server: { port: 4443 },
188
+ auth: { adminPassword: 'secret123' },
189
+ }),
190
+ 'utf-8',
191
+ )
192
+ const configService = new ConfigService(configPath)
193
+ configService.setSettingsStore(new InMemorySettingsStore({
194
+ 'eventBus.ringBufferSize': bufferSize,
195
+ }))
196
+ return new LoggingService(configService)
197
+ }
198
+
199
+ it('routes entries to all destinations', () => {
200
+ const service = createService()
201
+ const written: LogEntry[] = []
202
+ const dest: ILogDestination = {
203
+ initialize: async () => {},
204
+ shutdown: async () => {},
205
+ write: (entry) => written.push(entry),
206
+ }
207
+
208
+ service.addDestination(dest)
209
+ const logger = service.createLogger('test')
210
+ logger.info('hello')
211
+
212
+ expect(written).toHaveLength(1)
213
+ expect(written[0]!.message).toBe('hello')
214
+ expect(written[0]!.scope).toBe('test')
215
+ })
216
+
217
+ it('creates scoped loggers that push to ring buffer', () => {
218
+ const service = createService()
219
+ const logger = service.createLogger('myScope')
220
+ logger.error('boom')
221
+
222
+ const entries = service.query({})
223
+ expect(entries).toHaveLength(1)
224
+ expect(entries[0]!.message).toBe('boom')
225
+ expect(entries[0]!.scope).toBe('myScope')
226
+ })
227
+
228
+ it('removes destinations', () => {
229
+ const service = createService()
230
+ const written: LogEntry[] = []
231
+ const dest: ILogDestination = {
232
+ initialize: async () => {},
233
+ shutdown: async () => {},
234
+ write: (entry) => written.push(entry),
235
+ }
236
+
237
+ service.addDestination(dest)
238
+ service.removeDestination(dest)
239
+
240
+ const logger = service.createLogger('test')
241
+ logger.info('should not reach dest')
242
+
243
+ expect(written).toHaveLength(0)
244
+ // but still in ring buffer
245
+ expect(service.query({})).toHaveLength(1)
246
+ })
247
+ })
@@ -0,0 +1,129 @@
1
+ import { LogManager } from '@camstack/core'
2
+ import type { IScopedLogger, LogTags } from '@camstack/types'
3
+ import { EventCategory } from '@camstack/types'
4
+ import { ConfigService } from '../config/config.service'
5
+ import type { EventBusService } from '../events/event-bus.service'
6
+
7
+ /** Baseline tags every hub-local logger gets, so console output never
8
+ * falls back to `?` for the agent slot. Hub-local addons already
9
+ * re-tag with their addonId; this ensures non-addon scopes (core
10
+ * services like `AddonRegistry`, `StreamProbeService`, `moleculer`)
11
+ * render as `hub` in the agent column.
12
+ *
13
+ * `pid` is the hub renderer's own OS pid. Entries forwarded from
14
+ * forked workers via `$hub.log` carry their own `tags.pid` which
15
+ * wins over this baseline (right-biased merge in `writeFromWorker`). */
16
+ const HUB_BASELINE_TAGS: LogTags = { agentId: 'hub', nodeId: 'hub', pid: process.pid }
17
+
18
+ export class LoggingService extends LogManager {
19
+ /**
20
+ * Device-name cache consulted by the formatter when a log line
21
+ * carries `tags.deviceId` but no explicit `deviceName`. Populated
22
+ * by `setDeviceNames` whenever device-manager emits registered /
23
+ * updated / removed events. Missing entries fall back to `#<id>`.
24
+ */
25
+ private readonly deviceNames = new Map<number, string>()
26
+
27
+ constructor(configService: ConfigService) {
28
+ const bufferSize = configService.get<number>('eventBus.ringBufferSize') ?? 10000
29
+ super(bufferSize)
30
+ // Enriches every emitted LogEntry with `tags.deviceName` before
31
+ // destinations / subscribers see it — works across bundled copies
32
+ // of `@camstack/core` (addon packages) because the mutation
33
+ // happens upstream of their formatters.
34
+ this.setDeviceNameLookup((id) => this.deviceNames.get(id) ?? null)
35
+ }
36
+
37
+ /** Bulk-refresh from device-manager snapshot. Replaces every entry. */
38
+ setDeviceNames(entries: ReadonlyArray<{ id: number; name: string }>): void {
39
+ this.deviceNames.clear()
40
+ for (const { id, name } of entries) {
41
+ if (typeof id === 'number' && Number.isFinite(id) && typeof name === 'string' && name.length > 0) {
42
+ this.deviceNames.set(id, name)
43
+ }
44
+ }
45
+ }
46
+
47
+ /** Incremental update — called from DeviceRegistered / DeviceUpdated. */
48
+ upsertDeviceName(id: number, name: string | undefined): void {
49
+ if (!Number.isFinite(id)) return
50
+ if (typeof name === 'string' && name.length > 0) this.deviceNames.set(id, name)
51
+ else this.deviceNames.delete(id)
52
+ }
53
+
54
+ /** Drop on DeviceRemoved. */
55
+ removeDeviceName(id: number): void {
56
+ this.deviceNames.delete(id)
57
+ }
58
+
59
+ /**
60
+ * Subscribe to `device.*` events so the cache stays live: every
61
+ * DeviceRegistered / updated emit carries `{deviceId, name}` — we
62
+ * upsert, and DeviceUnregistered clears. Call once at boot.
63
+ */
64
+ attachDeviceNameStream(eventBus: EventBusService): void {
65
+ const selfLogger = this.createLogger('logging').withTags({ addonId: 'logging' })
66
+ eventBus.subscribe({ category: EventCategory.DeviceRegistered }, (event) => {
67
+ const data = event.data as { deviceId?: number; name?: string } | undefined
68
+ if (data && typeof data.deviceId === 'number') {
69
+ this.upsertDeviceName(data.deviceId, data.name)
70
+ selfLogger.info('device-name cache upserted', {
71
+ meta: { deviceId: data.deviceId, name: data.name ?? null, cacheSize: this.deviceNames.size },
72
+ })
73
+ }
74
+ })
75
+ eventBus.subscribe({ category: EventCategory.DeviceUnregistered }, (event) => {
76
+ const data = event.data as { deviceId?: number } | undefined
77
+ if (data && typeof data.deviceId === 'number') this.removeDeviceName(data.deviceId)
78
+ })
79
+ }
80
+
81
+ /**
82
+ * Override — every logger created hub-side comes pre-tagged with
83
+ * `agentId: 'hub'`. Callers that need per-addon identity chain
84
+ * `.withTags({ addonId })` on top (the merge is right-biased, so
85
+ * the explicit tag wins).
86
+ */
87
+ override createLogger(scope?: string): IScopedLogger {
88
+ return super.createLogger(scope).withTags(HUB_BASELINE_TAGS)
89
+ }
90
+
91
+ /**
92
+ * Write a log entry received from a forked worker / remote agent.
93
+ * Called by the `$hub.log` action and `log-receiver.ingest` action.
94
+ *
95
+ * Tags propagated by the worker (including `deviceId`, `deviceName`,
96
+ * `integrationId`, `streamId`, etc.) are preserved via `withTags`;
97
+ * baseline tags (`addonId`, `nodeId`, `agentId`) are always ensured
98
+ * even if the worker didn't set them explicitly.
99
+ */
100
+ writeFromWorker(entry: {
101
+ addonId: string
102
+ nodeId?: string
103
+ level: string
104
+ message: string
105
+ scope?: string
106
+ tags?: LogTags
107
+ meta?: Record<string, unknown>
108
+ }): void {
109
+ // Scope is a single optional sub-component label; addon/node identity
110
+ // lives in tags, not in scope. Pass through whatever the worker set.
111
+ let logger = this.createLogger(entry.scope)
112
+ const nodeId = entry.nodeId
113
+ const agentId = nodeId?.includes('/') ? nodeId.split('/')[0]! : nodeId
114
+ const mergedTags: LogTags = {
115
+ ...(entry.tags ?? {}),
116
+ addonId: entry.addonId,
117
+ ...(nodeId !== undefined ? { nodeId } : {}),
118
+ ...(agentId !== undefined ? { agentId } : {}),
119
+ }
120
+ logger = logger.withTags(mergedTags)
121
+ const level = entry.level as 'info' | 'warn' | 'error' | 'debug'
122
+ const extras = entry.meta !== undefined ? { meta: entry.meta } : undefined
123
+ if (typeof logger[level] === 'function') {
124
+ logger[level](entry.message, extras)
125
+ } else {
126
+ logger.info(entry.message, extras)
127
+ }
128
+ }
129
+ }
@@ -0,0 +1,3 @@
1
+ // Re-export from @camstack/core
2
+ export { ScopedLogger } from '@camstack/core'
3
+ export type { IScopedLogger } from '@camstack/core'