@onlineapps/service-wrapper 3.3.5 → 3.4.1
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/CHANGELOG.md +69 -0
- package/README.md +1 -1
- package/package.json +3 -1
- package/src/ServiceWrapper.js +200 -290
- package/src/createTenantContextMiddleware.js +1 -1
- package/src/index.js +32 -110
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# Changelog — @onlineapps/service-wrapper
|
|
2
|
+
|
|
3
|
+
All notable changes to this package. Follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) format and semantic versioning within the internal-monorepo convention (all biz-service consumers ship matching changes in the same session).
|
|
4
|
+
|
|
5
|
+
## [3.4.1] — 2026-08-17
|
|
6
|
+
|
|
7
|
+
**Patch: drop stale port/url/specificationEndpoint guards left over from A2.2.**
|
|
8
|
+
|
|
9
|
+
Under 3.4.0 the wrapper still refused to start when `config.service.port`, `config.service.url`, or `config.service.specificationEndpoint` were absent — legacy checks from `_ensureValidationProof` and `_initializeRegistry` that predated ADR 0005 and were not removed together with the Express init in A2.2. Live rollout hit the failure on first biz restart (biz-property Phase 0.2: "Service port is required for validation").
|
|
10
|
+
|
|
11
|
+
Removed:
|
|
12
|
+
- Port + URL guard in `_ensureValidationProof` (before Tier-1 validation runs).
|
|
13
|
+
- Port + URL guard in `_initializeRegistry` (before Registry registration).
|
|
14
|
+
- specificationEndpoint guard in `_initializeRegistry`.
|
|
15
|
+
|
|
16
|
+
Behaviour changes:
|
|
17
|
+
- `serviceInfo.url` sent to Registry is now the empty string (was: required non-empty).
|
|
18
|
+
- `specificationEndpoint` sent to RegistryClient is `''` when absent (was: throw).
|
|
19
|
+
- Consumers: none — Registry stores the ops spec directly from the register payload; the URL and endpoint fields have had no live reader since 3.4.0 A2.
|
|
20
|
+
|
|
21
|
+
Fully compatible with 3.4.0 tests (98/98 GREEN) and with 3.4.0 biz configs that still declare `port`/`url`/`specificationEndpoint` in `config/service/config.json` — they're just no longer required.
|
|
22
|
+
|
|
23
|
+
## [3.4.0] — 2026-08-17
|
|
24
|
+
|
|
25
|
+
**Zero-HTTP shape for biz services** (ADR 0005 landed). See `api/docs/biz/80-decisions/0005-no-http-in-biz-containers.md`.
|
|
26
|
+
|
|
27
|
+
### BREAKING
|
|
28
|
+
|
|
29
|
+
- **Constructor:** `options.app` and `options.server` no longer required. Passed values are accepted for transitional callers but IGNORED — the wrapper never binds an Express app or HTTP server.
|
|
30
|
+
- **`bootstrap()`:** signature `bootstrap(serviceRoot, options)` no longer reads `options.app`, no longer resolves `require('./src/app')`, no longer calls `app.listen(PORT)`. Return value shape changed from `{wrapper, server}` to `{wrapper}` (no server exists).
|
|
31
|
+
- **Removed HTTP endpoints:** `GET /`, `GET /health`, `GET /info`, `GET /status`, `GET /specification`. These no longer mount on any Express app. Consumers relying on HTTP-polling `/health` (e.g. Registry `businessReadinessChecker` pre-3.4.0) MUST switch to reading `infrastructureHealthTracker` state populated from the MQ heartbeat.
|
|
32
|
+
- **Removed methods** (were private, listed for grep-completeness): `_setupHealthChecks`, `_setupInfoEndpoint`, `_getValidationInfo`, `_getStandardLevel`, `_getInfraFingerprint`, `_extractExpressRoutes`.
|
|
33
|
+
- **Removed test suites**: `tests/unit/ServiceWrapper.healthDependencies.test.js` (whole file tested the retired HTTP `/health` handler). `tests/unit/ServiceWrapper.test.js` describes `/info endpoint` and `Operations-Routes Alignment` deleted (tested removed methods).
|
|
34
|
+
|
|
35
|
+
### Added
|
|
36
|
+
|
|
37
|
+
- **`wrapper.startHealthHeartbeat()`** — new public method. Wires the biz service into the existing MQ heartbeat mechanism (`createBaseClientAdapter` from `@onlineapps/infrastructure-tools`). First heartbeat is emitted synchronously by publisher `start()`.
|
|
38
|
+
- **`wrapper.stopHealthHeartbeat()`** — cleanup counterpart; called from `_cleanupBeforeRestart`.
|
|
39
|
+
- **`wrapper.refreshDependencyHealth()`** — periodic refresh of cached `_lastDbHealth` / `_lastRedisHealth` (was a side effect of `_setupHealthChecks`; now standalone).
|
|
40
|
+
- **`wrapper._collectHeartbeatComponents()`** — private helper returning `{mq, redis, db, cache} → 'healthy'|'unhealthy'|'unknown'` map, matching the shape `infrastructureHealth.listener` validates.
|
|
41
|
+
- **Bootstrap phase 0.10** — Health Heartbeat Publisher startup (documented in `api/docs/biz/50-lifecycle/tier1-validation.md`).
|
|
42
|
+
- **New standard level v1.3** — Zero-HTTP Shape (documented in `api/docs/biz/00-model/service-shape.md`).
|
|
43
|
+
|
|
44
|
+
### Dependencies
|
|
45
|
+
|
|
46
|
+
- New: `@onlineapps/infrastructure-tools@1.2.4` (exact, provides the `createHealthPublisher` factory this version wires into).
|
|
47
|
+
|
|
48
|
+
### Migration for biz services
|
|
49
|
+
|
|
50
|
+
Every biz service under `api_biz/*/` has also been updated in the same session:
|
|
51
|
+
- `docker-compose.yml` / `docker-compose.production.yml`: removed `ports:` section
|
|
52
|
+
- `Dockerfile`: removed `EXPOSE` and `HEALTHCHECK`
|
|
53
|
+
- No changes needed to handlers (they never touched Express directly)
|
|
54
|
+
- `src/app.js` remains in each biz repo for now (bootstrap doesn't read it; deletion is a follow-up cleanup)
|
|
55
|
+
|
|
56
|
+
Rollout order:
|
|
57
|
+
1. `npm publish @onlineapps/service-wrapper@3.4.0`
|
|
58
|
+
2. In each `api_biz/*/package.json`: exact `"@onlineapps/service-wrapper": "3.4.0"` + `npm install`
|
|
59
|
+
3. Restart containers; verify `docker port <biz>` empty and `redis-cli KEYS 'infrastructure:health:*'` shows entries.
|
|
60
|
+
|
|
61
|
+
### Rationale
|
|
62
|
+
|
|
63
|
+
- Removes the last HTTP surface in biz containers. `_probeServiceHealth` (Registry) was the sole automated consumer; replaced by tracker reads.
|
|
64
|
+
- Reduces service-wrapper code by ~950 lines net.
|
|
65
|
+
- Unifies biz + infra service liveness path (same queue, same tracker, same Redis projection).
|
|
66
|
+
|
|
67
|
+
## [3.3.5] — Pre-3.4.0 baseline
|
|
68
|
+
|
|
69
|
+
Last version with Express-mounted health/info/status endpoints. No changelog for versions prior to 3.4.0.
|
package/README.md
CHANGED
|
@@ -246,7 +246,7 @@ Returns runtime information:
|
|
|
246
246
|
- **Implementation standard level** — highest cumulative level satisfied (v1.0, v1.1, v1.2, ...)
|
|
247
247
|
- **Revalidation state** — current backoff cycle status
|
|
248
248
|
|
|
249
|
-
Standard levels are determined by `ServiceStructureValidator` from `@onlineapps/conn-orch-validator`. See [Implementation Standard Levels](/docs/
|
|
249
|
+
Standard levels are determined by `ServiceStructureValidator` from `@onlineapps/conn-orch-validator`. See [Implementation Standard Levels](/docs/biz/00-model/service-shape.md#implementation-standard-levels) and [Validator README](/shared/connector/conn-orch-validator/README.md#implementation-standard-levels).
|
|
250
250
|
|
|
251
251
|
---
|
|
252
252
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@onlineapps/service-wrapper",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.4.1",
|
|
4
4
|
"description": "Thin orchestration layer for microservices - delegates all infrastructure concerns to specialized connectors",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"scripts": {
|
|
@@ -34,6 +34,7 @@
|
|
|
34
34
|
"@onlineapps/conn-orch-orchestrator": "2.1.3",
|
|
35
35
|
"@onlineapps/conn-orch-registry": "1.2.2",
|
|
36
36
|
"@onlineapps/conn-orch-validator": "3.2.3",
|
|
37
|
+
"@onlineapps/infrastructure-tools": "1.2.4",
|
|
37
38
|
"@onlineapps/monitoring-core": "1.0.24",
|
|
38
39
|
"@onlineapps/runtime-config": "1.0.2",
|
|
39
40
|
"@onlineapps/service-common": "1.1.3",
|
|
@@ -46,6 +47,7 @@
|
|
|
46
47
|
"jsdoc": "^4.0.2",
|
|
47
48
|
"jsdoc-to-markdown": "^8.0.0"
|
|
48
49
|
},
|
|
50
|
+
"//_express_devDep": "Express remains in devDependencies solely because a few legacy unit tests (ServiceWrapper.test.js, orchestrator-contract-v1.test.js) still construct Express apps for tests that were written before ADR 0005. Runtime never imports Express. The dep will be dropped when those tests migrate.",
|
|
49
51
|
"engines": {
|
|
50
52
|
"node": ">=14.0.0"
|
|
51
53
|
}
|
package/src/ServiceWrapper.js
CHANGED
|
@@ -99,20 +99,22 @@ class ServiceWrapper {
|
|
|
99
99
|
/**
|
|
100
100
|
* Create a new ServiceWrapper instance
|
|
101
101
|
* @param {Object} options - Configuration options
|
|
102
|
-
* @param {Object} options.app - Express application instance
|
|
103
|
-
* @param {Object} options.server - HTTP server instance
|
|
104
102
|
* @param {Object} options.config - Service and wrapper configuration
|
|
105
103
|
* @param {Object} options.operations - Operations schema
|
|
106
104
|
* @param {string} [options.serviceRoot] - Service root directory (for validation)
|
|
107
105
|
* @param {Object} [options.validationProof=null] - Optional validation proof {hash, data}
|
|
108
106
|
* @param {Object} [options._validationOrchestrator=null] - Optional ValidationOrchestrator for testing
|
|
107
|
+
* @param {Object} [options.app] - IGNORED (transitional; ADR 0005 removed Express)
|
|
108
|
+
* @param {Object} [options.server] - IGNORED (transitional; ADR 0005 removed Express)
|
|
109
109
|
*/
|
|
110
110
|
constructor(options = {}) {
|
|
111
111
|
this._validateOptions(options);
|
|
112
112
|
|
|
113
113
|
// Store configuration
|
|
114
|
-
|
|
115
|
-
|
|
114
|
+
// Zero-HTTP shape (ADR 0005): biz services do not own an Express app
|
|
115
|
+
// or HTTP server. `options.app` / `options.server` are accepted for
|
|
116
|
+
// transitional compatibility with callers still passing them, but the
|
|
117
|
+
// wrapper does not read them anywhere.
|
|
116
118
|
this.config = this._processConfig(options.config);
|
|
117
119
|
this.operations = options.operations;
|
|
118
120
|
this.serviceRoot = options.serviceRoot;
|
|
@@ -137,7 +139,7 @@ class ServiceWrapper {
|
|
|
137
139
|
this._errorMapper = null;
|
|
138
140
|
|
|
139
141
|
// Fail-fast: enforce naming conventions and workspaceScoped invariants.
|
|
140
|
-
// See api/docs/
|
|
142
|
+
// See api/docs/biz/30-operations/schema-v3.md and biz-service-onboarding.md §3.1 / §4.
|
|
141
143
|
// Rejection here keeps the contract authoritative at the earliest possible
|
|
142
144
|
// point (before MQ listeners, registry client, or any I/O is touched).
|
|
143
145
|
this._validateNamingAndWorkspaceScoped();
|
|
@@ -309,12 +311,10 @@ class ServiceWrapper {
|
|
|
309
311
|
* @private
|
|
310
312
|
*/
|
|
311
313
|
_validateOptions(options) {
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
throw new Error('HTTP server instance is required');
|
|
317
|
-
}
|
|
314
|
+
// Zero-HTTP shape (ADR 0005): biz services no longer own an Express app
|
|
315
|
+
// or HTTP server. `app` and `server` options are accepted but no longer
|
|
316
|
+
// required — they exist only as a transitional hint for callers still
|
|
317
|
+
// passing them. They MUST NOT be used inside the wrapper.
|
|
318
318
|
if (!options.config) {
|
|
319
319
|
throw new Error('Configuration is required');
|
|
320
320
|
}
|
|
@@ -354,7 +354,7 @@ class ServiceWrapper {
|
|
|
354
354
|
: null;
|
|
355
355
|
if (!opsMap || typeof opsMap !== 'object') {
|
|
356
356
|
throw new Error(
|
|
357
|
-
`[ServiceWrapper][${serviceName}] operations schema is invalid - expected object with 'operations' map of operation definitions; see api/docs/
|
|
357
|
+
`[ServiceWrapper][${serviceName}] operations schema is invalid - expected object with 'operations' map of operation definitions; see api/docs/biz/30-operations/schema-v3.md`
|
|
358
358
|
);
|
|
359
359
|
}
|
|
360
360
|
const offenders = Object.keys(opsMap).filter((key) => !kebabRegex.test(key));
|
|
@@ -362,7 +362,7 @@ class ServiceWrapper {
|
|
|
362
362
|
throw new Error(
|
|
363
363
|
`[ServiceWrapper][${serviceName}] operation key(s) violate kebab-case convention: ` +
|
|
364
364
|
`${offenders.map((k) => `'${k}'`).join(', ')} - must match /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/. ` +
|
|
365
|
-
`See api/docs/
|
|
365
|
+
`See api/docs/biz/30-operations/schema-v3.md §Best Practices - Naming Convention. ` +
|
|
366
366
|
`Rename the key(s) in operations.json plus every cookbook/test/Admin UI literal; there is no alias layer.`
|
|
367
367
|
);
|
|
368
368
|
}
|
|
@@ -372,7 +372,7 @@ class ServiceWrapper {
|
|
|
372
372
|
if (typeof ws !== 'boolean') {
|
|
373
373
|
throw new Error(
|
|
374
374
|
`[ServiceWrapper][${serviceName}] service.workspaceScoped is required and MUST be an explicit boolean in config.json (got ${ws === undefined ? 'undefined' : JSON.stringify(ws)}). ` +
|
|
375
|
-
`See api/docs/
|
|
375
|
+
`See api/docs/biz/60-templates/onboarding-checklist.md §3.1. ` +
|
|
376
376
|
`true = service stores data keyed by workspace_id and implements list-workspaces operation; ` +
|
|
377
377
|
`false = stateless or cross-workspace.`
|
|
378
378
|
);
|
|
@@ -384,14 +384,14 @@ class ServiceWrapper {
|
|
|
384
384
|
throw new Error(
|
|
385
385
|
`[ServiceWrapper][${serviceName}] service.workspaceScoped is true but 'list-workspaces' operation is not declared. ` +
|
|
386
386
|
`Workspace-scoped services MUST expose list-workspaces (workspace discovery convention). ` +
|
|
387
|
-
`See api/docs/
|
|
387
|
+
`See api/docs/biz/60-templates/onboarding-checklist.md §4 and OPERATIONS.md §Reserved operation names.`
|
|
388
388
|
);
|
|
389
389
|
}
|
|
390
390
|
if (ws === false && hasListWorkspaces) {
|
|
391
391
|
throw new Error(
|
|
392
392
|
`[ServiceWrapper][${serviceName}] service.workspaceScoped is false but 'list-workspaces' operation is declared. ` +
|
|
393
393
|
`Stateless / cross-workspace services MUST NOT declare list-workspaces - the flag is the authoritative signal. ` +
|
|
394
|
-
`See api/docs/
|
|
394
|
+
`See api/docs/biz/60-templates/onboarding-checklist.md §4.`
|
|
395
395
|
);
|
|
396
396
|
}
|
|
397
397
|
}
|
|
@@ -650,8 +650,11 @@ class ServiceWrapper {
|
|
|
650
650
|
async _cleanupBeforeRestart() {
|
|
651
651
|
const serviceName = this.config.service?.name || 'unnamed-service';
|
|
652
652
|
console.log(`[CLEANUP] Starting cleanup for ${serviceName} before restart...`);
|
|
653
|
-
|
|
653
|
+
|
|
654
654
|
try {
|
|
655
|
+
// 0. Stop heartbeat publisher (Phase 0.10)
|
|
656
|
+
this.stopHealthHeartbeat();
|
|
657
|
+
|
|
655
658
|
// 1. Cancel all consumers
|
|
656
659
|
if (this.registryClient?.queueManager?.channel) {
|
|
657
660
|
try {
|
|
@@ -919,10 +922,8 @@ class ServiceWrapper {
|
|
|
919
922
|
// cache, monitoring, mq) and BEFORE MQ messages are dispatched.
|
|
920
923
|
this._initializeInvocationPipeline();
|
|
921
924
|
|
|
922
|
-
//
|
|
923
|
-
|
|
924
|
-
this._setupHealthChecks();
|
|
925
|
-
}
|
|
925
|
+
// Legacy HTTP /health and /info endpoints — RETIRED per ADR 0005.
|
|
926
|
+
// Health surface flows via Phase 0.10 heartbeat below. No HTTP mount.
|
|
926
927
|
|
|
927
928
|
// Initialize orchestrator for workflow processing
|
|
928
929
|
// NOTE: Orchestrator is prepared but workflow listeners will be started
|
|
@@ -931,6 +932,26 @@ class ServiceWrapper {
|
|
|
931
932
|
await this._initializeOrchestrator();
|
|
932
933
|
}
|
|
933
934
|
|
|
935
|
+
// FÁZE 0.10: Health heartbeat publisher (ADR 0005 — replaces HTTP /health).
|
|
936
|
+
// Wires biz service into the existing infrastructure heartbeat mechanism
|
|
937
|
+
// used by gateway / validator / delivery_endpoint. First heartbeat is
|
|
938
|
+
// emitted synchronously by createHealthPublisher.start() so Registry can
|
|
939
|
+
// flip readiness immediately on boot.
|
|
940
|
+
if (this.mqClient && this.config.wrapper?.heartbeat?.enabled !== false) {
|
|
941
|
+
const heartbeatStartTime = Date.now();
|
|
942
|
+
try {
|
|
943
|
+
await this.startHealthHeartbeat();
|
|
944
|
+
this._logPhase('0.10', 'Health Heartbeat Publisher', 'PASSED', null, Date.now() - heartbeatStartTime);
|
|
945
|
+
} catch (error) {
|
|
946
|
+
// Transient: heartbeat failure should not block service operation.
|
|
947
|
+
// Registry will mark the service stale until heartbeats arrive.
|
|
948
|
+
this._logPhase('0.10', 'Health Heartbeat Publisher', 'FAILED', error, Date.now() - heartbeatStartTime);
|
|
949
|
+
this.logger?.warn('[ServiceWrapper] Heartbeat publisher failed to start; service continues without heartbeat', {
|
|
950
|
+
error: error.message
|
|
951
|
+
});
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
|
|
934
955
|
this.isInitialized = true;
|
|
935
956
|
this._logPhase('INIT', 'ServiceWrapper Initialization', 'PASSED', null, Date.now() - startTime);
|
|
936
957
|
this.logger?.info(`ServiceWrapper initialized successfully for ${serviceName}`);
|
|
@@ -1213,15 +1234,11 @@ class ServiceWrapper {
|
|
|
1213
1234
|
throw new Error('[ServiceWrapper] Missing configuration - service.name is required');
|
|
1214
1235
|
}
|
|
1215
1236
|
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
const serviceUrl = this.config.service?.url;
|
|
1222
|
-
if (!serviceUrl) {
|
|
1223
|
-
throw new Error('[ServiceWrapper] Missing configuration - service.url is required');
|
|
1224
|
-
}
|
|
1237
|
+
// ADR 0005: biz has no HTTP — no port, no URL. Kept in serviceInfo
|
|
1238
|
+
// payload as empty string for Registry-side backward compat with
|
|
1239
|
+
// pre-3.4.1 consumers that may still expect the field.
|
|
1240
|
+
const servicePort = null;
|
|
1241
|
+
const serviceUrl = '';
|
|
1225
1242
|
|
|
1226
1243
|
const mqUrl = this.config.wrapper?.mq?.url;
|
|
1227
1244
|
if (!mqUrl) {
|
|
@@ -1232,14 +1249,11 @@ class ServiceWrapper {
|
|
|
1232
1249
|
// FÁZE 0.5 a 0.6: Vytvoření front a spuštění konzumerů se provádí v registryClient.init()
|
|
1233
1250
|
// FÁZE 0.7: Registrace u Registry
|
|
1234
1251
|
|
|
1235
|
-
//
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
`Fix: Add "specificationEndpoint": "/api/v1/specification" to conn-config/config.json under "service" section.`
|
|
1241
|
-
);
|
|
1242
|
-
}
|
|
1252
|
+
// ADR 0005: no HTTP surface → specificationEndpoint has no consumer.
|
|
1253
|
+
// Registry receives full operations spec in the register payload
|
|
1254
|
+
// itself. Field kept optional for pre-3.4.1 config files that may
|
|
1255
|
+
// still declare it; ignored if absent.
|
|
1256
|
+
const specificationEndpoint = this.config.service?.specificationEndpoint || '';
|
|
1243
1257
|
|
|
1244
1258
|
// Logger may not be initialized yet at this point - RegistryClient handles null gracefully
|
|
1245
1259
|
this.registryClient = new RegistryConnector.ServiceRegistryClient({
|
|
@@ -1507,236 +1521,180 @@ class ServiceWrapper {
|
|
|
1507
1521
|
}
|
|
1508
1522
|
|
|
1509
1523
|
/**
|
|
1510
|
-
*
|
|
1511
|
-
*
|
|
1524
|
+
* Refresh cached component health signals for the heartbeat.
|
|
1525
|
+
*
|
|
1526
|
+
* The heartbeat itself is fire-and-forget on a short interval; we do not
|
|
1527
|
+
* probe DB/Redis on every heartbeat. Instead, this helper is invoked
|
|
1528
|
+
* periodically (or on demand) to keep `_lastDbHealth` / `_lastRedisHealth`
|
|
1529
|
+
* fresh so the next heartbeat carries meaningful status.
|
|
1530
|
+
*
|
|
1531
|
+
* Ties into `_checkDatabaseDependency` / `_checkRedisDependency` which
|
|
1532
|
+
* remain the single source of truth for what "healthy" means.
|
|
1533
|
+
*
|
|
1534
|
+
* @returns {Promise<void>}
|
|
1512
1535
|
*/
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
requiredConnectors: this.requiredConnectors || null,
|
|
1523
|
-
components: {
|
|
1524
|
-
http: 'healthy',
|
|
1525
|
-
mq: 'disabled',
|
|
1526
|
-
registry: this.registryClient ? 'healthy' : 'disabled',
|
|
1527
|
-
cache: this.cacheConnector ? 'healthy' : 'disabled',
|
|
1528
|
-
state: this.stateConnector ? 'healthy' : 'disabled',
|
|
1529
|
-
database: this.requiredConnectors?.db === true ? 'unknown' : 'not_required',
|
|
1530
|
-
redis: this.requiredConnectors?.redis === true ? 'unknown' : 'not_required'
|
|
1531
|
-
}
|
|
1532
|
-
};
|
|
1533
|
-
|
|
1534
|
-
if (this.mqClient) {
|
|
1535
|
-
const transport = this.mqClient._transport;
|
|
1536
|
-
if (transport && typeof transport.performHealthCheck === 'function') {
|
|
1537
|
-
try {
|
|
1538
|
-
const mqHealth = await transport.performHealthCheck();
|
|
1539
|
-
health.components.mq = mqHealth.healthy ? 'healthy' : 'unhealthy';
|
|
1540
|
-
if (!mqHealth.healthy) {
|
|
1541
|
-
health.mqIssues = mqHealth.issues;
|
|
1542
|
-
}
|
|
1543
|
-
} catch (e) {
|
|
1544
|
-
health.components.mq = 'unhealthy';
|
|
1545
|
-
health.mqIssues = [e.message];
|
|
1546
|
-
}
|
|
1547
|
-
} else {
|
|
1548
|
-
health.components.mq = this.mqClient.isConnected() ? 'healthy' : 'unhealthy';
|
|
1549
|
-
}
|
|
1550
|
-
}
|
|
1551
|
-
|
|
1552
|
-
if (!this.requiredConnectors) {
|
|
1553
|
-
health.components.database = 'unhealthy';
|
|
1554
|
-
health.components.redis = 'unhealthy';
|
|
1555
|
-
dependencyIssues.push(
|
|
1556
|
-
'Integration contract was not loaded - expected config/service/integration-contract.json during initialization'
|
|
1557
|
-
);
|
|
1558
|
-
} else {
|
|
1536
|
+
async refreshDependencyHealth() {
|
|
1537
|
+
if (!this.requiredConnectors) {
|
|
1538
|
+
// Integration contract not loaded — dependency state is unknown.
|
|
1539
|
+
this._lastDbHealth = 'unknown';
|
|
1540
|
+
this._lastRedisHealth = 'unknown';
|
|
1541
|
+
return;
|
|
1542
|
+
}
|
|
1543
|
+
if (this.requiredConnectors.db === true) {
|
|
1544
|
+
try {
|
|
1559
1545
|
const dbDependency = await this._checkDatabaseDependency();
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
}
|
|
1564
|
-
|
|
1565
|
-
const redisDependency = await this._checkRedisDependency();
|
|
1566
|
-
health.components.redis = redisDependency.status;
|
|
1567
|
-
if (redisDependency.status === 'unhealthy' && redisDependency.reason) {
|
|
1568
|
-
dependencyIssues.push(`[redis] ${redisDependency.reason}`);
|
|
1569
|
-
}
|
|
1546
|
+
this._lastDbHealth = dbDependency.status;
|
|
1547
|
+
} catch (_) {
|
|
1548
|
+
this._lastDbHealth = 'unhealthy';
|
|
1570
1549
|
}
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
health.status = 'unhealthy';
|
|
1579
|
-
res.status(503);
|
|
1580
|
-
} else {
|
|
1581
|
-
res.status(200);
|
|
1550
|
+
}
|
|
1551
|
+
if (this.requiredConnectors.redis === true) {
|
|
1552
|
+
try {
|
|
1553
|
+
const redisDependency = await this._checkRedisDependency();
|
|
1554
|
+
this._lastRedisHealth = redisDependency.status;
|
|
1555
|
+
} catch (_) {
|
|
1556
|
+
this._lastRedisHealth = 'unhealthy';
|
|
1582
1557
|
}
|
|
1583
|
-
|
|
1584
|
-
res.json(health);
|
|
1585
|
-
};
|
|
1586
|
-
|
|
1587
|
-
// Health route must be registered BEFORE catch-all 404/error handlers.
|
|
1588
|
-
// Services register routes (including 404) in app.js before ServiceWrapper initializes.
|
|
1589
|
-
// We use _healthHandler reference that bootstrap() registered early in the stack.
|
|
1590
|
-
if (this._healthRouteRegistered) {
|
|
1591
|
-
this._healthImpl = healthHandler;
|
|
1592
|
-
} else {
|
|
1593
|
-
this.app.get(healthEndpoint, healthHandler);
|
|
1594
1558
|
}
|
|
1595
|
-
|
|
1596
|
-
this.logger?.info(`Health check endpoint registered at ${healthEndpoint}`);
|
|
1597
|
-
|
|
1598
|
-
this._setupInfoEndpoint();
|
|
1599
1559
|
}
|
|
1600
1560
|
|
|
1601
1561
|
/**
|
|
1602
|
-
*
|
|
1603
|
-
*
|
|
1604
|
-
*
|
|
1562
|
+
* Start the health heartbeat publisher (ADR 0005, Phase 0.10).
|
|
1563
|
+
*
|
|
1564
|
+
* Wires this biz service into the existing infrastructure heartbeat
|
|
1565
|
+
* mechanism at `@onlineapps/infrastructure-tools` — the same mechanism
|
|
1566
|
+
* already used in production by api_gateway, api_services_validator,
|
|
1567
|
+
* api_delivery_endpoint. Registry's `infrastructureHealth.listener`
|
|
1568
|
+
* consumes the messages and `infrastructureHealthTracker` maintains
|
|
1569
|
+
* the state.
|
|
1570
|
+
*
|
|
1571
|
+
* Payload shape follows the validated infra format:
|
|
1572
|
+
* { serviceName, version, status, timestamp,
|
|
1573
|
+
* components: { mq, redis, db, cache }, correlationId }
|
|
1574
|
+
*
|
|
1575
|
+
* First heartbeat is emitted synchronously by publisher.start().
|
|
1576
|
+
* On publish failure, the shared publisher logs and continues — the
|
|
1577
|
+
* service is not crashed by a heartbeat glitch.
|
|
1578
|
+
*
|
|
1579
|
+
* @returns {Promise<void>}
|
|
1605
1580
|
*/
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
this.
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
const info = {
|
|
1616
|
-
service: serviceName,
|
|
1617
|
-
serviceVersion,
|
|
1618
|
-
serviceWrapper: wrapperPkg.version,
|
|
1619
|
-
timestamp: new Date().toISOString()
|
|
1620
|
-
};
|
|
1621
|
-
|
|
1622
|
-
// Collect @onlineapps/* dependency versions from service package.json
|
|
1623
|
-
info.dependencies = {};
|
|
1624
|
-
if (this.serviceRoot) {
|
|
1625
|
-
try {
|
|
1626
|
-
const svcPkgPath = path.join(this.serviceRoot, 'package.json');
|
|
1627
|
-
const svcPkg = JSON.parse(fs.readFileSync(svcPkgPath, 'utf8'));
|
|
1628
|
-
const deps = svcPkg.dependencies || {};
|
|
1629
|
-
for (const [name, version] of Object.entries(deps)) {
|
|
1630
|
-
if (name.startsWith('@onlineapps/')) {
|
|
1631
|
-
info.dependencies[name] = version;
|
|
1632
|
-
}
|
|
1633
|
-
}
|
|
1634
|
-
} catch (e) {
|
|
1635
|
-
info.dependencies._error = e.message;
|
|
1636
|
-
}
|
|
1637
|
-
}
|
|
1638
|
-
|
|
1639
|
-
// Validation proof and contract fingerprint
|
|
1640
|
-
info.validation = this._getValidationInfo();
|
|
1581
|
+
async startHealthHeartbeat() {
|
|
1582
|
+
if (this._heartbeatPublisher) {
|
|
1583
|
+
this.logger?.warn('[ServiceWrapper] Heartbeat publisher already started');
|
|
1584
|
+
return;
|
|
1585
|
+
}
|
|
1586
|
+
if (!this.mqClient) {
|
|
1587
|
+
throw new Error('[ServiceWrapper] mqClient is required to start heartbeat publisher');
|
|
1588
|
+
}
|
|
1641
1589
|
|
|
1642
|
-
|
|
1643
|
-
|
|
1590
|
+
const { createBaseClientAdapter } = require('@onlineapps/infrastructure-tools');
|
|
1591
|
+
const serviceName = this.config.service?.name || 'unnamed-service';
|
|
1592
|
+
const serviceVersion = this.config.service?.version || '1.0.0';
|
|
1644
1593
|
|
|
1645
|
-
|
|
1646
|
-
info.standardLevel = this._getStandardLevel();
|
|
1594
|
+
const getHealthData = () => this._collectHeartbeatComponents();
|
|
1647
1595
|
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1596
|
+
// createBaseClientAdapter builds a publisher that uses BaseClient.publish;
|
|
1597
|
+
// our mqClient exposes .publish so this is a direct fit.
|
|
1598
|
+
this._heartbeatPublisher = createBaseClientAdapter(
|
|
1599
|
+
this.mqClient,
|
|
1600
|
+
serviceName,
|
|
1601
|
+
getHealthData,
|
|
1602
|
+
{ ...(this.config.wrapper?.heartbeat || {}) },
|
|
1603
|
+
this.logger
|
|
1604
|
+
);
|
|
1653
1605
|
|
|
1654
|
-
|
|
1655
|
-
|
|
1606
|
+
// Some downstream consumers of createBaseClientAdapter attach the
|
|
1607
|
+
// service version onto messages via the publisher constructor;
|
|
1608
|
+
// createHealthPublisher accepts version only via options — we can
|
|
1609
|
+
// rely on the tracker's `version: 'unknown'` default when absent.
|
|
1610
|
+
void serviceVersion;
|
|
1611
|
+
|
|
1612
|
+
// Prime the cached dependency signals so the first heartbeat has
|
|
1613
|
+
// meaningful component state instead of 'unknown' placeholders.
|
|
1614
|
+
await this.refreshDependencyHealth();
|
|
1615
|
+
|
|
1616
|
+
// Periodic dependency refresh so subsequent heartbeats stay fresh
|
|
1617
|
+
// without probing DB/Redis inside the sync getHealthData callback.
|
|
1618
|
+
const refreshInterval = this.config.wrapper?.heartbeat?.dependencyRefreshMs || 10000;
|
|
1619
|
+
this._heartbeatRefreshInterval = setInterval(() => {
|
|
1620
|
+
this.refreshDependencyHealth().catch((e) => {
|
|
1621
|
+
this.logger?.warn('[ServiceWrapper] Dependency refresh failed', { error: e.message });
|
|
1622
|
+
});
|
|
1623
|
+
}, refreshInterval);
|
|
1656
1624
|
|
|
1657
|
-
this.
|
|
1625
|
+
await this._heartbeatPublisher.start();
|
|
1626
|
+
this.logger?.info(`[ServiceWrapper] Heartbeat publisher started for ${serviceName}`);
|
|
1658
1627
|
}
|
|
1659
1628
|
|
|
1660
1629
|
/**
|
|
1661
|
-
*
|
|
1630
|
+
* Snapshot of connector-oriented component health for the heartbeat
|
|
1631
|
+
* `components` map. Values are one of 'healthy' | 'unhealthy' | 'unknown',
|
|
1632
|
+
* matching what `infrastructureHealth.listener` accepts.
|
|
1633
|
+
*
|
|
1634
|
+
* The connector-orientation (mq/redis/db/cache) matches what the legacy
|
|
1635
|
+
* HTTP /health handler already computes via _checkDatabaseDependency /
|
|
1636
|
+
* _checkRedisDependency; here we produce the same signals in the shape
|
|
1637
|
+
* the shared listener validates.
|
|
1638
|
+
*
|
|
1662
1639
|
* @private
|
|
1663
|
-
* @returns {Object}
|
|
1640
|
+
* @returns {Object<string, 'healthy'|'unhealthy'|'unknown'>}
|
|
1664
1641
|
*/
|
|
1665
|
-
|
|
1666
|
-
const
|
|
1667
|
-
|
|
1642
|
+
_collectHeartbeatComponents() {
|
|
1643
|
+
const components = {
|
|
1644
|
+
mq: 'unknown',
|
|
1645
|
+
redis: 'unknown',
|
|
1646
|
+
db: 'unknown',
|
|
1647
|
+
cache: 'unknown'
|
|
1648
|
+
};
|
|
1668
1649
|
|
|
1669
|
-
if (
|
|
1670
|
-
|
|
1650
|
+
// MQ — mqClient is the transport; if isConnected() returns true it is up.
|
|
1651
|
+
if (this.mqClient && typeof this.mqClient.isConnected === 'function') {
|
|
1652
|
+
try {
|
|
1653
|
+
components.mq = this.mqClient.isConnected() ? 'healthy' : 'unhealthy';
|
|
1654
|
+
} catch (_) {
|
|
1655
|
+
components.mq = 'unhealthy';
|
|
1656
|
+
}
|
|
1657
|
+
} else if (this.mqClient) {
|
|
1658
|
+
components.mq = 'healthy';
|
|
1671
1659
|
}
|
|
1672
1660
|
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1661
|
+
// Redis / DB / cache — heartbeat is fire-and-forget so we only sample
|
|
1662
|
+
// the last known state; the full async probe belongs to the periodic
|
|
1663
|
+
// dependency checks, not to every 5-second heartbeat.
|
|
1664
|
+
if (!this.requiredConnectors) {
|
|
1665
|
+
// Integration contract not loaded — treat DB/redis as not required.
|
|
1666
|
+
components.db = 'unknown';
|
|
1667
|
+
components.redis = 'unknown';
|
|
1668
|
+
} else {
|
|
1669
|
+
components.db = this.requiredConnectors.db === true
|
|
1670
|
+
? (this._lastDbHealth || 'unknown')
|
|
1671
|
+
: 'unknown';
|
|
1672
|
+
components.redis = this.requiredConnectors.redis === true
|
|
1673
|
+
? (this._lastRedisHealth || 'unknown')
|
|
1674
|
+
: 'unknown';
|
|
1677
1675
|
}
|
|
1678
1676
|
|
|
1679
|
-
|
|
1680
|
-
const raw = JSON.parse(fs.readFileSync(proofPath, 'utf8'));
|
|
1681
|
-
const data = raw.validationData || {};
|
|
1677
|
+
components.cache = this.cacheConnector ? 'healthy' : 'unknown';
|
|
1682
1678
|
|
|
1683
|
-
|
|
1684
|
-
const ageMs = validatedAt ? Date.now() - validatedAt.getTime() : null;
|
|
1685
|
-
|
|
1686
|
-
return {
|
|
1687
|
-
status: 'valid',
|
|
1688
|
-
contractFingerprint: data.contractFingerprint || null,
|
|
1689
|
-
validatedAt: data.validatedAt || null,
|
|
1690
|
-
proofAge: ageMs !== null ? `${Math.round(ageMs / 60000)}m` : null,
|
|
1691
|
-
validatorVersion: data.validatorVersion || null,
|
|
1692
|
-
testsRun: data.testsRun ?? null,
|
|
1693
|
-
testsPassed: data.testsPassed ?? null
|
|
1694
|
-
};
|
|
1695
|
-
} catch (e) {
|
|
1696
|
-
return { status: 'error', error: e.message };
|
|
1697
|
-
}
|
|
1679
|
+
return components;
|
|
1698
1680
|
}
|
|
1699
1681
|
|
|
1700
1682
|
/**
|
|
1701
|
-
*
|
|
1702
|
-
*
|
|
1703
|
-
* @private
|
|
1704
|
-
* @returns {{ level: string|null, details: Array }}
|
|
1683
|
+
* Stop the heartbeat publisher (used in cleanup paths).
|
|
1684
|
+
* @returns {void}
|
|
1705
1685
|
*/
|
|
1706
|
-
|
|
1707
|
-
if (
|
|
1708
|
-
|
|
1686
|
+
stopHealthHeartbeat() {
|
|
1687
|
+
if (this._heartbeatRefreshInterval) {
|
|
1688
|
+
clearInterval(this._heartbeatRefreshInterval);
|
|
1689
|
+
this._heartbeatRefreshInterval = null;
|
|
1709
1690
|
}
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
}
|
|
1718
|
-
}
|
|
1719
|
-
|
|
1720
|
-
/**
|
|
1721
|
-
* Read infra:fingerprint from Redis (if cache connector is available).
|
|
1722
|
-
* @private
|
|
1723
|
-
* @returns {Promise<Object|null>}
|
|
1724
|
-
*/
|
|
1725
|
-
async _getInfraFingerprint() {
|
|
1726
|
-
if (!this.cacheConnector) {
|
|
1727
|
-
return null;
|
|
1728
|
-
}
|
|
1729
|
-
|
|
1730
|
-
try {
|
|
1731
|
-
const raw = await this.cacheConnector.get('infra:fingerprint');
|
|
1732
|
-
if (!raw) return null;
|
|
1733
|
-
const parsed = typeof raw === 'string' ? JSON.parse(raw) : raw;
|
|
1734
|
-
return {
|
|
1735
|
-
hash: parsed.hash || null,
|
|
1736
|
-
timestamp: parsed.timestamp || null
|
|
1737
|
-
};
|
|
1738
|
-
} catch (e) {
|
|
1739
|
-
return { error: e.message };
|
|
1691
|
+
if (this._heartbeatPublisher) {
|
|
1692
|
+
try {
|
|
1693
|
+
this._heartbeatPublisher.stop();
|
|
1694
|
+
} catch (e) {
|
|
1695
|
+
this.logger?.warn('[ServiceWrapper] Heartbeat stop() threw', { error: e.message });
|
|
1696
|
+
}
|
|
1697
|
+
this._heartbeatPublisher = null;
|
|
1740
1698
|
}
|
|
1741
1699
|
}
|
|
1742
1700
|
|
|
@@ -2162,49 +2120,6 @@ class ServiceWrapper {
|
|
|
2162
2120
|
throw err;
|
|
2163
2121
|
}
|
|
2164
2122
|
|
|
2165
|
-
/**
|
|
2166
|
-
* Extract all registered Express routes from the app router stack.
|
|
2167
|
-
* Handles nested Router instances (e.g. app.use('/api', router)).
|
|
2168
|
-
* Compatible with Express 4 (app._router) and Express 5 (app.router).
|
|
2169
|
-
* @private
|
|
2170
|
-
* @returns {Array<{method: string, path: string}>}
|
|
2171
|
-
*/
|
|
2172
|
-
_extractExpressRoutes() {
|
|
2173
|
-
const routes = [];
|
|
2174
|
-
const stack = this.app._router?.stack || this.app.router?.stack || [];
|
|
2175
|
-
|
|
2176
|
-
const extractFromStack = (layers, prefix) => {
|
|
2177
|
-
for (const layer of layers) {
|
|
2178
|
-
if (layer.route) {
|
|
2179
|
-
for (const method of Object.keys(layer.route.methods)) {
|
|
2180
|
-
routes.push({ method: method.toUpperCase(), path: `${prefix}${layer.route.path}` });
|
|
2181
|
-
}
|
|
2182
|
-
} else if (layer.name === 'router' && layer.handle?.stack) {
|
|
2183
|
-
let mountPath = '';
|
|
2184
|
-
|
|
2185
|
-
// Express 4: extract prefix from regexp
|
|
2186
|
-
// Multi-segment paths like /api/v1 produce regexp source ^\/api\/v1\/?...
|
|
2187
|
-
// Each path segment is escaped as \/segment, so we capture all segments.
|
|
2188
|
-
if (layer.regexp?.source) {
|
|
2189
|
-
const match = layer.regexp.source.match(/^\^((?:\\\/[a-zA-Z0-9_.~-]+)+)/);
|
|
2190
|
-
if (match) mountPath = match[1].replace(/\\\//g, '/');
|
|
2191
|
-
}
|
|
2192
|
-
|
|
2193
|
-
// Express 5: probe the matcher to discover mount prefix
|
|
2194
|
-
if (!mountPath && layer.matchers?.[0]) {
|
|
2195
|
-
const probe = layer.matchers[0]('/api');
|
|
2196
|
-
if (probe && probe.path) mountPath = probe.path;
|
|
2197
|
-
}
|
|
2198
|
-
|
|
2199
|
-
extractFromStack(layer.handle.stack, `${prefix}${mountPath}`);
|
|
2200
|
-
}
|
|
2201
|
-
}
|
|
2202
|
-
};
|
|
2203
|
-
|
|
2204
|
-
extractFromStack(stack, '');
|
|
2205
|
-
return routes;
|
|
2206
|
-
}
|
|
2207
|
-
|
|
2208
2123
|
/**
|
|
2209
2124
|
* Instantiate the v3 invocation pipeline (HandlerRegistry, SchemaValidator,
|
|
2210
2125
|
* ContextBuilder, ErrorMapper, HandlerLoader). Called from initialize()
|
|
@@ -2699,16 +2614,11 @@ class ServiceWrapper {
|
|
|
2699
2614
|
throw new Error('Service version is required for validation');
|
|
2700
2615
|
}
|
|
2701
2616
|
|
|
2702
|
-
const { name: serviceName, version: serviceVersion
|
|
2703
|
-
|
|
2704
|
-
if (!servicePort) {
|
|
2705
|
-
throw new Error('Service port is required for validation (config.service.port)');
|
|
2706
|
-
}
|
|
2617
|
+
const { name: serviceName, version: serviceVersion } = this.config.service;
|
|
2707
2618
|
|
|
2708
|
-
|
|
2709
|
-
|
|
2710
|
-
|
|
2711
|
-
}
|
|
2619
|
+
// ADR 0005: biz services have no HTTP surface — no port to bind, no
|
|
2620
|
+
// URL to advertise. Prior versions of this method required both;
|
|
2621
|
+
// dropped in 3.4.1 because they had no consumer post-A2.
|
|
2712
2622
|
|
|
2713
2623
|
// Phase 5: Check restart-aware failure tracking
|
|
2714
2624
|
const failureData = this._readValidationFailureFile();
|
package/src/index.js
CHANGED
|
@@ -22,136 +22,60 @@ const { createTenantContextMiddleware } = require('./createTenantContextMiddlewa
|
|
|
22
22
|
// @onlineapps/conn-orch-orchestrator. The former ApiCaller /
|
|
23
23
|
// @onlineapps/conn-orch-api-mapper was retired 2026-04-21 (ADR-0001);
|
|
24
24
|
// per-operation dispatch happens in-process via HandlerRegistry.
|
|
25
|
-
|
|
26
|
-
//
|
|
27
|
-
//
|
|
25
|
+
//
|
|
26
|
+
// ADR 0005 (2026-08-17): biz containers run zero HTTP. bootstrap() does
|
|
27
|
+
// not create or listen on any Express app. Status flows via the MQ
|
|
28
|
+
// heartbeat mechanism at @onlineapps/infrastructure-tools, consumed by
|
|
29
|
+
// Registry's infrastructureHealth.listener. See docs/biz/80-decisions/0005-.
|
|
28
30
|
|
|
29
31
|
/**
|
|
30
|
-
* Bootstrap a business service with standard configuration
|
|
31
|
-
* Eliminates boilerplate code in service index.js
|
|
32
|
-
*
|
|
32
|
+
* Bootstrap a business service with standard configuration.
|
|
33
|
+
* Eliminates boilerplate code in service index.js.
|
|
34
|
+
*
|
|
35
|
+
* Zero-HTTP shape (ADR 0005): no Express app is created, no port is bound.
|
|
36
|
+
* Business operations flow via MQ + in-process HandlerRegistry dispatch.
|
|
37
|
+
* Health flows via MQ heartbeat (Phase 0.10 inside wrapper.initialize()).
|
|
38
|
+
*
|
|
33
39
|
* @param {string} serviceRoot - Service root directory (__dirname from index.js)
|
|
34
40
|
* @param {Object} [options] - Optional overrides
|
|
35
|
-
* @param {Object} [options.app] - Express app (default: require('./src/app'))
|
|
36
41
|
* @param {Object} [options.config] - Config (default: require('./src/config'))
|
|
37
42
|
* @param {Function} [options.setLogger] - Logger setter (default: require('./src/lib/logger').setLogger)
|
|
38
|
-
* @
|
|
39
|
-
*
|
|
43
|
+
* @param {string} [options.serviceBaseDir] - Handler base dir (default: <serviceRoot>/src)
|
|
44
|
+
* @returns {Promise<{wrapper: ServiceWrapper}>}
|
|
45
|
+
*
|
|
40
46
|
* @example
|
|
41
47
|
* // index.js (minimal)
|
|
42
48
|
* require('@onlineapps/service-wrapper').bootstrap(__dirname);
|
|
43
49
|
*/
|
|
44
50
|
async function bootstrap(serviceRoot, options = {}) {
|
|
45
|
-
// Load app, config, and logger from service
|
|
46
|
-
const app = options.app || require(path.join(serviceRoot, 'src', 'app'));
|
|
47
51
|
const config = options.config || require(path.join(serviceRoot, 'src', 'config'));
|
|
48
|
-
|
|
49
|
-
// Logger setter is optional
|
|
52
|
+
|
|
53
|
+
// Logger setter is optional — some services may not have one.
|
|
50
54
|
let setLogger = options.setLogger;
|
|
51
55
|
if (!setLogger) {
|
|
52
56
|
try {
|
|
53
57
|
const loggerModule = require(path.join(serviceRoot, 'src', 'lib', 'logger'));
|
|
54
58
|
setLogger = loggerModule.setLogger;
|
|
55
59
|
} catch (e) {
|
|
56
|
-
// No logger module - that's OK
|
|
57
60
|
setLogger = () => {};
|
|
58
61
|
}
|
|
59
62
|
}
|
|
60
63
|
|
|
61
64
|
console.log(`Starting ${config.service.name} v${config.service.version}...`);
|
|
62
65
|
|
|
63
|
-
//
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
tenantContext: config.wrapper?.tenantContext
|
|
67
|
-
});
|
|
68
|
-
app.use(tenantContextMw);
|
|
69
|
-
|
|
70
|
-
// Express routes are processed in registration order.
|
|
71
|
-
// Services usually register routes inside src/app BEFORE bootstrap runs, so app.use() here would be too late.
|
|
72
|
-
// We must enforce tenant context BEFORE routes: move our middleware to the beginning of the router stack.
|
|
73
|
-
// Fail-fast if we cannot guarantee correct order.
|
|
74
|
-
if (!app || !app._router || !Array.isArray(app._router.stack) || app._router.stack.length === 0) {
|
|
75
|
-
throw new Error(
|
|
76
|
-
`[service-wrapper][TenantContext] Cannot enforce tenant context - Express router stack not available. ` +
|
|
77
|
-
`Fix: ensure the service exports an Express app instance with registered routes before bootstrap.`
|
|
78
|
-
);
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
const stack = app._router.stack;
|
|
82
|
-
const lastLayer = stack[stack.length - 1];
|
|
83
|
-
const isOurMiddleware =
|
|
84
|
-
lastLayer &&
|
|
85
|
-
lastLayer.handle &&
|
|
86
|
-
typeof lastLayer.handle === 'function' &&
|
|
87
|
-
lastLayer.handle.name === tenantContextMw.name;
|
|
88
|
-
|
|
89
|
-
if (!isOurMiddleware) {
|
|
90
|
-
throw new Error(
|
|
91
|
-
`[service-wrapper][TenantContext] Cannot enforce tenant context - middleware placement is not deterministic. ` +
|
|
92
|
-
`Fix: ensure bootstrap registers tenant context middleware before routes, or update service to export app factory.`
|
|
93
|
-
);
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
stack.pop();
|
|
97
|
-
// Insert AFTER expressInit (which sets up res.status/res.json via setPrototypeOf).
|
|
98
|
-
// Position 0 = query, 1 = expressInit. Tenant context must run after both.
|
|
99
|
-
const expressInitIdx = stack.findIndex(l => l.name === 'expressInit');
|
|
100
|
-
if (expressInitIdx === -1) {
|
|
101
|
-
throw new Error(
|
|
102
|
-
'[service-wrapper][TenantContext] Express stack missing expressInit layer - ' +
|
|
103
|
-
'cannot guarantee tenant context runs after Express initialization'
|
|
104
|
-
);
|
|
105
|
-
}
|
|
106
|
-
stack.splice(expressInitIdx + 1, 0, lastLayer);
|
|
107
|
-
|
|
108
|
-
// Register /health route BEFORE service's 404 handler.
|
|
109
|
-
// ServiceWrapper._setupHealthChecks() will later set _healthImpl with actual MQ checks.
|
|
110
|
-
// Until then, this returns a basic healthy response (service is starting up).
|
|
111
|
-
let wrapperRef = null;
|
|
112
|
-
const healthEndpoint = config.wrapper?.health?.endpoint || '/health';
|
|
113
|
-
app.get(healthEndpoint, async (req, res) => {
|
|
114
|
-
if (wrapperRef && wrapperRef._healthImpl) {
|
|
115
|
-
return wrapperRef._healthImpl(req, res);
|
|
116
|
-
}
|
|
117
|
-
res.json({ status: 'starting', service: config.service.name, timestamp: new Date().toISOString() });
|
|
118
|
-
});
|
|
119
|
-
|
|
120
|
-
// Move health route before 404/error handlers (same stack technique as tenant context)
|
|
121
|
-
const healthLayer = stack.pop();
|
|
122
|
-
if (healthLayer) {
|
|
123
|
-
const catchAllIdx = stack.findIndex((layer, i) =>
|
|
124
|
-
i > 0 && !layer.route && !['query', 'expressInit', 'tenantContextMiddleware', 'jsonParser', 'urlencodedParser'].includes(layer.name)
|
|
125
|
-
);
|
|
126
|
-
if (catchAllIdx > 0) {
|
|
127
|
-
stack.splice(catchAllIdx, 0, healthLayer);
|
|
128
|
-
} else {
|
|
129
|
-
stack.push(healthLayer);
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
// 1. Start HTTP server
|
|
134
|
-
const PORT = config.service.port;
|
|
135
|
-
const server = app.listen(PORT, () => {
|
|
136
|
-
console.log(`✓ HTTP server listening on port ${PORT}`);
|
|
137
|
-
console.log(`✓ Health check: /health`);
|
|
138
|
-
});
|
|
139
|
-
|
|
140
|
-
// 2. Initialize Service Wrapper (MQ, Registry, Monitoring, etc.)
|
|
141
|
-
// v3 invocation model: HandlerLoader requires the service's src/ as base
|
|
142
|
-
// dir for resolving handler refs (RFC §5.9). Biz services may override by
|
|
143
|
-
// passing options.serviceBaseDir via bootstrap().
|
|
66
|
+
// v3 invocation model: HandlerLoader requires the service's src/ as the
|
|
67
|
+
// base dir for resolving handler refs (RFC §5.9). Biz services may
|
|
68
|
+
// override via options.serviceBaseDir.
|
|
144
69
|
const serviceBaseDir = options.serviceBaseDir || path.join(serviceRoot, 'src');
|
|
70
|
+
|
|
145
71
|
const wrapper = new ServiceWrapper({
|
|
146
|
-
app,
|
|
147
|
-
server,
|
|
148
72
|
serviceRoot,
|
|
149
73
|
serviceBaseDir,
|
|
150
74
|
config: {
|
|
151
75
|
service: {
|
|
152
76
|
name: config.service.name,
|
|
153
77
|
version: config.service.version,
|
|
154
|
-
port
|
|
78
|
+
// `port` intentionally omitted — no HTTP surface in biz containers.
|
|
155
79
|
url: config.service.url,
|
|
156
80
|
specificationEndpoint: config.service.specificationEndpoint,
|
|
157
81
|
description: config.service.description,
|
|
@@ -164,33 +88,31 @@ async function bootstrap(serviceRoot, options = {}) {
|
|
|
164
88
|
validationProof: config.validationProof
|
|
165
89
|
});
|
|
166
90
|
|
|
167
|
-
wrapper._healthRouteRegistered = true;
|
|
168
91
|
await wrapper.initialize();
|
|
169
|
-
wrapperRef = wrapper;
|
|
170
92
|
|
|
171
|
-
// Initialize centralized logger for use throughout the service
|
|
172
93
|
if (setLogger && wrapper.logger) {
|
|
173
94
|
setLogger(wrapper.logger);
|
|
174
95
|
}
|
|
175
96
|
|
|
176
|
-
console.log(`✓ Service Wrapper initialized (MQ, Registry, Monitoring)`);
|
|
97
|
+
console.log(`✓ Service Wrapper initialized (MQ, Registry, Monitoring, Heartbeat)`);
|
|
177
98
|
console.log(`✓ Local logs: logs/app.{date}.log`);
|
|
178
99
|
console.log(`✓ ${config.service.name} ready\n`);
|
|
179
100
|
|
|
180
|
-
//
|
|
101
|
+
// Graceful shutdown — no HTTP server to close, just tear down the wrapper.
|
|
181
102
|
const shutdown = async () => {
|
|
182
103
|
console.log('\nShutting down gracefully...');
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
}
|
|
104
|
+
if (typeof wrapper.shutdown === 'function') {
|
|
105
|
+
await wrapper.shutdown();
|
|
106
|
+
} else if (typeof wrapper.stopHealthHeartbeat === 'function') {
|
|
107
|
+
wrapper.stopHealthHeartbeat();
|
|
108
|
+
}
|
|
109
|
+
process.exit(0);
|
|
188
110
|
};
|
|
189
111
|
|
|
190
112
|
process.on('SIGTERM', shutdown);
|
|
191
113
|
process.on('SIGINT', shutdown);
|
|
192
114
|
|
|
193
|
-
return { wrapper
|
|
115
|
+
return { wrapper };
|
|
194
116
|
}
|
|
195
117
|
|
|
196
118
|
// v3 invocation-model components (RFC §5.9): exported for biz services
|
|
@@ -225,4 +147,4 @@ module.exports.ErrorMapper = ErrorMapper;
|
|
|
225
147
|
module.exports.ValidationError = ValidationError;
|
|
226
148
|
module.exports.UnknownOperationError = UnknownOperationError;
|
|
227
149
|
module.exports.BusinessError = BusinessError;
|
|
228
|
-
module.exports.AbortError = AbortError;
|
|
150
|
+
module.exports.AbortError = AbortError;
|