@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 +20 -0
- package/.release-please-manifest.json +3 -0
- package/README.md +165 -0
- package/bootstrap.js +10 -0
- package/eslint.config.mjs +74 -0
- package/index.js +5 -0
- package/lib/config.js +119 -0
- package/lib/core/ApiClient.js +207 -0
- package/lib/core/HealthMonitor.js +120 -0
- package/lib/core/QueueManager.js +156 -0
- package/lib/core/Worker.js +118 -0
- package/lib/message/Message.js +75 -0
- package/lib/runtime/adapter-loader.js +32 -0
- package/lib/runtime/pipeline.js +67 -0
- package/lib/runtime/service.js +148 -0
- package/package.json +50 -0
- package/release-please-config.json +11 -0
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
|
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
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
|
+
}
|