@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,212 @@
|
|
|
1
|
+
import { execFile } from 'child_process'
|
|
2
|
+
import { promisify } from 'util'
|
|
3
|
+
|
|
4
|
+
import { LoggingService } from '../logging/logging.service'
|
|
5
|
+
import type { StreamMetadata, StreamAudioMetadata } from '@camstack/types'
|
|
6
|
+
import type { IScopedLogger } from '@camstack/types'
|
|
7
|
+
import { errMsg } from '@camstack/types'
|
|
8
|
+
|
|
9
|
+
const execFileAsync = promisify(execFile)
|
|
10
|
+
|
|
11
|
+
const CACHE_TTL_MS = 3_600_000 // 1 hour
|
|
12
|
+
const PROBE_TIMEOUT_MS = 5_000
|
|
13
|
+
|
|
14
|
+
/** Codec aliases normalised to canonical names. */
|
|
15
|
+
const CODEC_ALIASES: Readonly<Record<string, string>> = {
|
|
16
|
+
hevc: 'h265',
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface CacheEntry {
|
|
20
|
+
readonly metadata: StreamMetadata
|
|
21
|
+
readonly timestamp: number
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export class StreamProbeService {
|
|
25
|
+
private readonly logger: IScopedLogger
|
|
26
|
+
private readonly cache = new Map<string, CacheEntry>()
|
|
27
|
+
|
|
28
|
+
constructor(
|
|
29
|
+
loggingService: LoggingService,
|
|
30
|
+
) {
|
|
31
|
+
this.logger = loggingService.createLogger('StreamProbeService')
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
// Public API
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
/** Probe a stream URL and return its metadata (cached for 1 hour). */
|
|
39
|
+
async probe(url: string, options?: { force?: boolean }): Promise<StreamMetadata> {
|
|
40
|
+
const force = options?.force ?? false
|
|
41
|
+
|
|
42
|
+
if (!force) {
|
|
43
|
+
const cached = this.cache.get(url)
|
|
44
|
+
if (cached && Date.now() - cached.timestamp < CACHE_TTL_MS) {
|
|
45
|
+
return cached.metadata
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const metadata = await this.runProbe(url)
|
|
50
|
+
this.cache.set(url, { metadata, timestamp: Date.now() })
|
|
51
|
+
return metadata
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Generic field probe: given a field key and value, decides how to probe.
|
|
56
|
+
* Stream fields (stream_*) → ffprobe, other URLs → HTTP HEAD check.
|
|
57
|
+
*/
|
|
58
|
+
async probeField(key: string, value: unknown): Promise<{ status: 'ok' | 'error'; labels?: string[]; error?: string }> {
|
|
59
|
+
const url = String(value ?? '').trim()
|
|
60
|
+
if (!url) {
|
|
61
|
+
return { status: 'error', error: 'No URL provided' }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Stream fields: use ffprobe
|
|
65
|
+
if (key.startsWith('stream')) {
|
|
66
|
+
const meta = await this.probe(url, { force: true })
|
|
67
|
+
const labels: string[] = []
|
|
68
|
+
if (meta.width && meta.height) labels.push(`${meta.width}\u00d7${meta.height}`)
|
|
69
|
+
if (meta.codec) {
|
|
70
|
+
const codecDisplay: Record<string, string> = { h265: 'H.265', h264: 'H.264', hevc: 'H.265' }
|
|
71
|
+
labels.push(codecDisplay[meta.codec] ?? meta.codec.toUpperCase())
|
|
72
|
+
}
|
|
73
|
+
if (meta.fps) labels.push(`${Math.round(meta.fps)}fps`)
|
|
74
|
+
if (meta.audio?.codec) {
|
|
75
|
+
const audioCodec = meta.audio.codec.toUpperCase()
|
|
76
|
+
const audioBits: string[] = [audioCodec]
|
|
77
|
+
if (meta.audio.sampleRate) audioBits.push(`${Math.round(meta.audio.sampleRate / 1000)}kHz`)
|
|
78
|
+
if (meta.audio.channels === 1) audioBits.push('mono')
|
|
79
|
+
else if (meta.audio.channels === 2) audioBits.push('stereo')
|
|
80
|
+
else if (meta.audio.channels) audioBits.push(`${meta.audio.channels}ch`)
|
|
81
|
+
labels.push(`audio: ${audioBits.join(' ')}`)
|
|
82
|
+
}
|
|
83
|
+
if (labels.length === 0) {
|
|
84
|
+
return { status: 'error', error: 'No video streams found at URL' }
|
|
85
|
+
}
|
|
86
|
+
return { status: 'ok', labels }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Other URL fields (snapshot, etc.): GET with early abort to check reachability + content-type
|
|
90
|
+
try {
|
|
91
|
+
const controller = new AbortController()
|
|
92
|
+
const timeout = AbortSignal.timeout(5000)
|
|
93
|
+
timeout.addEventListener('abort', () => controller.abort(timeout.reason))
|
|
94
|
+
const response = await fetch(url, { method: 'GET', signal: controller.signal })
|
|
95
|
+
if (!response.ok) {
|
|
96
|
+
controller.abort()
|
|
97
|
+
return { status: 'error', error: `HTTP ${response.status} ${response.statusText}` }
|
|
98
|
+
}
|
|
99
|
+
const contentType = response.headers.get('content-type') ?? ''
|
|
100
|
+
controller.abort() // Don't download the full body
|
|
101
|
+
const labels: string[] = ['Reachable']
|
|
102
|
+
if (contentType.startsWith('image/')) {
|
|
103
|
+
labels.push(contentType.replace('image/', '').toUpperCase())
|
|
104
|
+
}
|
|
105
|
+
return { status: 'ok', labels }
|
|
106
|
+
} catch (err) {
|
|
107
|
+
if (err instanceof Error && err.name === 'AbortError') {
|
|
108
|
+
// We aborted intentionally after reading headers — that's success
|
|
109
|
+
return { status: 'ok', labels: ['Reachable'] }
|
|
110
|
+
}
|
|
111
|
+
const msg = errMsg(err)
|
|
112
|
+
return { status: 'error', error: `Unreachable: ${msg}` }
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Clear cached metadata for a single URL or all URLs. */
|
|
117
|
+
clearCache(url?: string): void {
|
|
118
|
+
if (url) {
|
|
119
|
+
this.cache.delete(url)
|
|
120
|
+
} else {
|
|
121
|
+
this.cache.clear()
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ---------------------------------------------------------------------------
|
|
126
|
+
// Internal
|
|
127
|
+
// ---------------------------------------------------------------------------
|
|
128
|
+
|
|
129
|
+
private async runProbe(url: string): Promise<StreamMetadata> {
|
|
130
|
+
try {
|
|
131
|
+
// Query first video + first audio stream in one ffprobe pass.
|
|
132
|
+
// `-show_streams` returns every stream in the container; we filter
|
|
133
|
+
// by codec_type when parsing. Fields cover both kinds.
|
|
134
|
+
const { stdout } = await execFileAsync('ffprobe', [
|
|
135
|
+
'-v', 'error',
|
|
136
|
+
'-rtsp_transport', 'tcp',
|
|
137
|
+
'-timeout', '5000000',
|
|
138
|
+
'-show_entries',
|
|
139
|
+
'stream=codec_type,codec_name,profile,width,height,r_frame_rate,bit_rate,sample_rate,channels',
|
|
140
|
+
'-of', 'json',
|
|
141
|
+
url,
|
|
142
|
+
], { timeout: PROBE_TIMEOUT_MS })
|
|
143
|
+
|
|
144
|
+
return this.parseOutput(stdout)
|
|
145
|
+
} catch (err) {
|
|
146
|
+
this.logger.error('ffprobe failed', { meta: { url, error: String(err) } })
|
|
147
|
+
return {}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
private parseOutput(stdout: string): StreamMetadata {
|
|
152
|
+
const parsed: unknown = JSON.parse(stdout)
|
|
153
|
+
const streams = (parsed as { streams?: unknown[] }).streams
|
|
154
|
+
if (!Array.isArray(streams) || streams.length === 0) {
|
|
155
|
+
return {}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const meta: StreamMetadata = {}
|
|
159
|
+
|
|
160
|
+
for (const raw of streams) {
|
|
161
|
+
const stream = raw as Record<string, unknown>
|
|
162
|
+
const codecType = typeof stream.codec_type === 'string' ? stream.codec_type : undefined
|
|
163
|
+
|
|
164
|
+
if (codecType === 'video' && meta.codec === undefined) {
|
|
165
|
+
const rawCodec = typeof stream.codec_name === 'string' ? stream.codec_name : undefined
|
|
166
|
+
meta.codec = rawCodec ? (CODEC_ALIASES[rawCodec] ?? rawCodec) : undefined
|
|
167
|
+
if (typeof stream.width === 'number') meta.width = stream.width
|
|
168
|
+
if (typeof stream.height === 'number') meta.height = stream.height
|
|
169
|
+
const fps = this.parseFps(stream.r_frame_rate)
|
|
170
|
+
if (fps !== undefined) meta.fps = fps
|
|
171
|
+
const bitrateKbps = this.parseBitrateKbps(stream.bit_rate)
|
|
172
|
+
if (bitrateKbps !== undefined) meta.bitrateKbps = bitrateKbps
|
|
173
|
+
} else if (codecType === 'audio' && meta.audio === undefined) {
|
|
174
|
+
const audio: StreamAudioMetadata = {}
|
|
175
|
+
if (typeof stream.codec_name === 'string') audio.codec = stream.codec_name
|
|
176
|
+
if (typeof stream.profile === 'string') audio.profile = stream.profile
|
|
177
|
+
const sampleRate = this.parseIntField(stream.sample_rate)
|
|
178
|
+
if (sampleRate !== undefined) audio.sampleRate = sampleRate
|
|
179
|
+
if (typeof stream.channels === 'number') audio.channels = stream.channels
|
|
180
|
+
const audioBitrateKbps = this.parseBitrateKbps(stream.bit_rate)
|
|
181
|
+
if (audioBitrateKbps !== undefined) audio.bitrateKbps = audioBitrateKbps
|
|
182
|
+
meta.audio = audio
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return meta
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
private parseIntField(value: unknown): number | undefined {
|
|
190
|
+
const n = typeof value === 'string' ? Number(value) : typeof value === 'number' ? value : NaN
|
|
191
|
+
if (!Number.isFinite(n) || n <= 0) return undefined
|
|
192
|
+
return Math.round(n)
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Parse fractional frame rate string like "25/1" → 25. */
|
|
196
|
+
private parseFps(value: unknown): number | undefined {
|
|
197
|
+
if (typeof value !== 'string') return undefined
|
|
198
|
+
const parts = value.split('/')
|
|
199
|
+
if (parts.length !== 2) return undefined
|
|
200
|
+
const num = Number(parts[0])
|
|
201
|
+
const den = Number(parts[1])
|
|
202
|
+
if (!Number.isFinite(num) || !Number.isFinite(den) || den === 0) return undefined
|
|
203
|
+
return Math.round((num / den) * 100) / 100
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Parse bit_rate string (bps) → kbps. */
|
|
207
|
+
private parseBitrateKbps(value: unknown): number | undefined {
|
|
208
|
+
const n = typeof value === 'string' ? Number(value) : typeof value === 'number' ? value : NaN
|
|
209
|
+
if (!Number.isFinite(n) || n <= 0) return undefined
|
|
210
|
+
return Math.round(n / 1000)
|
|
211
|
+
}
|
|
212
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TopologyEmitterService — push the cluster topology over the event bus.
|
|
3
|
+
*
|
|
4
|
+
* Subscribes to agent connect/disconnect + addon lifecycle events,
|
|
5
|
+
* recomputes the topology snapshot on each change (debounced), and
|
|
6
|
+
* emits `cluster.topology-snapshot`. Also fires a periodic safety-net
|
|
7
|
+
* snapshot every TOPOLOGY_HEARTBEAT_MS so consumers that boot mid-
|
|
8
|
+
* stream get a fresh payload without waiting for the next lifecycle
|
|
9
|
+
* change.
|
|
10
|
+
*
|
|
11
|
+
* Replaces UI polling on `nodes.topology`. Admin-ui dashboards
|
|
12
|
+
* subscribe to the snapshot category and read state directly from the
|
|
13
|
+
* payload — zero round-trip.
|
|
14
|
+
*/
|
|
15
|
+
import { randomUUID } from 'node:crypto'
|
|
16
|
+
import { EventCategory } from '@camstack/types'
|
|
17
|
+
|
|
18
|
+
type Unsubscribe = () => void
|
|
19
|
+
import type { EventBusService } from '../events/event-bus.service'
|
|
20
|
+
import type { AgentRegistryService } from '../agent/agent-registry.service'
|
|
21
|
+
import type { AddonRegistryService } from '../addon/addon-registry.service'
|
|
22
|
+
import { computeTopology } from '../../api/core/cap-providers'
|
|
23
|
+
|
|
24
|
+
const DEBOUNCE_MS = 200
|
|
25
|
+
const HEARTBEAT_MS = 30_000
|
|
26
|
+
|
|
27
|
+
const LIFECYCLE_CATEGORIES: readonly EventCategory[] = [
|
|
28
|
+
EventCategory.AgentOnline,
|
|
29
|
+
EventCategory.AgentOffline,
|
|
30
|
+
EventCategory.WorkerOnline,
|
|
31
|
+
EventCategory.WorkerOffline,
|
|
32
|
+
EventCategory.AgentUnregistered,
|
|
33
|
+
EventCategory.AddonStarted,
|
|
34
|
+
EventCategory.AddonStopped,
|
|
35
|
+
EventCategory.AddonRestarted,
|
|
36
|
+
EventCategory.AddonInstalled,
|
|
37
|
+
EventCategory.AddonUninstalled,
|
|
38
|
+
EventCategory.AddonUpdated,
|
|
39
|
+
EventCategory.AddonCrashed,
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
export class TopologyEmitterService {
|
|
43
|
+
private readonly unsubscribers: Unsubscribe[] = []
|
|
44
|
+
private debounceTimer: ReturnType<typeof setTimeout> | null = null
|
|
45
|
+
private heartbeatTimer: ReturnType<typeof setInterval> | null = null
|
|
46
|
+
private emitting = false
|
|
47
|
+
|
|
48
|
+
constructor(
|
|
49
|
+
private readonly eventBus: EventBusService,
|
|
50
|
+
private readonly agentRegistry: AgentRegistryService,
|
|
51
|
+
private readonly addonRegistry: AddonRegistryService,
|
|
52
|
+
) {}
|
|
53
|
+
|
|
54
|
+
onModuleInit(): void {
|
|
55
|
+
for (const category of LIFECYCLE_CATEGORIES) {
|
|
56
|
+
const unsub = this.eventBus.subscribe({ category }, () => this.scheduleEmit())
|
|
57
|
+
this.unsubscribers.push(unsub)
|
|
58
|
+
}
|
|
59
|
+
// Heartbeat fires the first emit ~immediately so consumers that
|
|
60
|
+
// mount before any lifecycle change still see a snapshot.
|
|
61
|
+
void this.emitNow()
|
|
62
|
+
this.heartbeatTimer = setInterval(() => this.scheduleEmit(), HEARTBEAT_MS)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
onModuleDestroy(): void {
|
|
66
|
+
if (this.debounceTimer) clearTimeout(this.debounceTimer)
|
|
67
|
+
if (this.heartbeatTimer) clearInterval(this.heartbeatTimer)
|
|
68
|
+
for (const unsub of this.unsubscribers) {
|
|
69
|
+
try { unsub() } catch { /* idempotent */ }
|
|
70
|
+
}
|
|
71
|
+
this.unsubscribers.length = 0
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
private scheduleEmit(): void {
|
|
75
|
+
if (this.debounceTimer) clearTimeout(this.debounceTimer)
|
|
76
|
+
this.debounceTimer = setTimeout(() => {
|
|
77
|
+
this.debounceTimer = null
|
|
78
|
+
void this.emitNow()
|
|
79
|
+
}, DEBOUNCE_MS)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
private async emitNow(): Promise<void> {
|
|
83
|
+
if (this.emitting) return
|
|
84
|
+
this.emitting = true
|
|
85
|
+
try {
|
|
86
|
+
const nodes = await computeTopology(this.agentRegistry, this.addonRegistry)
|
|
87
|
+
this.eventBus.emit({
|
|
88
|
+
id: randomUUID(),
|
|
89
|
+
timestamp: new Date(),
|
|
90
|
+
source: { type: 'core', id: 'topology-emitter' },
|
|
91
|
+
category: EventCategory.ClusterTopologySnapshot,
|
|
92
|
+
data: { nodes, timestamp: Date.now() },
|
|
93
|
+
})
|
|
94
|
+
} catch {
|
|
95
|
+
// Best-effort observability path. Next lifecycle event or the
|
|
96
|
+
// heartbeat will retry — no need to log here.
|
|
97
|
+
} finally {
|
|
98
|
+
this.emitting = false
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
package/src/launcher.ts
ADDED
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Launcher -- entry point that ensures required addons are installed
|
|
3
|
+
* before main.ts loads. This solves the chicken-and-egg problem:
|
|
4
|
+
* main.ts has static imports from @camstack/core, but core lives in
|
|
5
|
+
* data/addons/ and may not exist on first boot.
|
|
6
|
+
*
|
|
7
|
+
* 1. Run AddonInstaller.ensureRequiredPackages (zero core dependencies)
|
|
8
|
+
* 2. Create symlinks so @camstack/core resolves from data/addons/
|
|
9
|
+
* 3. Dynamically import main.ts (which has static @camstack/core imports)
|
|
10
|
+
*/
|
|
11
|
+
import * as fs from 'node:fs'
|
|
12
|
+
import * as path from 'node:path'
|
|
13
|
+
import * as tar from 'tar'
|
|
14
|
+
import * as yaml from 'js-yaml'
|
|
15
|
+
import { AddonInstaller, bootstrapSchema, detectWorkspacePackagesDir } from '@camstack/kernel'
|
|
16
|
+
|
|
17
|
+
/** Path of the manifest file embedded inside every archive. */
|
|
18
|
+
const ARCHIVE_MANIFEST_NAME = '.camstack-backup-manifest.json'
|
|
19
|
+
|
|
20
|
+
/** Resolve the data directory from env or default */
|
|
21
|
+
const DATA_DIR = process.env['CAMSTACK_DATA'] ?? 'camstack-data'
|
|
22
|
+
|
|
23
|
+
const RESTORE_MARKER_FILE = '.pending-restore.json'
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Apply a pending system-restore archive over `dataDir` if a marker is
|
|
27
|
+
* present. Runs BEFORE the addon installer so the restored snapshot's
|
|
28
|
+
* `addons/` folder is what `ensureRequiredPackages` picks up.
|
|
29
|
+
*
|
|
30
|
+
* Marker contract is owned by `SystemBackupService.scheduleRestoreMarker`
|
|
31
|
+
* (packages/core/src/builtins/system-backup) — kept inline here so the
|
|
32
|
+
* launcher remains zero-core-deps. Schema:
|
|
33
|
+
* { archivePath: string, requestedAt: number, source: string }
|
|
34
|
+
*
|
|
35
|
+
* Failure modes:
|
|
36
|
+
* - marker malformed → log + leave it in place (operator visibility)
|
|
37
|
+
* - archive missing → log + clear marker (auto-recover from stale)
|
|
38
|
+
* - tar fails → throw → launcher exits non-zero, marker preserved
|
|
39
|
+
*/
|
|
40
|
+
async function applyPendingRestore(dataDir: string): Promise<void> {
|
|
41
|
+
const markerPath = path.join(dataDir, RESTORE_MARKER_FILE)
|
|
42
|
+
if (!fs.existsSync(markerPath)) return
|
|
43
|
+
|
|
44
|
+
console.log(`[launcher] Pending restore marker found at ${markerPath}`)
|
|
45
|
+
let archivePath: string | null = null
|
|
46
|
+
let locations: string[] | null = null
|
|
47
|
+
try {
|
|
48
|
+
const raw = fs.readFileSync(markerPath, 'utf-8')
|
|
49
|
+
const parsed: unknown = JSON.parse(raw)
|
|
50
|
+
if (
|
|
51
|
+
typeof parsed === 'object' &&
|
|
52
|
+
parsed != null &&
|
|
53
|
+
typeof (parsed as { archivePath?: unknown }).archivePath === 'string'
|
|
54
|
+
) {
|
|
55
|
+
archivePath = (parsed as { archivePath: string }).archivePath
|
|
56
|
+
}
|
|
57
|
+
const locField = (parsed as { locations?: unknown }).locations
|
|
58
|
+
if (Array.isArray(locField) && locField.every((l) => typeof l === 'string')) {
|
|
59
|
+
locations = locField as string[]
|
|
60
|
+
}
|
|
61
|
+
} catch (err) {
|
|
62
|
+
console.error('[launcher] Restore marker malformed — leaving in place:', err)
|
|
63
|
+
return
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (archivePath == null) {
|
|
67
|
+
console.error('[launcher] Restore marker missing archivePath — clearing')
|
|
68
|
+
fs.rmSync(markerPath, { force: true })
|
|
69
|
+
return
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (!fs.existsSync(archivePath)) {
|
|
73
|
+
console.error(`[launcher] Restore archive ${archivePath} missing — clearing marker`)
|
|
74
|
+
fs.rmSync(markerPath, { force: true })
|
|
75
|
+
return
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const scope = locations ? ` (only: ${locations.join(', ')})` : ''
|
|
79
|
+
console.log(`[launcher] Restoring snapshot from ${archivePath} into ${dataDir}${scope}`)
|
|
80
|
+
fs.mkdirSync(dataDir, { recursive: true })
|
|
81
|
+
|
|
82
|
+
// Same filter logic SystemBackupService.extractArchive uses — kept
|
|
83
|
+
// inline here so the launcher stays zero-core-deps. The shape is
|
|
84
|
+
// straightforward enough to duplicate.
|
|
85
|
+
const allowedPrefixes = locations
|
|
86
|
+
? locations.map((loc) => loc.replace(/^\.\//, '').replace(/\/+$/, ''))
|
|
87
|
+
: null
|
|
88
|
+
await tar.extract({
|
|
89
|
+
file: archivePath,
|
|
90
|
+
cwd: dataDir,
|
|
91
|
+
filter: (p) => {
|
|
92
|
+
if (p === ARCHIVE_MANIFEST_NAME || p === `./${ARCHIVE_MANIFEST_NAME}`) return false
|
|
93
|
+
if (!allowedPrefixes) return true
|
|
94
|
+
const normalized = p.replace(/^\.\//, '')
|
|
95
|
+
return allowedPrefixes.some((prefix) =>
|
|
96
|
+
normalized === prefix || normalized.startsWith(`${prefix}/`),
|
|
97
|
+
)
|
|
98
|
+
},
|
|
99
|
+
})
|
|
100
|
+
fs.rmSync(markerPath, { force: true })
|
|
101
|
+
console.log('[launcher] Restore complete; marker cleared')
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Narrow `CAMSTACK_INSTALL_SOURCE` env into the typed union. */
|
|
105
|
+
function parseInstallSource(value: string | undefined): 'npm' | 'local' | 'symlink' | undefined {
|
|
106
|
+
if (value === 'npm' || value === 'local' || value === 'symlink') return value
|
|
107
|
+
return undefined
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Locate + parse `config.yaml` relative to the data dir. Returns the
|
|
112
|
+
* raw object on success, `null` when missing or unparseable. The
|
|
113
|
+
* launcher uses this BEFORE @camstack/core is imported so it must stay
|
|
114
|
+
* free of core deps — just js-yaml + the kernel's bootstrapSchema for
|
|
115
|
+
* shape validation by the call site.
|
|
116
|
+
*/
|
|
117
|
+
function readConfigYaml(dataDir: string): unknown {
|
|
118
|
+
const candidates = [
|
|
119
|
+
path.resolve(dataDir, '..', 'config.yaml'),
|
|
120
|
+
path.resolve(dataDir, 'config.yaml'),
|
|
121
|
+
path.resolve(process.cwd(), 'config.yaml'),
|
|
122
|
+
]
|
|
123
|
+
const found = candidates.find((p) => fs.existsSync(p))
|
|
124
|
+
if (!found) return null
|
|
125
|
+
try {
|
|
126
|
+
return yaml.load(fs.readFileSync(found, 'utf-8')) ?? {}
|
|
127
|
+
} catch (err) {
|
|
128
|
+
console.warn(`[launcher] Could not parse ${found}: ${err instanceof Error ? err.message : String(err)}`)
|
|
129
|
+
return null
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Read `bootstrap.installSource` from `<dataDir>/config.yaml` if present.
|
|
135
|
+
* Returns the configured value or `null` when absent.
|
|
136
|
+
*
|
|
137
|
+
* Production deployments should leave this unset (defaults to 'npm');
|
|
138
|
+
* `symlink` is a dev-time convenience that points data/addons/<pkg>
|
|
139
|
+
* directly at the workspace source dir — never set in shipped
|
|
140
|
+
* containers or Electron bundles.
|
|
141
|
+
*/
|
|
142
|
+
function readBootstrapInstallSource(dataDir: string): 'npm' | 'local' | 'symlink' | undefined {
|
|
143
|
+
const raw = readConfigYaml(dataDir)
|
|
144
|
+
if (raw === null) return undefined
|
|
145
|
+
const validation = bootstrapSchema.safeParse(raw)
|
|
146
|
+
if (!validation.success) return undefined
|
|
147
|
+
return parseInstallSource(validation.data.bootstrap.installSource)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Read `bootstrap.requiredAddons` from `<dataDir>/config.yaml` if present.
|
|
152
|
+
* Returns the parsed array, an empty array when explicitly set to `[]`,
|
|
153
|
+
* or `null` when the field is absent (caller should fall back to the
|
|
154
|
+
* kernel's REQUIRED_PACKAGES constant).
|
|
155
|
+
*
|
|
156
|
+
* Inline YAML parse rather than going through ConfigManager: the
|
|
157
|
+
* launcher runs before @camstack/core is imported and must stay free of
|
|
158
|
+
* core dependencies. Just yaml.load + bootstrapSchema (kernel) for
|
|
159
|
+
* shape validation.
|
|
160
|
+
*/
|
|
161
|
+
function readBootstrapRequiredAddons(dataDir: string): readonly string[] | null {
|
|
162
|
+
const raw = readConfigYaml(dataDir)
|
|
163
|
+
if (raw === null) return null
|
|
164
|
+
|
|
165
|
+
const validation = bootstrapSchema.safeParse(raw)
|
|
166
|
+
if (!validation.success) {
|
|
167
|
+
console.warn(`[launcher] config.yaml failed bootstrapSchema validation: ${validation.error.message}`)
|
|
168
|
+
return null
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return validation.data.bootstrap.requiredAddons ?? null
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async function launch(): Promise<void> {
|
|
175
|
+
const dataDir = DATA_DIR
|
|
176
|
+
const addonsDir = path.resolve(dataDir, 'addons')
|
|
177
|
+
|
|
178
|
+
// Restore must happen first so the snapshot's `addons/` is what the
|
|
179
|
+
// installer + symlink step pick up — otherwise we'd install a stale
|
|
180
|
+
// set and overwrite it a step later.
|
|
181
|
+
await applyPendingRestore(dataDir)
|
|
182
|
+
|
|
183
|
+
// Install source resolution:
|
|
184
|
+
// 1. CAMSTACK_BUNDLED_ADDONS_DIR — set by Electron-packaged builds
|
|
185
|
+
// to <resourcesPath>/addons. Pre-built addons ship with the
|
|
186
|
+
// bundle, copied at first launch (`'local'` mode).
|
|
187
|
+
// 2. CAMSTACK_INSTALL_SOURCE=local — explicit opt-in to copy from
|
|
188
|
+
// the monorepo workspace (used by e2e + dev bootstrap). Hub
|
|
189
|
+
// auto-detects `packages/` via detectWorkspacePackagesDir.
|
|
190
|
+
// 3. Otherwise: 'npm' from the registry. Local development pushes
|
|
191
|
+
// via `camstack deploy` (CLI tarball upload), bypassing this
|
|
192
|
+
// bootstrap entirely.
|
|
193
|
+
console.log('[launcher] Checking required packages...')
|
|
194
|
+
// Install source resolution:
|
|
195
|
+
// CAMSTACK_INSTALL_SOURCE env: 'npm' | 'local' | 'symlink' | undefined.
|
|
196
|
+
// CAMSTACK_BUNDLED_ADDONS_DIR: Electron path, forces 'local'.
|
|
197
|
+
// bootstrap.installSource (config.yaml): same set as env. Env wins.
|
|
198
|
+
const rawEnvSource = process.env['CAMSTACK_INSTALL_SOURCE']
|
|
199
|
+
const yamlSource = readBootstrapInstallSource(dataDir)
|
|
200
|
+
const explicitSource = parseInstallSource(rawEnvSource) ?? yamlSource
|
|
201
|
+
const bundledDir = process.env['CAMSTACK_BUNDLED_ADDONS_DIR']
|
|
202
|
+
let workspaceDir: string | null = null
|
|
203
|
+
let resolvedSource: 'npm' | 'local' | 'symlink' | undefined = explicitSource
|
|
204
|
+
if (bundledDir && fs.existsSync(bundledDir)) {
|
|
205
|
+
workspaceDir = bundledDir
|
|
206
|
+
resolvedSource = 'local'
|
|
207
|
+
console.log(`[launcher] Using bundled addons from ${bundledDir}`)
|
|
208
|
+
} else if (explicitSource === 'local' || explicitSource === 'symlink') {
|
|
209
|
+
workspaceDir = detectWorkspacePackagesDir(__dirname)
|
|
210
|
+
if (workspaceDir === null) {
|
|
211
|
+
console.warn(
|
|
212
|
+
`[launcher] installSource=${explicitSource} requested but no workspace `
|
|
213
|
+
+ `packages/ dir found from ${__dirname} — falling back to 'npm'`,
|
|
214
|
+
)
|
|
215
|
+
resolvedSource = 'npm'
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
const installer = new AddonInstaller({
|
|
219
|
+
addonsDir,
|
|
220
|
+
workspacePackagesDir: workspaceDir ?? undefined,
|
|
221
|
+
installSource: resolvedSource,
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
// bootstrap.requiredAddons from config.yaml — if set, REPLACES the
|
|
225
|
+
// kernel's hard-coded REQUIRED_PACKAGES. The kernel never reaches into
|
|
226
|
+
// SQL before this list is installed; everything here must be a known
|
|
227
|
+
// package name that can be resolved from the workspace, the bundle, or
|
|
228
|
+
// npm. Default (undefined) → fall back to the in-kernel constant.
|
|
229
|
+
const bootstrapRequired = readBootstrapRequiredAddons(dataDir)
|
|
230
|
+
if (bootstrapRequired !== null) {
|
|
231
|
+
console.log(`[launcher] bootstrap.requiredAddons from config.yaml: ${bootstrapRequired.length} packages`)
|
|
232
|
+
await installer.ensureRequiredPackages(bootstrapRequired)
|
|
233
|
+
} else {
|
|
234
|
+
await installer.ensureRequiredPackages()
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Self-contained addon bundles (build preset `self-contained`) inline
|
|
238
|
+
// @camstack/types + zod + @camstack/sdk into each addon's dist. The
|
|
239
|
+
// hub no longer plants peer-dep symlinks under
|
|
240
|
+
// addonsDir/node_modules/@camstack/ — addons resolve everything from
|
|
241
|
+
// their own bundle. Kept the serverDir derivation in case future
|
|
242
|
+
// tooling needs it (NODE_PATH extension below still uses it).
|
|
243
|
+
const serverDir = path.resolve(__dirname, '..')
|
|
244
|
+
const nodeModulesDir = path.join(serverDir, 'node_modules')
|
|
245
|
+
|
|
246
|
+
// Extend Node's module resolution so addons in `data/addons/`
|
|
247
|
+
// (which sit outside the server tree on packaged builds — e.g.
|
|
248
|
+
// `~/Library/Application Support/.../data/addons/`) can resolve
|
|
249
|
+
// third-party peer deps from the hub's own `node_modules`.
|
|
250
|
+
//
|
|
251
|
+
// Why: an addon's runtime file `data/addons/@scope/<x>/dist/x.js`
|
|
252
|
+
// walks UP looking for `node_modules/<pkg>`. With user-data
|
|
253
|
+
// outside the workspace, the walk dead-ends well before hitting
|
|
254
|
+
// the hub's tree. NODE_PATH instructs Node to ALSO check the
|
|
255
|
+
// listed dirs after the regular walk fails.
|
|
256
|
+
//
|
|
257
|
+
// The workspace-root node_modules holds hoisted deps (zod, werift,
|
|
258
|
+
// etc); the server-local node_modules has direct deps. Listing
|
|
259
|
+
// both covers every realistic resolution.
|
|
260
|
+
const workspaceRootNodeModules = path.resolve(serverDir, '..', '..', 'node_modules')
|
|
261
|
+
const extraNodePaths = [nodeModulesDir, workspaceRootNodeModules]
|
|
262
|
+
.filter((p) => fs.existsSync(p))
|
|
263
|
+
if (extraNodePaths.length > 0) {
|
|
264
|
+
const sep = process.platform === 'win32' ? ';' : ':'
|
|
265
|
+
process.env['NODE_PATH'] = process.env['NODE_PATH']
|
|
266
|
+
? `${process.env['NODE_PATH']}${sep}${extraNodePaths.join(sep)}`
|
|
267
|
+
: extraNodePaths.join(sep)
|
|
268
|
+
// Re-init Node's module path cache so the change takes effect for
|
|
269
|
+
// subsequent require()/import() calls. Without this the resolver
|
|
270
|
+
// already cached the empty list at startup.
|
|
271
|
+
const Module = require('node:module') as { _initPaths: () => void }
|
|
272
|
+
Module._initPaths()
|
|
273
|
+
console.log(`[launcher] NODE_PATH extended with: ${extraNodePaths.join(sep)}`)
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Now safe to load main.ts (which has static imports from @camstack/core)
|
|
277
|
+
console.log('[launcher] Starting server...')
|
|
278
|
+
await import('./main')
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Signal handlers — forward to default exit so NestJS shutdown hooks
|
|
282
|
+
// (Moleculer broker stopped() → kills spawned workers) run cleanly.
|
|
283
|
+
// Without these, TTY close / `npm run serve` kill leaves workers orphaned.
|
|
284
|
+
let shuttingDown = false
|
|
285
|
+
const handleSignal = (signal: NodeJS.Signals) => {
|
|
286
|
+
if (shuttingDown) return
|
|
287
|
+
shuttingDown = true
|
|
288
|
+
console.log(`[launcher] Received ${signal} — shutting down`)
|
|
289
|
+
process.exit(0)
|
|
290
|
+
}
|
|
291
|
+
process.on('SIGHUP', handleSignal)
|
|
292
|
+
process.on('SIGTERM', handleSignal)
|
|
293
|
+
process.on('SIGINT', handleSignal)
|
|
294
|
+
|
|
295
|
+
// Parent-death watchdog (macOS: no PR_SET_PDEATHSIG). If tsx watch / npm
|
|
296
|
+
// get orphaned to init, self-destruct so NestJS shutdown hooks fire before
|
|
297
|
+
// the process tree turns into a zombie.
|
|
298
|
+
const initialPpid = process.ppid
|
|
299
|
+
setInterval(() => {
|
|
300
|
+
if (process.ppid === 1 && initialPpid !== 1) {
|
|
301
|
+
console.error('[launcher] Parent died (reparented to init) — exiting')
|
|
302
|
+
process.exit(0)
|
|
303
|
+
}
|
|
304
|
+
}, 2000).unref()
|
|
305
|
+
|
|
306
|
+
launch().catch((err) => {
|
|
307
|
+
console.error('[launcher] FATAL:', err)
|
|
308
|
+
process.exit(1)
|
|
309
|
+
})
|