@pagermon/ingest-core 1.0.0

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 ADDED
@@ -0,0 +1,20 @@
1
+ # PagerMon Ingest Core configuration
2
+ #
3
+ # Prefixes:
4
+ # - Core: INGEST_CORE__*
5
+ # - Adapter: INGEST_ADAPTER__*
6
+
7
+ # Core runtime settings
8
+ INGEST_CORE__LABEL=pagermon-ingest
9
+ INGEST_CORE__API_URL=http://pagermon:3000
10
+ INGEST_CORE__API_KEY=your_api_key_here
11
+ INGEST_CORE__REDIS_URL=redis://redis:6379
12
+ INGEST_CORE__ENABLE_DLQ=true
13
+ INGEST_CORE__HEALTH_CHECK_INTERVAL=10000
14
+ INGEST_CORE__HEALTH_UNHEALTHY_THRESHOLD=3
15
+
16
+ # Adapter settings are forwarded to the selected adapter entry.
17
+ # Example keys (actual keys depend on the adapter implementation):
18
+ # INGEST_ADAPTER__FREQUENCIES=163000000
19
+ # INGEST_ADAPTER__PROTOCOLS=POCSAG512
20
+ # INGEST_ADAPTER__SMTP__HOST=smtp.example.org
@@ -0,0 +1,3 @@
1
+ {
2
+ ".": "1.0.0"
3
+ }
package/README.md ADDED
@@ -0,0 +1,165 @@
1
+ # @pagermon/ingest-core
2
+
3
+ Shared ingest core runtime for PagerMon.
4
+
5
+ ---
6
+
7
+ > **Looking to run PagerMon Ingest with RTL-SDR?**
8
+ > You probably want the [multimon adapter repository](https://github.com/eopo/ingest-adapter-multimon) instead.
9
+ > This repository is the shared core runtime library and is only relevant if you're developing custom adapters or contributing to the core.
10
+
11
+ ---
12
+
13
+ ## What This Is
14
+
15
+ This repository contains the stable core pipeline shared by all PagerMon ingest adapters:
16
+
17
+ - config parsing and validation
18
+ - queue and worker processing
19
+ - API client and health monitor
20
+ - adapter orchestration and lifecycle management
21
+
22
+ It does **not** contain a concrete source adapter implementation (RTL-SDR, SMTP, etc.).
23
+
24
+ ## Who This Is For
25
+
26
+ - **Adapter developers**: building custom ingest sources
27
+ - **Core contributors**: improving shared runtime behavior
28
+ - **Not for**: end users who just want to run a ready-made adapter
29
+
30
+ If you just want to run PagerMon Ingest with RTL-SDR hardware, use a concrete adapter repository instead of this one.
31
+
32
+ ## Architecture At A Glance
33
+
34
+ `@pagermon/ingest-core` separates reusable runtime concerns from source-specific logic.
35
+
36
+ - Core runtime responsibilities:
37
+ - read and validate config
38
+ - initialize queue/API/health/worker services
39
+ - start an adapter and consume emitted messages
40
+ - enqueue normalized messages for API delivery
41
+ - Adapter responsibilities (in adapter repo):
42
+ - read from a concrete source (SDR, SMTP, polling, etc.)
43
+ - parse source-specific payloads
44
+ - emit normalized `Message` objects
45
+
46
+ This split keeps source integration complexity out of the core and allows multiple adapter repos to share one stable runtime.
47
+
48
+ ## Runtime Flow
49
+
50
+ On startup, the core follows this sequence:
51
+
52
+ 1. Validate `INGEST_CORE__*` configuration.
53
+ 2. Initialize API client, queue manager, health monitor, and worker.
54
+ 3. Load/create adapter instance.
55
+ 4. Start adapter stream processing.
56
+ 5. For each emitted message:
57
+ - set source label
58
+ - enqueue message
59
+ - worker submits to PagerMon API
60
+ 6. On signal/error: stop adapter pipeline and core services gracefully.
61
+
62
+ This behavior is orchestrated in `lib/runtime/service.js` and `lib/runtime/pipeline.js`.
63
+
64
+ ## Repository Structure
65
+
66
+ Important paths in this repository:
67
+
68
+ - `index.js`: default entrypoint (loader mode)
69
+ - `bootstrap.js`: bootstrap API used by adapter repos
70
+ - `lib/config.js`: env parsing and validation
71
+ - `lib/core/`: queue, API, worker, health services
72
+ - `lib/runtime/`: adapter loader and runtime orchestration
73
+ - `lib/message/Message.js`: shared normalized message model
74
+ - `test/unit/`, `test/integration/`: core runtime tests
75
+
76
+ ## Adapter Convention
77
+
78
+ A concrete adapter image must provide this module:
79
+
80
+ - `/app/adapter/adapter.js`
81
+
82
+ The core loads that module at startup and validates the runtime adapter contract (`getName`, `start`, `stop`, `isRunning`).
83
+
84
+ ## Configuration Prefixes
85
+
86
+ - Core: `INGEST_CORE__*`
87
+ - Adapter: `INGEST_ADAPTER__*`
88
+
89
+ The core forwards adapter keys as structured config (`adapter`) and raw env map (`rawEnv`) to the selected adapter.
90
+
91
+ Example mapping:
92
+
93
+ - Env: `INGEST_ADAPTER__SMTP__HOST=smtp.example.org`
94
+ - In adapter: `this.config.adapter.smtp.host === 'smtp.example.org'`
95
+ - Raw fallback: `this.config.rawEnv.INGEST_ADAPTER__SMTP__HOST`
96
+
97
+ ## Runtime Modes
98
+
99
+ `@pagermon/ingest-core` supports two startup modes:
100
+
101
+ - Default loader mode: `node index.js`
102
+ - Bootstrap mode: adapter repo entrypoint calls `bootstrapWithAdapter(AdapterClass)`
103
+
104
+ Default loader mode expects an adapter entry module at `/app/adapter/adapter.js`
105
+ (override with `INGEST_CORE__ADAPTER_ENTRY`).
106
+
107
+ Bootstrap mode lets adapter repos pass the adapter class directly and avoids path conventions.
108
+
109
+ Use loader mode when your container layout already provides `/app/adapter/adapter.js`.
110
+ Use bootstrap mode when your adapter repo wants explicit startup control in code.
111
+
112
+ ## Development
113
+
114
+ ```bash
115
+ npm ci
116
+ npm run check
117
+ npm test
118
+ ```
119
+
120
+ ## Container
121
+
122
+ Build core image:
123
+
124
+ ```bash
125
+ docker build -t shutterfire/ingest-core:latest .
126
+ ```
127
+
128
+ ## Using Ingest with PagerMon Server
129
+
130
+ Ingest sends messages to PagerMon server API endpoint `INGEST_CORE__API_URL`.
131
+
132
+ If both services run in one compose project, set:
133
+
134
+ ```bash
135
+ INGEST_CORE__API_URL=http://pagermon:3000
136
+ ```
137
+
138
+ Where `pagermon` is the server service name.
139
+
140
+ ## Developing Your Own Adapter
141
+
142
+ You can always build your own adapter to support other sources, such as PDW, incoming emails, polling from websites and so on.
143
+
144
+ - Full adapter contract and implementation guide: [ADAPTER_DEVELOPMENT.md](./ADAPTER_DEVELOPMENT.md)
145
+
146
+ Rule of thumb:
147
+
148
+ - change this repo when runtime behavior should be shared by all adapters
149
+ - change adapter repo when behavior is source-specific
150
+
151
+ If you only need to run Ingest, you can ignore this section.
152
+
153
+ ## Contribution
154
+
155
+ If you plan to change code in this repository, use [CONTRIBUTING.md](./CONTRIBUTING.md) as the primary guide.
156
+
157
+ Quick local quality check:
158
+
159
+ ```bash
160
+ npm run lint
161
+ npm test
162
+ ```
163
+
164
+ Detailed testing conventions and adapter integration test behavior are documented in
165
+ [CONTRIBUTING.md](./CONTRIBUTING.md).
package/bootstrap.js ADDED
@@ -0,0 +1,10 @@
1
+ import { runService } from './lib/runtime/service.js';
2
+
3
+ export function bootstrapWithAdapter(AdapterClass) {
4
+ if (typeof AdapterClass !== 'function') {
5
+ throw new TypeError('bootstrapWithAdapter requires an adapter class/constructor');
6
+ }
7
+
8
+ const adapterFactory = (adapterConfig) => new AdapterClass(adapterConfig);
9
+ return runService({ adapterFactory });
10
+ }
@@ -0,0 +1,74 @@
1
+ import js from '@eslint/js';
2
+ import prettier from 'eslint-plugin-prettier';
3
+ import prettierConfig from 'eslint-config-prettier';
4
+ import globals from 'globals';
5
+
6
+ export default [
7
+ js.configs.recommended,
8
+ prettierConfig,
9
+ {
10
+ plugins: {
11
+ prettier,
12
+ },
13
+ rules: {
14
+ // Prettier integration: options come from .prettierrc
15
+ 'prettier/prettier': 'error',
16
+
17
+ // Console statements (allowed in Node.js service)
18
+ 'no-console': 'off',
19
+
20
+ // Variable declarations
21
+ 'no-var': 'warn',
22
+ 'prefer-const': 'warn',
23
+
24
+ // Naming conventions
25
+ camelcase: ['warn', { properties: 'never', ignoreDestructuring: true }],
26
+
27
+ // Code quality
28
+ 'no-unused-vars': ['error', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }],
29
+ 'no-unused-expressions': 'off',
30
+ 'no-use-before-define': ['error', { functions: false, classes: true, variables: true }],
31
+
32
+ // Best practices
33
+ eqeqeq: ['error', 'always', { null: 'ignore' }],
34
+ 'no-shadow': 'warn',
35
+ 'no-param-reassign': ['warn', { props: false }],
36
+ 'prefer-arrow-callback': 'warn',
37
+ 'prefer-template': 'warn',
38
+ 'no-else-return': 'warn',
39
+ 'object-shorthand': ['warn', 'always'],
40
+ 'prefer-destructuring': ['warn', { object: true, array: false }],
41
+
42
+ // Error handling
43
+ 'no-throw-literal': 'error',
44
+ 'prefer-promise-reject-errors': 'error',
45
+
46
+ // Async/await
47
+ 'require-await': 'warn',
48
+ 'no-await-in-loop': 'warn',
49
+
50
+ // Security
51
+ 'no-eval': 'error',
52
+ 'no-implied-eval': 'error',
53
+ 'no-new-func': 'error',
54
+ },
55
+ languageOptions: {
56
+ ecmaVersion: 2024,
57
+ sourceType: 'module',
58
+ globals: {
59
+ ...globals.node,
60
+ },
61
+ },
62
+ },
63
+ {
64
+ files: ['test/**/*.js'],
65
+ languageOptions: {
66
+ globals: {
67
+ ...globals.vitest,
68
+ },
69
+ },
70
+ },
71
+ {
72
+ ignores: ['node_modules/**', 'coverage/**', 'docs/**', '*.min.js'],
73
+ },
74
+ ];
package/index.js ADDED
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env node
2
+ import { createAdapter } from './lib/runtime/adapter-loader.js';
3
+ import { runService } from './lib/runtime/service.js';
4
+
5
+ runService({ adapterFactory: createAdapter });
package/lib/config.js ADDED
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Configuration Module
3
+ *
4
+ * Configuration prefixes:
5
+ * - Core: INGEST_CORE__*
6
+ * - Adapter: INGEST_ADAPTER__*
7
+ */
8
+
9
+ function getCoreEnv(key, fallback = null) {
10
+ const value = process.env[`INGEST_CORE__${key}`];
11
+ return value !== undefined ? value : fallback;
12
+ }
13
+
14
+ function parseAdapterConfig() {
15
+ const result = {};
16
+
17
+ for (const [envKey, value] of Object.entries(process.env)) {
18
+ if (!envKey.startsWith('INGEST_ADAPTER__')) {
19
+ continue;
20
+ }
21
+
22
+ const tail = envKey.slice('INGEST_ADAPTER__'.length);
23
+ const parts = tail.split('__').filter(Boolean);
24
+
25
+ // Expected minimum: <KEY>
26
+ if (parts.length < 1) {
27
+ continue;
28
+ }
29
+
30
+ const path = parts.map((part) => part.toLowerCase());
31
+ let node = result;
32
+ for (let i = 0; i < path.length - 1; i++) {
33
+ const segment = path[i];
34
+ if (typeof node[segment] !== 'object' || node[segment] === null) {
35
+ node[segment] = {};
36
+ }
37
+ node = node[segment];
38
+ }
39
+
40
+ node[path[path.length - 1]] = value;
41
+ }
42
+
43
+ return result;
44
+ }
45
+
46
+ function getAdapterRawEnv() {
47
+ const raw = {};
48
+ for (const [envKey, value] of Object.entries(process.env)) {
49
+ if (envKey.startsWith('INGEST_ADAPTER__')) {
50
+ raw[envKey] = value;
51
+ }
52
+ }
53
+ return raw;
54
+ }
55
+
56
+ function parseInteger(value, fallback = null) {
57
+ if (value === null || value === undefined || value === '') {
58
+ return fallback;
59
+ }
60
+
61
+ const parsed = parseInt(value, 10);
62
+ return Number.isNaN(parsed) ? fallback : parsed;
63
+ }
64
+
65
+ const adapterConfig = parseAdapterConfig();
66
+ const adapterRawEnv = getAdapterRawEnv();
67
+
68
+ const config = {
69
+ // Service configuration
70
+ label: getCoreEnv('LABEL', 'pagermon-ingest'),
71
+
72
+ // Adapter configuration (single adapter prefix)
73
+ adapterConfig,
74
+ adapterRawEnv,
75
+
76
+ // Core services configuration
77
+ apiUrl: getCoreEnv('API_URL', 'http://pagermon:3000'),
78
+ apiKey: getCoreEnv('API_KEY', null),
79
+ redisUrl: getCoreEnv('REDIS_URL', 'redis://redis:6379'),
80
+ enableDLQ: getCoreEnv('ENABLE_DLQ', 'true') !== 'false',
81
+
82
+ // Health check configuration
83
+ healthCheckInterval: parseInteger(getCoreEnv('HEALTH_CHECK_INTERVAL', '10000'), 10000),
84
+ healthCheckUnhealthyThreshold: parseInteger(getCoreEnv('HEALTH_UNHEALTHY_THRESHOLD', '3'), 3),
85
+ };
86
+
87
+ /**
88
+ * Validate required configuration
89
+ */
90
+ function validate() {
91
+ const errors = [];
92
+
93
+ if (!config.apiKey) {
94
+ errors.push('INGEST_CORE__API_KEY not set');
95
+ }
96
+
97
+ if (errors.length > 0) {
98
+ console.error('Configuration errors:');
99
+ errors.forEach((e) => console.error(` - ${e}`));
100
+ process.exit(1);
101
+ }
102
+ }
103
+
104
+ /**
105
+ * Build source adapter configuration
106
+ */
107
+ function buildAdapterConfig() {
108
+ return {
109
+ label: config.label,
110
+ adapter: config.adapterConfig,
111
+ rawEnv: config.adapterRawEnv,
112
+ };
113
+ }
114
+
115
+ export default {
116
+ ...config,
117
+ validate,
118
+ buildAdapterConfig,
119
+ };
@@ -0,0 +1,207 @@
1
+ /**
2
+ * API Client - PagerMon API communication
3
+ *
4
+ * Handles HTTP communication with the PagerMon API.
5
+ */
6
+
7
+ import http from 'http';
8
+ import https from 'https';
9
+
10
+ // Error classes
11
+ class TimeoutError extends Error {
12
+ constructor(message) {
13
+ super(message);
14
+ this.name = 'TimeoutError';
15
+ }
16
+ }
17
+
18
+ class AuthError extends Error {
19
+ constructor(message) {
20
+ super(message);
21
+ this.name = 'AuthError';
22
+ this.isAuth = true;
23
+ }
24
+ }
25
+
26
+ class ClientError extends Error {
27
+ constructor(message) {
28
+ super(message);
29
+ this.name = 'ClientError';
30
+ }
31
+ }
32
+
33
+ class ServerError extends Error {
34
+ constructor(message) {
35
+ super(message);
36
+ this.name = 'ServerError';
37
+ }
38
+ }
39
+
40
+ class ApiClient {
41
+ /**
42
+ * @param {Object} config
43
+ * @param {string} config.url - API Base URL
44
+ * @param {string} config.apiKey - API key for authentication
45
+ * @param {Object} [options] - Additional options
46
+ */
47
+ constructor(config, options = {}) {
48
+ if (!config.url) throw new Error('ApiClient requires config.url');
49
+ if (!config.apiKey) throw new Error('ApiClient requires config.apiKey');
50
+
51
+ this.url = config.url;
52
+ this.apiKey = config.apiKey;
53
+ this.timeout = options.timeout || 10000;
54
+ this.retries = options.retries || 3;
55
+ this.retryDelay = options.retryDelay || 1000;
56
+ }
57
+
58
+ /**
59
+ * Submit a message to the API
60
+ * @param {Message|Object} message - Message with address, message, format, etc.
61
+ * @returns {Promise<Object>} API response
62
+ */
63
+ async submitMessage(message) {
64
+ const payload = message.toPayload ? message.toPayload() : message;
65
+
66
+ try {
67
+ const result = await this._request('POST', '/api/messages', payload);
68
+ return { success: true, data: result };
69
+ } catch (err) {
70
+ return { success: false, error: err.message };
71
+ }
72
+ }
73
+
74
+ /**
75
+ * Check API health
76
+ * @returns {Promise<boolean>}
77
+ */
78
+ async checkHealth() {
79
+ try {
80
+ const result = await this._request('GET', '/api/health', null, {
81
+ timeout: 5000,
82
+ retries: 1,
83
+ });
84
+ return result && result.status === 'ok';
85
+ } catch {
86
+ return false;
87
+ }
88
+ }
89
+
90
+ /**
91
+ * Make HTTP request with retry logic
92
+ * @private
93
+ */
94
+ async _request(method, path, body = null, options = {}) {
95
+ const timeout = options.timeout || this.timeout;
96
+ const maxRetries = options.retries !== undefined ? options.retries : this.retries;
97
+
98
+ let lastErr;
99
+
100
+ // Sequential retry with exponential backoff is intentional here.
101
+ /* eslint-disable no-await-in-loop */
102
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
103
+ try {
104
+ return await this._makeRequest(method, path, body, timeout);
105
+ } catch (err) {
106
+ lastErr = err;
107
+
108
+ // Only retry on transient errors
109
+ if (!this._isTransientError(err)) {
110
+ throw err;
111
+ }
112
+
113
+ if (attempt < maxRetries) {
114
+ const delay = this.retryDelay * Math.pow(2, attempt); // Exponential backoff
115
+ await new Promise((resolve) => setTimeout(resolve, delay));
116
+ }
117
+ }
118
+ }
119
+ /* eslint-enable no-await-in-loop */
120
+
121
+ throw lastErr;
122
+ }
123
+
124
+ /**
125
+ * Actually execute the HTTP request
126
+ * @private
127
+ */
128
+ _makeRequest(method, path, body, timeout) {
129
+ return new Promise((resolve, reject) => {
130
+ const url = new URL(path, this.url);
131
+ const isHttps = url.protocol === 'https:';
132
+ const client = isHttps ? https : http;
133
+
134
+ const bodyStr = body ? JSON.stringify(body) : null;
135
+
136
+ const options = {
137
+ method,
138
+ timeout,
139
+ headers: {
140
+ 'Content-Type': 'application/json',
141
+ 'X-API-Key': this.apiKey,
142
+ },
143
+ };
144
+
145
+ if (bodyStr) {
146
+ options.headers['Content-Length'] = Buffer.byteLength(bodyStr);
147
+ }
148
+
149
+ const req = client.request(url, options, (res) => {
150
+ let data = '';
151
+
152
+ res.on('data', (chunk) => {
153
+ data += chunk;
154
+ });
155
+
156
+ res.on('end', () => {
157
+ try {
158
+ if (res.statusCode >= 200 && res.statusCode < 300) {
159
+ const parsed = data ? JSON.parse(data) : {};
160
+ resolve(parsed);
161
+ } else if (res.statusCode === 401) {
162
+ reject(new AuthError('Unauthorized'));
163
+ } else if (res.statusCode >= 400 && res.statusCode < 500) {
164
+ reject(new ClientError(`${res.statusCode}: ${data}`));
165
+ } else {
166
+ reject(new ServerError(`${res.statusCode}: ${data}`));
167
+ }
168
+ } catch (err) {
169
+ reject(err);
170
+ }
171
+ });
172
+ });
173
+
174
+ req.on('timeout', () => {
175
+ req.destroy();
176
+ reject(new TimeoutError('Request timeout'));
177
+ });
178
+
179
+ req.on('error', (err) => {
180
+ reject(err);
181
+ });
182
+
183
+ if (bodyStr) {
184
+ req.write(bodyStr);
185
+ }
186
+
187
+ req.end();
188
+ });
189
+ }
190
+
191
+ /**
192
+ * Determine if an error is transient (retryable)
193
+ * @private
194
+ */
195
+ _isTransientError(err) {
196
+ if (err instanceof TimeoutError) return true;
197
+ if (err instanceof ServerError) return true;
198
+ if (err instanceof ClientError) return false; // 4xx errors don't retry
199
+ if (err instanceof AuthError) return false; // 401 doesn't retry
200
+ if (err.code === 'ECONNREFUSED') return true;
201
+ if (err.code === 'ETIMEDOUT') return true;
202
+ if (err.code === 'EHOSTUNREACH') return true;
203
+ return false;
204
+ }
205
+ }
206
+
207
+ export default ApiClient;
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Health Monitor - API availability monitoring
3
+ *
4
+ * Tracks API availability only.
5
+ */
6
+
7
+ class HealthMonitor {
8
+ /**
9
+ * @param {Object} config
10
+ * @param {ApiClient} config.apiClient - API client for health checks
11
+ * @param {Object} [options] - Additional options
12
+ */
13
+ constructor(config, options = {}) {
14
+ if (!config.apiClient) throw new Error('HealthMonitor requires config.apiClient');
15
+
16
+ this.apiClient = config.apiClient;
17
+ this.checkInterval = options.checkInterval || 10000; // 10 seconds
18
+ this.unhealthyThreshold = options.unhealthyThreshold || 3;
19
+
20
+ this.isHealthy = true;
21
+ this.failureCount = 0;
22
+ this.lastCheckTime = null;
23
+ this.timer = null;
24
+ this.callbacks = {
25
+ onHealthChange: null,
26
+ onCheck: null,
27
+ };
28
+ }
29
+
30
+ /**
31
+ * Register callbacks
32
+ */
33
+ on(event, callback) {
34
+ if (event === 'healthChange' || event === 'check') {
35
+ this.callbacks[`on${event.charAt(0).toUpperCase()}${event.slice(1)}`] = callback;
36
+ }
37
+ }
38
+
39
+ /**
40
+ * Start the monitor
41
+ */
42
+ start() {
43
+ console.log('[HEALTH] Starting health monitor');
44
+ this.perform();
45
+ this.timer = setInterval(() => this.perform(), this.checkInterval);
46
+ }
47
+
48
+ /**
49
+ * Stop the monitor
50
+ */
51
+ stop() {
52
+ if (this.timer) {
53
+ clearInterval(this.timer);
54
+ this.timer = null;
55
+ }
56
+ console.log('[HEALTH] Health monitor stopped');
57
+ }
58
+
59
+ /**
60
+ * Perform one health check cycle
61
+ */
62
+ async perform() {
63
+ try {
64
+ const wasHealthy = this.isHealthy;
65
+ this.lastCheckTime = new Date();
66
+
67
+ const healthy = await this.apiClient.checkHealth();
68
+
69
+ if (healthy) {
70
+ this.isHealthy = true;
71
+ this.failureCount = 0;
72
+ } else {
73
+ this.failureCount++;
74
+ if (this.failureCount >= this.unhealthyThreshold) {
75
+ this.isHealthy = false;
76
+ }
77
+ }
78
+
79
+ if (this.callbacks.onCheck) {
80
+ this.callbacks.onCheck({
81
+ healthy: this.isHealthy,
82
+ failureCount: this.failureCount,
83
+ timestamp: this.lastCheckTime,
84
+ });
85
+ }
86
+
87
+ if (wasHealthy !== this.isHealthy && this.callbacks.onHealthChange) {
88
+ this.callbacks.onHealthChange({
89
+ healthy: this.isHealthy,
90
+ timestamp: this.lastCheckTime,
91
+ });
92
+ }
93
+ } catch (err) {
94
+ console.error('[HEALTH] Check error:', err.message);
95
+ this.failureCount++;
96
+ if (this.failureCount >= this.unhealthyThreshold && this.isHealthy) {
97
+ this.isHealthy = false;
98
+ if (this.callbacks.onHealthChange) {
99
+ this.callbacks.onHealthChange({
100
+ healthy: false,
101
+ timestamp: new Date(),
102
+ });
103
+ }
104
+ }
105
+ }
106
+ }
107
+
108
+ /**
109
+ * Current status snapshot
110
+ */
111
+ getStatus() {
112
+ return {
113
+ healthy: this.isHealthy,
114
+ failureCount: this.failureCount,
115
+ lastCheck: this.lastCheckTime,
116
+ };
117
+ }
118
+ }
119
+
120
+ export default HealthMonitor;
@@ -0,0 +1,156 @@
1
+ /**
2
+ * Message Queue Manager - BullMQ based message queue
3
+ *
4
+ * Handles message queue management only.
5
+ */
6
+
7
+ import { Queue, Worker as BullWorker } from 'bullmq';
8
+ import IORedis from 'ioredis';
9
+
10
+ class QueueManager {
11
+ /**
12
+ * @param {Object} config
13
+ * @param {string} config.redisUrl - Redis connection URL
14
+ * @param {Object} [options] - Additional options
15
+ */
16
+ constructor(config, options = {}) {
17
+ if (!config.redisUrl) throw new Error('QueueManager requires config.redisUrl');
18
+
19
+ this.redisUrl = config.redisUrl;
20
+ this.queueName = options.queueName || 'sdr-messages';
21
+ this.queue = null;
22
+ this.dlq = null;
23
+ this.worker = null;
24
+ this.connection = null;
25
+ this.workerConnection = null;
26
+ this.enableDLQ = options.enableDLQ !== false;
27
+ }
28
+
29
+ /**
30
+ * Initialize queue resources
31
+ */
32
+ initialize() {
33
+ this.connection = new IORedis(this.redisUrl, {
34
+ maxRetriesPerRequest: null,
35
+ });
36
+
37
+ this.queue = new Queue(this.queueName, {
38
+ connection: this.connection,
39
+ defaultJobOptions: {
40
+ attempts: 10,
41
+ backoff: {
42
+ type: 'exponential',
43
+ delay: 2000,
44
+ },
45
+ removeOnComplete: true,
46
+ },
47
+ });
48
+
49
+ if (this.enableDLQ) {
50
+ this.dlq = new Queue(`${this.queueName}-dlq`, {
51
+ connection: this.connection,
52
+ });
53
+ }
54
+
55
+ console.log(`[QUEUE] Initialized: ${this.queueName}`);
56
+ }
57
+
58
+ /**
59
+ * Enqueue a message
60
+ * @param {Message|Object} message
61
+ * @returns {Promise<Job>}
62
+ */
63
+ async addMessage(message) {
64
+ if (!this.queue) throw new Error('Queue not initialized. Call initialize() first.');
65
+
66
+ const payload = message.toPayload ? message.toPayload() : message;
67
+ const job = await this.queue.add('message', payload, {
68
+ jobId: `msg-${payload.source}-${payload.address}-${payload.timestamp}`,
69
+ });
70
+
71
+ console.debug(`[QUEUE] Added job ${job.id}`);
72
+ return job;
73
+ }
74
+
75
+ /**
76
+ * Get failed messages from the DLQ
77
+ * @returns {Promise<Object[]>}
78
+ */
79
+ async getDeadLetters(limit = 100) {
80
+ if (!this.enableDLQ || !this.dlq) {
81
+ return [];
82
+ }
83
+
84
+ const jobs = await this.dlq.getJobs(['failed'], 0, Math.max(0, limit - 1));
85
+ return jobs.map((job) => ({
86
+ id: job.id,
87
+ data: job.data,
88
+ failedReason: job.failedReason,
89
+ attemptsMade: job.attemptsMade,
90
+ }));
91
+ }
92
+
93
+ /**
94
+ * Get current queue size
95
+ */
96
+ async getQueueSize() {
97
+ if (!this.queue) return 0;
98
+ return await this.queue.count();
99
+ }
100
+
101
+ /**
102
+ * Start processing jobs using a BullMQ Worker
103
+ * @param {(job: import('bullmq').Job) => Promise<unknown>} processor
104
+ */
105
+ startProcessing(processor) {
106
+ if (this.worker) {
107
+ return;
108
+ }
109
+
110
+ // BullMQ worker uses blocking Redis operations; use dedicated connection.
111
+ this.workerConnection = this.connection.duplicate();
112
+
113
+ this.worker = new BullWorker(
114
+ this.queueName,
115
+ async (job) => {
116
+ return await processor(job);
117
+ },
118
+ {
119
+ connection: this.workerConnection,
120
+ }
121
+ );
122
+
123
+ this.worker.on('error', (err) => {
124
+ console.error('[QUEUE] Worker error:', err.message);
125
+ });
126
+ }
127
+
128
+ /**
129
+ * Close all queue connections
130
+ */
131
+ async close() {
132
+ if (this.worker) {
133
+ await this.worker.close();
134
+ this.worker = null;
135
+ }
136
+ if (this.queue) {
137
+ await this.queue.close();
138
+ this.queue = null;
139
+ }
140
+ if (this.dlq) {
141
+ await this.dlq.close();
142
+ this.dlq = null;
143
+ }
144
+ if (this.connection) {
145
+ await this.connection.quit();
146
+ this.connection = null;
147
+ }
148
+ if (this.workerConnection) {
149
+ await this.workerConnection.quit();
150
+ this.workerConnection = null;
151
+ }
152
+ console.log('[QUEUE] Closed');
153
+ }
154
+ }
155
+
156
+ export default QueueManager;
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Worker - queue consumer that submits messages to the API
3
+ *
4
+ * Consumes messages from the queue and submits them to the API.
5
+ */
6
+
7
+ class Worker {
8
+ /**
9
+ * @param {Object} config
10
+ * @param {QueueManager} config.queue - Queue manager
11
+ * @param {ApiClient} config.apiClient - API client
12
+ * @param {HealthMonitor} config.health - Health monitor
13
+ */
14
+ constructor(config) {
15
+ if (!config.queue) throw new Error('Worker requires config.queue');
16
+ if (!config.apiClient) throw new Error('Worker requires config.apiClient');
17
+ if (!config.health) throw new Error('Worker requires config.health');
18
+
19
+ this.queue = config.queue;
20
+ this.apiClient = config.apiClient;
21
+ this.health = config.health;
22
+ this.processing = false;
23
+ this.callbacks = {
24
+ onMessageProcessed: null,
25
+ onMessageFailed: null,
26
+ };
27
+ }
28
+
29
+ /**
30
+ * Register callbacks
31
+ */
32
+ on(event, callback) {
33
+ if (event === 'messageProcessed' || event === 'messageFailed') {
34
+ this.callbacks[`on${event.charAt(0).toUpperCase()}${event.slice(1)}`] = callback;
35
+ }
36
+ }
37
+
38
+ /**
39
+ * Start the worker and begin processing jobs
40
+ */
41
+ async start() {
42
+ console.log('[WORKER] Starting queue consumer');
43
+
44
+ if (!this.queue.queue) {
45
+ await this.queue.initialize();
46
+ }
47
+
48
+ this.processing = true;
49
+
50
+ // Process each message via QueueManager/BullMQ worker
51
+ this.queue.startProcessing((job) => this._processMessage(job));
52
+
53
+ console.log('[WORKER] Queue consumer started');
54
+ }
55
+
56
+ /**
57
+ * Process a single message
58
+ * @private
59
+ */
60
+ async _processMessage(job) {
61
+ const messageData = job.data;
62
+
63
+ const result = await this.apiClient.submitMessage(messageData);
64
+
65
+ if (result.success) {
66
+ console.debug(`[WORKER] Message sent: ${messageData.address}`);
67
+
68
+ if (this.callbacks.onMessageProcessed) {
69
+ this.callbacks.onMessageProcessed({
70
+ jobId: job.id,
71
+ message: messageData,
72
+ });
73
+ }
74
+
75
+ return result;
76
+ }
77
+ console.warn(`[WORKER] Message failed: ${messageData.address} - ${result.error}`);
78
+
79
+ // If API is unhealthy, throw to trigger queue retry
80
+ if (!this.health.isHealthy) {
81
+ throw new Error(`API unhealthy: ${result.error}`);
82
+ }
83
+
84
+ // For other failures, throw only for retryable classes
85
+ if (result.error && result.error.includes('401')) {
86
+ throw new Error('API Authentication failed - will not retry');
87
+ }
88
+
89
+ if (result.error && !result.error.includes('4')) {
90
+ // Server-side errors should be retried
91
+ throw new Error(result.error);
92
+ }
93
+
94
+ if (this.callbacks.onMessageFailed) {
95
+ this.callbacks.onMessageFailed({
96
+ jobId: job.id,
97
+ message: messageData,
98
+ error: result.error,
99
+ });
100
+ }
101
+
102
+ // Return a soft-failure payload without throwing
103
+ return { failed: true, error: result.error };
104
+ }
105
+
106
+ /**
107
+ * Stop the worker
108
+ */
109
+ async stop() {
110
+ this.processing = false;
111
+ if (this.queue) {
112
+ await this.queue.close();
113
+ }
114
+ console.log('[WORKER] Worker stopped');
115
+ }
116
+ }
117
+
118
+ export default Worker;
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Message Domain - normalized message structure
3
+ *
4
+ * Defines the standardized structure for all messages in the system.
5
+ */
6
+
7
+ class Message {
8
+ /**
9
+ * @param {Object} data
10
+ * @param {string} data.address - Receiver address/capcode
11
+ * @param {string} data.message - Message text
12
+ * @param {string} data.format - 'alpha' or 'numeric'
13
+ * @param {string} data.source - Source label
14
+ * @param {number} [data.timestamp] - Unix timestamp
15
+ * @param {string} [data.time] - ISO8601 timestamp
16
+ * @param {Object} [data.metadata] - Optional protocol-specific metadata
17
+ */
18
+ constructor(data) {
19
+ if (!data.address) throw new Error('Message requires address');
20
+ if (!data.message && data.format === 'alpha') {
21
+ throw new Error('Alpha message requires message content');
22
+ }
23
+ if (!data.format) throw new Error('Message requires format');
24
+ if (!data.source) throw new Error('Message requires source');
25
+
26
+ this.address = String(data.address);
27
+ this.message = String(data.message || '');
28
+ this.format = data.format.toLowerCase();
29
+ this.source = data.source;
30
+ this.timestamp = data.timestamp || Math.floor(Date.now() / 1000);
31
+ this.time = data.time || new Date(this.timestamp * 1000).toISOString();
32
+ this.metadata = data.metadata || {};
33
+ }
34
+
35
+ /**
36
+ * Convert to API payload format
37
+ */
38
+ toPayload() {
39
+ return {
40
+ address: this.address,
41
+ message: this.message,
42
+ format: this.format,
43
+ source: this.source,
44
+ timestamp: this.timestamp,
45
+ time: this.time,
46
+ ...this.metadata,
47
+ };
48
+ }
49
+
50
+ /**
51
+ * Validate message shape and semantic constraints
52
+ */
53
+ validate() {
54
+ const errors = [];
55
+
56
+ if (!this.address || this.address.trim().length === 0) {
57
+ errors.push('address is required');
58
+ }
59
+
60
+ if (this.format === 'alpha' && this.message.trim().length === 0) {
61
+ errors.push('alpha messages require message content');
62
+ }
63
+
64
+ if (!['alpha', 'numeric'].includes(this.format)) {
65
+ errors.push(`invalid format: ${this.format}`);
66
+ }
67
+
68
+ return {
69
+ valid: errors.length === 0,
70
+ errors,
71
+ };
72
+ }
73
+ }
74
+
75
+ export default Message;
@@ -0,0 +1,32 @@
1
+ const DEFAULT_ADAPTER_ENTRY = '/app/adapter/adapter.js';
2
+ const REQUIRED_METHODS = ['getName', 'start', 'stop', 'isRunning'];
3
+
4
+ function getAdapterEntry() {
5
+ const configured = process.env.INGEST_CORE__ADAPTER_ENTRY;
6
+ return configured && configured.trim() ? configured.trim() : DEFAULT_ADAPTER_ENTRY;
7
+ }
8
+
9
+ function validateAdapterInstance(instance) {
10
+ if (!instance || typeof instance !== 'object') {
11
+ throw new TypeError('Selected adapter must be an object instance');
12
+ }
13
+
14
+ for (const method of REQUIRED_METHODS) {
15
+ if (typeof instance[method] !== 'function') {
16
+ throw new TypeError(`Selected adapter missing required method: ${method}()`);
17
+ }
18
+ }
19
+ }
20
+
21
+ export async function createAdapter(config) {
22
+ const module = await import(getAdapterEntry());
23
+ const AdapterClass = module.default;
24
+
25
+ if (typeof AdapterClass !== 'function') {
26
+ throw new TypeError('Adapter module must export a default class or constructor function');
27
+ }
28
+
29
+ const instance = new AdapterClass(config);
30
+ validateAdapterInstance(instance);
31
+ return instance;
32
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Orchestration Pipeline
3
+ *
4
+ * Orchestrates a unified source adapter.
5
+ * Used by the main application and contains no business logic.
6
+ */
7
+
8
+ import { createAdapter } from './adapter-loader.js';
9
+
10
+ class Orchestrator {
11
+ /**
12
+ * @param {Object} config
13
+ * @param {Object} config.adapter - Adapter configuration object
14
+ */
15
+ constructor(config) {
16
+ if (!config.adapter) {
17
+ throw new Error('Orchestrator requires adapter config');
18
+ }
19
+
20
+ this.config = config;
21
+ this.adapterFactory = config.adapterFactory || createAdapter;
22
+ this.adapter = null;
23
+ }
24
+
25
+ /**
26
+ * Initialize source adapter
27
+ */
28
+ async initialize() {
29
+ this.adapter = await this.adapterFactory(this.config.adapter);
30
+ console.log(`[ORCHESTRATOR] Source adapter: ${this.adapter.getName()}`);
31
+ }
32
+
33
+ /**
34
+ * Start reading messages from the adapter
35
+ * @param {Function} onMessage - Callback for each parsed message
36
+ * @param {Function} onClose - Callback when stream closes
37
+ * @param {Function} onError - Callback on stream error
38
+ */
39
+ async startReadingMessages(onMessage, onClose, onError) {
40
+ await this.adapter.start(onMessage, onClose, onError);
41
+ }
42
+
43
+ /**
44
+ * Stop the pipeline
45
+ */
46
+ async shutdown() {
47
+ console.log('[ORCHESTRATOR] Shutting down...');
48
+
49
+ if (this.adapter) {
50
+ await this.adapter.stop();
51
+ }
52
+
53
+ console.log('[ORCHESTRATOR] Shutdown complete');
54
+ }
55
+
56
+ /**
57
+ * Check status
58
+ */
59
+ getStatus() {
60
+ return {
61
+ adapterRunning: this.adapter && this.adapter.isRunning(),
62
+ adapterConfigured: !!this.config.adapter,
63
+ };
64
+ }
65
+ }
66
+
67
+ export default Orchestrator;
@@ -0,0 +1,148 @@
1
+ import { readFileSync } from 'fs';
2
+ import { fileURLToPath } from 'url';
3
+ import { dirname, join } from 'path';
4
+ import config from '../config.js';
5
+ import ApiClient from '../core/ApiClient.js';
6
+ import QueueManager from '../core/QueueManager.js';
7
+ import HealthMonitor from '../core/HealthMonitor.js';
8
+ import Worker from '../core/Worker.js';
9
+ import Orchestrator from './pipeline.js';
10
+
11
+ const __filename = fileURLToPath(import.meta.url);
12
+ const __dirname = dirname(__filename);
13
+ const packageJson = JSON.parse(readFileSync(join(__dirname, '..', '..', 'package.json'), 'utf-8'));
14
+ const { version } = packageJson;
15
+
16
+ export async function runService({ adapterFactory } = {}) {
17
+ let orchestrator = null;
18
+ let api = null;
19
+ let queue = null;
20
+ let health = null;
21
+ let worker = null;
22
+
23
+ async function shutdown(code = 0) {
24
+ console.log('[MAIN] Shutting down...');
25
+
26
+ try {
27
+ if (orchestrator) {
28
+ await orchestrator.shutdown();
29
+ }
30
+
31
+ if (worker) {
32
+ await worker.stop();
33
+ }
34
+
35
+ if (queue) {
36
+ await queue.close();
37
+ }
38
+
39
+ if (health) {
40
+ health.stop();
41
+ }
42
+
43
+ console.log('[MAIN] Shutdown complete');
44
+ process.exit(code);
45
+ } catch (err) {
46
+ console.error('[MAIN] Shutdown error:', err.message);
47
+ process.exit(1);
48
+ }
49
+ }
50
+
51
+ config.validate();
52
+
53
+ console.log(`${'='.repeat(60)}`);
54
+ console.log(`Pagermon Ingest Service v${version}`);
55
+ console.log(`${'='.repeat(60)}`);
56
+ console.log(`Label: ${config.label}`);
57
+ console.log('Adapter: /app/adapter/adapter.js');
58
+ console.log(`API URL: ${config.apiUrl}`);
59
+ console.log(`Redis URL: ${config.redisUrl}`);
60
+ console.log(`Dead Letter Queue: ${config.enableDLQ ? 'enabled' : 'disabled'}`);
61
+ console.log(`${'='.repeat(60)}`);
62
+
63
+ try {
64
+ console.log('[MAIN] Initializing core services...');
65
+
66
+ api = new ApiClient({ url: config.apiUrl, apiKey: config.apiKey }, { timeout: 10000, retries: 3 });
67
+
68
+ queue = new QueueManager({ redisUrl: config.redisUrl }, { queueName: 'sdr-messages', enableDLQ: config.enableDLQ });
69
+ await queue.initialize();
70
+
71
+ health = new HealthMonitor(
72
+ { apiClient: api },
73
+ {
74
+ checkInterval: config.healthCheckInterval,
75
+ unhealthyThreshold: config.healthCheckUnhealthyThreshold,
76
+ }
77
+ );
78
+ health.start();
79
+
80
+ worker = new Worker({
81
+ queue,
82
+ apiClient: api,
83
+ health,
84
+ });
85
+
86
+ worker.on('messageProcessed', (info) => {
87
+ console.debug(`[WORKER] Processed: ${info.message.address}`);
88
+ });
89
+
90
+ worker.on('messageFailed', (info) => {
91
+ console.warn(`[WORKER] Failed: ${info.message.address} - ${info.error}`);
92
+ });
93
+
94
+ await worker.start();
95
+
96
+ console.log('[MAIN] Core services initialized');
97
+ console.log('[MAIN] Initializing adapters...');
98
+
99
+ orchestrator = new Orchestrator({
100
+ adapter: config.buildAdapterConfig(),
101
+ adapterFactory,
102
+ });
103
+
104
+ await orchestrator.initialize();
105
+
106
+ console.log('[MAIN] Starting message processing...');
107
+
108
+ await orchestrator.startReadingMessages(
109
+ async (message) => {
110
+ try {
111
+ message.source = config.label;
112
+ await queue.addMessage(message);
113
+ } catch (err) {
114
+ console.error('[MAIN] Failed to enqueue message:', err.message);
115
+ }
116
+ },
117
+ () => {
118
+ console.error('[MAIN] Message stream closed unexpectedly');
119
+ shutdown(1);
120
+ },
121
+ (err) => {
122
+ console.error('[MAIN] Message stream error:', err.message);
123
+ shutdown(1);
124
+ }
125
+ );
126
+
127
+ process.on('SIGINT', () => {
128
+ console.log('\n[MAIN] Received SIGINT');
129
+ shutdown(0);
130
+ });
131
+
132
+ process.on('SIGTERM', () => {
133
+ console.log('[MAIN] Received SIGTERM');
134
+ shutdown(0);
135
+ });
136
+
137
+ process.on('SIGQUIT', () => {
138
+ console.log('[MAIN] Received SIGQUIT');
139
+ shutdown(0);
140
+ });
141
+
142
+ console.log('[MAIN] Service started successfully');
143
+ } catch (err) {
144
+ console.error('[MAIN] Initialization error:', err.message);
145
+ console.error(err.stack);
146
+ process.exit(1);
147
+ }
148
+ }
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "@pagermon/ingest-core",
3
+ "version": "1.0.0",
4
+ "description": "PagerMon ingest core runtime",
5
+ "type": "module",
6
+ "main": "index.js",
7
+ "scripts": {
8
+ "start": "node index.js",
9
+ "test": "vitest run --coverage",
10
+ "test:all": "vitest run",
11
+ "test:unit": "vitest run test/unit",
12
+ "test:integration": "vitest run test/integration",
13
+ "test:coverage": "vitest run --coverage",
14
+ "test:watch": "vitest",
15
+ "lint": "eslint .",
16
+ "lint:fix": "eslint . --fix",
17
+ "format": "prettier --write \"**/*.{js,mjs,json,md}\"",
18
+ "format:check": "prettier --check \"**/*.{js,mjs,json,md}\"",
19
+ "check": "npm run format:check && npm run lint",
20
+ "prepare": "husky"
21
+ },
22
+ "keywords": [
23
+ "pagermon",
24
+ "sdr",
25
+ "rtl-sdr",
26
+ "multimon-ng",
27
+ "pocsag",
28
+ "flex"
29
+ ],
30
+ "author": "",
31
+ "license": "ISC",
32
+ "engines": {
33
+ "node": ">=22.0.0",
34
+ "npm": ">=9.0"
35
+ },
36
+ "dependencies": {
37
+ "bullmq": "^5.63.0",
38
+ "ioredis": "^5.8.2"
39
+ },
40
+ "devDependencies": {
41
+ "@vitest/coverage-v8": "^2.1.8",
42
+ "eslint": "^9.16.0",
43
+ "eslint-config-prettier": "^9.1.0",
44
+ "eslint-plugin-prettier": "^5.2.1",
45
+ "globals": "^15.14.0",
46
+ "husky": "^9.1.7",
47
+ "prettier": "^3.4.2",
48
+ "vitest": "^2.1.8"
49
+ }
50
+ }
@@ -0,0 +1,11 @@
1
+ {
2
+ "packages": {
3
+ ".": {
4
+ "release-type": "node",
5
+ "package-name": "@pagermon/ingest-core",
6
+ "changelog-path": "CHANGELOG.md",
7
+ "bump-minor-pre-major": true,
8
+ "bump-patch-for-minor-pre-major": false
9
+ }
10
+ }
11
+ }