@benzenejs/mesh-aggregator 0.0.0-stage → 0.1.0-beta.2
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/dist/ArtifactStoreMeshReportPublisher.d.ts +18 -0
- package/dist/ArtifactStoreMeshReportPublisher.js +25 -0
- package/dist/AsyncApiCompositor.d.ts +21 -0
- package/dist/AsyncApiCompositor.js +285 -0
- package/dist/Extensions.d.ts +19 -0
- package/dist/Extensions.js +56 -0
- package/dist/FileSystemMeshArtifactStore.d.ts +25 -0
- package/dist/FileSystemMeshArtifactStore.js +62 -0
- package/dist/HttpMeshServiceSource.d.ts +37 -0
- package/dist/HttpMeshServiceSource.js +84 -0
- package/dist/IMeshArtifactStore.d.ts +25 -0
- package/dist/IMeshArtifactStore.js +7 -0
- package/dist/IMeshServiceSource.d.ts +44 -0
- package/dist/IMeshServiceSource.js +7 -0
- package/dist/MeshAggregateMessageHandler.d.ts +20 -0
- package/dist/MeshAggregateMessageHandler.js +26 -0
- package/dist/MeshAggregator.d.ts +67 -0
- package/dist/MeshAggregator.js +766 -0
- package/dist/MeshAnnotationPublisher.d.ts +36 -0
- package/dist/MeshAnnotationPublisher.js +83 -0
- package/dist/MeshAnnotationsMessageHandler.d.ts +26 -0
- package/dist/MeshAnnotationsMessageHandler.js +50 -0
- package/dist/MeshReportMessageHandler.d.ts +15 -0
- package/dist/MeshReportMessageHandler.js +20 -0
- package/dist/MeshSnapshotBuilder.d.ts +24 -0
- package/dist/MeshSnapshotBuilder.js +55 -0
- package/dist/UnknownMeshServiceSource.d.ts +17 -0
- package/dist/UnknownMeshServiceSource.js +27 -0
- package/dist/index.d.ts +22 -0
- package/dist/index.js +38 -0
- package/package.json +19 -4
- package/README.md +0 -3
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/** Port of Benzene.Mesh.Aggregator.ArtifactStoreMeshReportPublisher. */
|
|
2
|
+
import { IMeshReportPublisher, MeshServiceReport } from '@benzenejs/mesh-contracts';
|
|
3
|
+
import { IMeshArtifactStore } from './IMeshArtifactStore';
|
|
4
|
+
/**
|
|
5
|
+
* The direct-write `IMeshReportPublisher` - turns a self-reported `MeshServiceReport` into a full
|
|
6
|
+
* `MeshServiceSnapshot` (via the same `MeshSnapshotBuilder` a pulled fetch uses, so both compute
|
|
7
|
+
* `MeshServiceSnapshot.contractDrift` identically) and writes it straight into the shared
|
|
8
|
+
* `IMeshArtifactStore` - the same `services/{name}.json` path `MeshAggregator` itself writes to.
|
|
9
|
+
*
|
|
10
|
+
* Fits a reporter colocated with the aggregator's own storage (e.g. sharing a mounted volume in a Docker
|
|
11
|
+
* Compose deployment). A reporter that isn't colocated should use `@benzenejs/mesh-reporting`'s
|
|
12
|
+
* `HttpMeshReportPublisher` instead. `JsonSerializerOptions` (camelCase, indented) -> `JSON.stringify(x, null, 2)`.
|
|
13
|
+
*/
|
|
14
|
+
export declare class ArtifactStoreMeshReportPublisher implements IMeshReportPublisher {
|
|
15
|
+
private readonly store;
|
|
16
|
+
constructor(store: IMeshArtifactStore);
|
|
17
|
+
publishAsync(report: MeshServiceReport): Promise<void>;
|
|
18
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.ArtifactStoreMeshReportPublisher = void 0;
|
|
4
|
+
const MeshSnapshotBuilder_1 = require("./MeshSnapshotBuilder");
|
|
5
|
+
/**
|
|
6
|
+
* The direct-write `IMeshReportPublisher` - turns a self-reported `MeshServiceReport` into a full
|
|
7
|
+
* `MeshServiceSnapshot` (via the same `MeshSnapshotBuilder` a pulled fetch uses, so both compute
|
|
8
|
+
* `MeshServiceSnapshot.contractDrift` identically) and writes it straight into the shared
|
|
9
|
+
* `IMeshArtifactStore` - the same `services/{name}.json` path `MeshAggregator` itself writes to.
|
|
10
|
+
*
|
|
11
|
+
* Fits a reporter colocated with the aggregator's own storage (e.g. sharing a mounted volume in a Docker
|
|
12
|
+
* Compose deployment). A reporter that isn't colocated should use `@benzenejs/mesh-reporting`'s
|
|
13
|
+
* `HttpMeshReportPublisher` instead. `JsonSerializerOptions` (camelCase, indented) -> `JSON.stringify(x, null, 2)`.
|
|
14
|
+
*/
|
|
15
|
+
class ArtifactStoreMeshReportPublisher {
|
|
16
|
+
store;
|
|
17
|
+
constructor(store) {
|
|
18
|
+
this.store = store;
|
|
19
|
+
}
|
|
20
|
+
async publishAsync(report) {
|
|
21
|
+
const snapshot = await MeshSnapshotBuilder_1.MeshSnapshotBuilder.buildAsync(this.store, report.name, report.reportedAtUtc, report.specJson, report.health, report.error);
|
|
22
|
+
await this.store.publishAsync(`services/${report.name}.json`, JSON.stringify(snapshot, null, 2));
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
exports.ArtifactStoreMeshReportPublisher = ArtifactStoreMeshReportPublisher;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/** One service's fetched AsyncAPI doc plus what the merge needs to namespace/filter it. */
|
|
2
|
+
export interface ServiceDocument {
|
|
3
|
+
/** The service name - the namespace discriminator (channels/operations/schemas/tags). */
|
|
4
|
+
readonly serviceName: string;
|
|
5
|
+
/** The service's raw AsyncAPI 3.0 JSON. */
|
|
6
|
+
readonly asyncApiJson: string;
|
|
7
|
+
/**
|
|
8
|
+
* Extra reserved (utility) topic ids whose channels/operations are skipped. Every `benzene:`-prefixed topic
|
|
9
|
+
* is skipped regardless (`BenzeneTopic.isReserved`): a Contract Document no longer lists reserved topics,
|
|
10
|
+
* so the prefix is the rule.
|
|
11
|
+
*/
|
|
12
|
+
readonly reservedTopics: ReadonlySet<string>;
|
|
13
|
+
}
|
|
14
|
+
export declare const AsyncApiCompositor: {
|
|
15
|
+
/**
|
|
16
|
+
* Merges the given services' AsyncAPI 3.0 docs into one composite AsyncAPI 3.0 JSON document. A service
|
|
17
|
+
* whose doc is missing/unparseable is skipped (best-effort); an empty input still produces a valid empty
|
|
18
|
+
* document, so a published `asyncapi.json` is always loadable. `generatedAt` is epoch ms.
|
|
19
|
+
*/
|
|
20
|
+
merge(services: readonly ServiceDocument[], generatedAt: number): string;
|
|
21
|
+
};
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.AsyncApiCompositor = void 0;
|
|
4
|
+
/** Port of Benzene.Mesh.Aggregator.AsyncApiCompositor. */
|
|
5
|
+
const abstractions_1 = require("@benzenejs/abstractions");
|
|
6
|
+
const SchemaRefPrefix = '#/components/schemas/';
|
|
7
|
+
const ChannelRefPrefix = '#/channels/';
|
|
8
|
+
function isObject(value) {
|
|
9
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
10
|
+
}
|
|
11
|
+
function isArray(value) {
|
|
12
|
+
return Array.isArray(value);
|
|
13
|
+
}
|
|
14
|
+
exports.AsyncApiCompositor = {
|
|
15
|
+
/**
|
|
16
|
+
* Merges the given services' AsyncAPI 3.0 docs into one composite AsyncAPI 3.0 JSON document. A service
|
|
17
|
+
* whose doc is missing/unparseable is skipped (best-effort); an empty input still produces a valid empty
|
|
18
|
+
* document, so a published `asyncapi.json` is always loadable. `generatedAt` is epoch ms.
|
|
19
|
+
*/
|
|
20
|
+
merge(services, generatedAt) {
|
|
21
|
+
const channels = {};
|
|
22
|
+
const operations = {};
|
|
23
|
+
const schemas = {};
|
|
24
|
+
const tags = [];
|
|
25
|
+
const usedOperationKeys = new Set();
|
|
26
|
+
let merged = 0;
|
|
27
|
+
for (const service of services) {
|
|
28
|
+
const doc = tryParse(service.asyncApiJson);
|
|
29
|
+
if (doc === undefined) {
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
merged++;
|
|
33
|
+
tags.push({ name: service.serviceName });
|
|
34
|
+
const ns = slug(service.serviceName);
|
|
35
|
+
// 1) Namespace + move component schemas (rewriting every schema $ref across the doc first, so
|
|
36
|
+
// channel-message payload refs and nested inter-schema refs all point at the new keys).
|
|
37
|
+
const schemaRenames = new Map();
|
|
38
|
+
if (isObject(doc.components) && isObject(doc.components.schemas)) {
|
|
39
|
+
for (const key of Object.keys(doc.components.schemas)) {
|
|
40
|
+
schemaRenames.set(key, schemaKey(service.serviceName, key));
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
rewriteSchemaRefs(doc, schemaRenames);
|
|
44
|
+
if (isObject(doc.components) && isObject(doc.components.schemas)) {
|
|
45
|
+
const serviceSchemas = doc.components.schemas;
|
|
46
|
+
for (const [key, value] of Object.entries(serviceSchemas)) {
|
|
47
|
+
schemas[schemaRenames.get(key)] = structuredClone(value);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
// 2) Map ALL channel keys old->new (namespaced), capturing each channel's address and a clone.
|
|
51
|
+
// Channels are emitted below only if a surviving (non-reserved) operation references them - so
|
|
52
|
+
// reserved request channels AND their reply channels fall away regardless of the reply-suffix a
|
|
53
|
+
// service happens to use.
|
|
54
|
+
const channelKeyMap = new Map();
|
|
55
|
+
const channelAddress = new Map();
|
|
56
|
+
const clonedChannels = new Map();
|
|
57
|
+
if (isObject(doc.channels)) {
|
|
58
|
+
for (const [key, value] of Object.entries(doc.channels)) {
|
|
59
|
+
if (!isObject(value)) {
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
const newKey = `${ns}_${key}`;
|
|
63
|
+
channelKeyMap.set(key, newKey);
|
|
64
|
+
channelAddress.set(key, typeof value.address === 'string' ? value.address : key);
|
|
65
|
+
clonedChannels.set(newKey, structuredClone(value));
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
// 3) Namespace operations, dropping any whose channel is a reserved (utility) topic, and rewriting
|
|
69
|
+
// their channel/message/reply $refs onto the namespaced channel keys. An operation's primary
|
|
70
|
+
// channel address IS the topic id, so reserved detection needs no suffix knowledge.
|
|
71
|
+
const referencedChannelKeys = new Set();
|
|
72
|
+
if (isObject(doc.operations)) {
|
|
73
|
+
for (const [key, value] of Object.entries(doc.operations)) {
|
|
74
|
+
if (!isObject(value)) {
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
const operationObj = structuredClone(value);
|
|
78
|
+
const primaryKey = channelKeyOf(operationObj.channel);
|
|
79
|
+
if (primaryKey === undefined ||
|
|
80
|
+
!channelKeyMap.has(primaryKey) ||
|
|
81
|
+
service.reservedTopics.has(channelAddress.get(primaryKey)) ||
|
|
82
|
+
abstractions_1.BenzeneTopic.isReserved(channelAddress.get(primaryKey))) {
|
|
83
|
+
continue; // no resolvable channel, or a reserved/utility operation - drop it
|
|
84
|
+
}
|
|
85
|
+
rewriteChannelRef(operationObj.channel, channelKeyMap);
|
|
86
|
+
rewriteMessageRefs(operationObj.messages, channelKeyMap);
|
|
87
|
+
referencedChannelKeys.add(channelKeyMap.get(primaryKey));
|
|
88
|
+
if (isObject(operationObj.reply)) {
|
|
89
|
+
const reply = operationObj.reply;
|
|
90
|
+
const replyKey = channelKeyOf(reply.channel);
|
|
91
|
+
if (replyKey !== undefined && channelKeyMap.has(replyKey)) {
|
|
92
|
+
rewriteChannelRef(reply.channel, channelKeyMap);
|
|
93
|
+
rewriteMessageRefs(reply.messages, channelKeyMap);
|
|
94
|
+
referencedChannelKeys.add(channelKeyMap.get(replyKey));
|
|
95
|
+
}
|
|
96
|
+
else {
|
|
97
|
+
delete operationObj.reply;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
tagOperation(operationObj, service.serviceName);
|
|
101
|
+
operations[uniqueKey(`${ns}_${key}`, usedOperationKeys)] = operationObj;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
// 4) Emit only the channels a surviving operation references.
|
|
105
|
+
for (const referenced of referencedChannelKeys) {
|
|
106
|
+
const channelObj = clonedChannels.get(referenced);
|
|
107
|
+
if (channelObj !== undefined) {
|
|
108
|
+
channels[referenced] = channelObj;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
// Reserved-channel drops can orphan the schemas only those channels' messages referenced; drop any
|
|
113
|
+
// component schema not reachable from a retained channel (validators flag unused components).
|
|
114
|
+
pruneUnusedSchemas(channels, schemas);
|
|
115
|
+
const document = {
|
|
116
|
+
asyncapi: '3.0.0',
|
|
117
|
+
id: 'urn:benzene:mesh:composite',
|
|
118
|
+
defaultContentType: 'application/json',
|
|
119
|
+
info: {
|
|
120
|
+
title: 'Benzene Mesh — Composite AsyncAPI',
|
|
121
|
+
version: new Date(generatedAt).toISOString(),
|
|
122
|
+
description: `Composite of ${merged} service(s)' AsyncAPI specs across the mesh, generated by Benzene Mesh.`,
|
|
123
|
+
tags,
|
|
124
|
+
},
|
|
125
|
+
channels,
|
|
126
|
+
operations,
|
|
127
|
+
components: { schemas },
|
|
128
|
+
};
|
|
129
|
+
return JSON.stringify(document, null, 2);
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
function tryParse(json) {
|
|
133
|
+
if (json.trim().length === 0) {
|
|
134
|
+
return undefined;
|
|
135
|
+
}
|
|
136
|
+
try {
|
|
137
|
+
const parsed = JSON.parse(json);
|
|
138
|
+
return isObject(parsed) ? parsed : undefined;
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
return undefined;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
function channelKeyOf(channelRefNode) {
|
|
145
|
+
if (isObject(channelRefNode) && typeof channelRefNode.$ref === 'string' && channelRefNode.$ref.startsWith(ChannelRefPrefix)) {
|
|
146
|
+
const rest = channelRefNode.$ref.substring(ChannelRefPrefix.length);
|
|
147
|
+
const slash = rest.indexOf('/');
|
|
148
|
+
return slash < 0 ? rest : rest.substring(0, slash);
|
|
149
|
+
}
|
|
150
|
+
return undefined;
|
|
151
|
+
}
|
|
152
|
+
function rewriteChannelRef(channelRefNode, channelKeyMap) {
|
|
153
|
+
if (isObject(channelRefNode) && typeof channelRefNode.$ref === 'string') {
|
|
154
|
+
channelRefNode.$ref = rewritePointer(channelRefNode.$ref, channelKeyMap);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
function rewriteMessageRefs(messagesNode, channelKeyMap) {
|
|
158
|
+
if (!isArray(messagesNode)) {
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
for (const message of messagesNode) {
|
|
162
|
+
if (isObject(message) && typeof message.$ref === 'string') {
|
|
163
|
+
message.$ref = rewritePointer(message.$ref, channelKeyMap);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
/** Rewrites the channel-id segment of a `#/channels/<key>[/messages/<msg>]` pointer via the map. */
|
|
168
|
+
function rewritePointer(reference, channelKeyMap) {
|
|
169
|
+
if (!reference.startsWith(ChannelRefPrefix)) {
|
|
170
|
+
return reference;
|
|
171
|
+
}
|
|
172
|
+
const rest = reference.substring(ChannelRefPrefix.length);
|
|
173
|
+
const slash = rest.indexOf('/');
|
|
174
|
+
const oldKey = slash < 0 ? rest : rest.substring(0, slash);
|
|
175
|
+
const tail = slash < 0 ? '' : rest.substring(slash);
|
|
176
|
+
const newKey = channelKeyMap.get(oldKey);
|
|
177
|
+
return newKey !== undefined ? ChannelRefPrefix + newKey + tail : reference;
|
|
178
|
+
}
|
|
179
|
+
function tagOperation(operation, serviceName) {
|
|
180
|
+
let tags = operation.tags;
|
|
181
|
+
if (!isArray(tags)) {
|
|
182
|
+
tags = [];
|
|
183
|
+
operation.tags = tags;
|
|
184
|
+
}
|
|
185
|
+
tags.push({ name: serviceName });
|
|
186
|
+
}
|
|
187
|
+
function uniqueKey(candidate, used) {
|
|
188
|
+
let unique = candidate;
|
|
189
|
+
let suffix = 2;
|
|
190
|
+
while (used.has(unique)) {
|
|
191
|
+
unique = `${candidate}_${suffix++}`;
|
|
192
|
+
}
|
|
193
|
+
used.add(unique);
|
|
194
|
+
return unique;
|
|
195
|
+
}
|
|
196
|
+
/** Removes component schemas not reachable from any retained channel (following $refs transitively). */
|
|
197
|
+
function pruneUnusedSchemas(channels, schemas) {
|
|
198
|
+
const reachable = new Set();
|
|
199
|
+
const queue = [];
|
|
200
|
+
for (const name of findSchemaRefNames(channels)) {
|
|
201
|
+
if (!reachable.has(name)) {
|
|
202
|
+
reachable.add(name);
|
|
203
|
+
queue.push(name);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
while (queue.length > 0) {
|
|
207
|
+
const name = queue.shift();
|
|
208
|
+
const schema = schemas[name];
|
|
209
|
+
if (schema === undefined) {
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
for (const nested of findSchemaRefNames(schema)) {
|
|
213
|
+
if (!reachable.has(nested)) {
|
|
214
|
+
reachable.add(nested);
|
|
215
|
+
queue.push(nested);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
for (const key of Object.keys(schemas)) {
|
|
220
|
+
if (!reachable.has(key)) {
|
|
221
|
+
delete schemas[key];
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
function findSchemaRefNames(node) {
|
|
226
|
+
const results = [];
|
|
227
|
+
walk(node, results);
|
|
228
|
+
return results;
|
|
229
|
+
function walk(n, into) {
|
|
230
|
+
if (isObject(n)) {
|
|
231
|
+
if (typeof n.$ref === 'string' && n.$ref.startsWith(SchemaRefPrefix)) {
|
|
232
|
+
into.push(n.$ref.substring(SchemaRefPrefix.length));
|
|
233
|
+
}
|
|
234
|
+
for (const [key, value] of Object.entries(n)) {
|
|
235
|
+
if (key !== '$ref') {
|
|
236
|
+
walk(value, into);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
else if (isArray(n)) {
|
|
241
|
+
for (const item of n) {
|
|
242
|
+
walk(item, into);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
function rewriteSchemaRefs(node, renames) {
|
|
248
|
+
if (isObject(node)) {
|
|
249
|
+
if (typeof node.$ref === 'string' && node.$ref.startsWith(SchemaRefPrefix)) {
|
|
250
|
+
const name = node.$ref.substring(SchemaRefPrefix.length);
|
|
251
|
+
const renamed = renames.get(name);
|
|
252
|
+
if (renamed !== undefined) {
|
|
253
|
+
node.$ref = SchemaRefPrefix + renamed;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
for (const [key, value] of Object.entries(node)) {
|
|
257
|
+
if (key !== '$ref') {
|
|
258
|
+
rewriteSchemaRefs(value, renames);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
else if (isArray(node)) {
|
|
263
|
+
for (const item of node) {
|
|
264
|
+
rewriteSchemaRefs(item, renames);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
function schemaKey(serviceName, typeName) {
|
|
269
|
+
const prefix = slug(serviceName)
|
|
270
|
+
.split('-')
|
|
271
|
+
.filter((part) => part.length > 0)
|
|
272
|
+
.map((part) => part.charAt(0).toUpperCase() + part.substring(1))
|
|
273
|
+
.join('');
|
|
274
|
+
return prefix.length === 0 ? typeName : prefix + '_' + typeName;
|
|
275
|
+
}
|
|
276
|
+
function slug(value) {
|
|
277
|
+
let lowered = Array.from((value ?? '').trim().toLowerCase())
|
|
278
|
+
.map((c) => (/[\p{L}\p{N}]/u.test(c) ? c : '-'))
|
|
279
|
+
.join('');
|
|
280
|
+
while (lowered.includes('--')) {
|
|
281
|
+
lowered = lowered.replace(/--/g, '-');
|
|
282
|
+
}
|
|
283
|
+
lowered = lowered.replace(/^-+/, '').replace(/-+$/, '');
|
|
284
|
+
return lowered.length === 0 ? 'service' : lowered;
|
|
285
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/** Port of Benzene.Mesh.Aggregator.Extensions. */
|
|
2
|
+
import { IBenzeneServiceContainer, IServiceResolver } from '@benzenejs/abstractions';
|
|
3
|
+
import { MeshServiceRegistry } from '@benzenejs/mesh-contracts';
|
|
4
|
+
import { IMeshArtifactStore } from './IMeshArtifactStore';
|
|
5
|
+
/**
|
|
6
|
+
* Registers `MeshAggregator` (backed by a `FileSystemMeshArtifactStore` when given a directory string, or a
|
|
7
|
+
* caller-supplied `IMeshArtifactStore` factory for a blob-storage adapter) against the given service
|
|
8
|
+
* registry, plus the three mesh message handlers and their HTTP endpoints. C# extension method + two
|
|
9
|
+
* overloads -> one free function whose third argument is a directory string or a store factory.
|
|
10
|
+
*
|
|
11
|
+
* Unlike the C# `AddMeshAggregator` (which registers only the infra and leaves handler *discovery* to the
|
|
12
|
+
* app's assembly-scanning `.AddMessageHandlers()`), the port also registers the handler definitions,
|
|
13
|
+
* endpoints and scoped factories here: JS has no assembly scan or constructor-parameter reflection, so a
|
|
14
|
+
* handler with dependencies can't be discovered or activated without an explicit factory (the same
|
|
15
|
+
* container-registration convention `@benzenejs/mesh-dispatch` uses). The default `HttpMeshServiceSource` and
|
|
16
|
+
* `ArtifactStoreMeshReportPublisher` are registered too; other adapter packages add their own
|
|
17
|
+
* `IMeshServiceSource`/`IMeshReportPublisher` alongside.
|
|
18
|
+
*/
|
|
19
|
+
export declare function addMeshAggregator(services: IBenzeneServiceContainer, registry: MeshServiceRegistry, artifactStore: string | ((resolver: IServiceResolver) => IMeshArtifactStore)): IBenzeneServiceContainer;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.addMeshAggregator = addMeshAggregator;
|
|
4
|
+
/** Port of Benzene.Mesh.Aggregator.Extensions. */
|
|
5
|
+
const abstractions_1 = require("@benzenejs/abstractions");
|
|
6
|
+
const abstractions_message_handlers_1 = require("@benzenejs/abstractions-message-handlers");
|
|
7
|
+
const core_message_handlers_1 = require("@benzenejs/core-message-handlers");
|
|
8
|
+
const http_1 = require("@benzenejs/http");
|
|
9
|
+
const mesh_contracts_1 = require("@benzenejs/mesh-contracts");
|
|
10
|
+
const ArtifactStoreMeshReportPublisher_1 = require("./ArtifactStoreMeshReportPublisher");
|
|
11
|
+
const FileSystemMeshArtifactStore_1 = require("./FileSystemMeshArtifactStore");
|
|
12
|
+
const HttpMeshServiceSource_1 = require("./HttpMeshServiceSource");
|
|
13
|
+
const IMeshArtifactStore_1 = require("./IMeshArtifactStore");
|
|
14
|
+
const IMeshServiceSource_1 = require("./IMeshServiceSource");
|
|
15
|
+
const MeshAggregateMessageHandler_1 = require("./MeshAggregateMessageHandler");
|
|
16
|
+
const MeshAggregator_1 = require("./MeshAggregator");
|
|
17
|
+
const MeshAnnotationPublisher_1 = require("./MeshAnnotationPublisher");
|
|
18
|
+
const MeshAnnotationsMessageHandler_1 = require("./MeshAnnotationsMessageHandler");
|
|
19
|
+
const MeshReportMessageHandler_1 = require("./MeshReportMessageHandler");
|
|
20
|
+
/**
|
|
21
|
+
* Registers `MeshAggregator` (backed by a `FileSystemMeshArtifactStore` when given a directory string, or a
|
|
22
|
+
* caller-supplied `IMeshArtifactStore` factory for a blob-storage adapter) against the given service
|
|
23
|
+
* registry, plus the three mesh message handlers and their HTTP endpoints. C# extension method + two
|
|
24
|
+
* overloads -> one free function whose third argument is a directory string or a store factory.
|
|
25
|
+
*
|
|
26
|
+
* Unlike the C# `AddMeshAggregator` (which registers only the infra and leaves handler *discovery* to the
|
|
27
|
+
* app's assembly-scanning `.AddMessageHandlers()`), the port also registers the handler definitions,
|
|
28
|
+
* endpoints and scoped factories here: JS has no assembly scan or constructor-parameter reflection, so a
|
|
29
|
+
* handler with dependencies can't be discovered or activated without an explicit factory (the same
|
|
30
|
+
* container-registration convention `@benzenejs/mesh-dispatch` uses). The default `HttpMeshServiceSource` and
|
|
31
|
+
* `ArtifactStoreMeshReportPublisher` are registered too; other adapter packages add their own
|
|
32
|
+
* `IMeshServiceSource`/`IMeshReportPublisher` alongside.
|
|
33
|
+
*/
|
|
34
|
+
function addMeshAggregator(services, registry, artifactStore) {
|
|
35
|
+
const storeFactory = typeof artifactStore === 'string'
|
|
36
|
+
? () => new FileSystemMeshArtifactStore_1.FileSystemMeshArtifactStore(artifactStore)
|
|
37
|
+
: artifactStore;
|
|
38
|
+
services.addSingletonInstance(mesh_contracts_1.MeshServiceRegistry, registry);
|
|
39
|
+
services.addSingletonFactory(IMeshArtifactStore_1.IMeshArtifactStore, (resolver) => storeFactory(resolver));
|
|
40
|
+
// The default IMeshServiceSource - other adapter packages add their own IMeshServiceSource registration
|
|
41
|
+
// alongside this one; MeshAggregator resolves all of them via getServices(IMeshServiceSource), keyed by key.
|
|
42
|
+
services.addSingletonFactory(IMeshServiceSource_1.IMeshServiceSource, () => new HttpMeshServiceSource_1.HttpMeshServiceSource());
|
|
43
|
+
// The default push-path IMeshReportPublisher - MeshReportMessageHandler resolves this.
|
|
44
|
+
services.addSingletonFactory(mesh_contracts_1.IMeshReportPublisher, (resolver) => new ArtifactStoreMeshReportPublisher_1.ArtifactStoreMeshReportPublisher(resolver.getService(IMeshArtifactStore_1.IMeshArtifactStore)));
|
|
45
|
+
services.addSingletonFactory(MeshAnnotationPublisher_1.MeshAnnotationPublisher, (resolver) => new MeshAnnotationPublisher_1.MeshAnnotationPublisher(resolver.getService(IMeshArtifactStore_1.IMeshArtifactStore)));
|
|
46
|
+
services.addSingletonFactory(MeshAggregator_1.MeshAggregator, (resolver) => new MeshAggregator_1.MeshAggregator(resolver.getServices(IMeshServiceSource_1.IMeshServiceSource), resolver.getService(IMeshArtifactStore_1.IMeshArtifactStore), undefined, resolver.getServices(mesh_contracts_1.IMeshUsageSource)));
|
|
47
|
+
registerHandler(services, 'benzene:mesh:aggregate', 'POST', '/mesh/aggregate', abstractions_1.VoidResult, mesh_contracts_1.MeshManifest, MeshAggregateMessageHandler_1.MeshAggregateMessageHandler, (resolver) => new MeshAggregateMessageHandler_1.MeshAggregateMessageHandler(resolver.getService(MeshAggregator_1.MeshAggregator), resolver.getService(mesh_contracts_1.MeshServiceRegistry)));
|
|
48
|
+
registerHandler(services, 'benzene:mesh:report', 'POST', '/mesh/report', mesh_contracts_1.MeshServiceReport, abstractions_1.VoidResult, MeshReportMessageHandler_1.MeshReportMessageHandler, (resolver) => new MeshReportMessageHandler_1.MeshReportMessageHandler(resolver.getService(mesh_contracts_1.IMeshReportPublisher)));
|
|
49
|
+
registerHandler(services, 'benzene:mesh:annotations:add', 'POST', '/mesh/annotations', mesh_contracts_1.MeshAnnotationRequest, mesh_contracts_1.MeshAnnotationThread, MeshAnnotationsMessageHandler_1.MeshAnnotationsMessageHandler, (resolver) => new MeshAnnotationsMessageHandler_1.MeshAnnotationsMessageHandler(resolver.getService(MeshAnnotationPublisher_1.MeshAnnotationPublisher)));
|
|
50
|
+
return services;
|
|
51
|
+
}
|
|
52
|
+
function registerHandler(services, topic, method, path, requestType, responseType, handlerType, factory) {
|
|
53
|
+
services.addScopedFactory(handlerType, factory);
|
|
54
|
+
services.addSingletonInstance(abstractions_message_handlers_1.IMessageHandlerDefinition, core_message_handlers_1.MessageHandlerDefinition.createInstance(topic, '', requestType, responseType, handlerType));
|
|
55
|
+
services.addSingletonInstance(http_1.IHttpEndpointDefinition, http_1.HttpEndpointDefinition.createInstance(method, path, topic));
|
|
56
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { IMeshArtifactStore } from './IMeshArtifactStore';
|
|
2
|
+
/**
|
|
3
|
+
* An `IMeshArtifactStore` that publishes artifacts to a directory on local disk.
|
|
4
|
+
*
|
|
5
|
+
* `File.WriteAllTextAsync`/`ReadAllTextAsync`/`Directory.CreateDirectory` -> `node:fs/promises`;
|
|
6
|
+
* `Path.Combine`/`Path.GetFullPath` -> `node:path`'s `resolve`. A missing file on read surfaces as
|
|
7
|
+
* `ENOENT`, mapped to `undefined` (C# `File.Exists` check).
|
|
8
|
+
*/
|
|
9
|
+
export declare class FileSystemMeshArtifactStore implements IMeshArtifactStore {
|
|
10
|
+
private readonly rootDirectory;
|
|
11
|
+
/**
|
|
12
|
+
* @param rootDirectory The directory artifacts are written under. Created on first write if it doesn't exist.
|
|
13
|
+
*/
|
|
14
|
+
constructor(rootDirectory: string);
|
|
15
|
+
publishAsync(relativePath: string, content: string): Promise<void>;
|
|
16
|
+
tryReadAsync(relativePath: string): Promise<string | undefined>;
|
|
17
|
+
/**
|
|
18
|
+
* Resolves `relativePath` against the store root and asserts the result stays inside it. The relative
|
|
19
|
+
* path can carry a service name that originated in an untrusted push report (`services/{report.name}.json`),
|
|
20
|
+
* so a value like `"../../etc/passwd"` or a rooted path would otherwise let path-combination escape the
|
|
21
|
+
* root and read or overwrite an arbitrary file. Resolving to a full path and checking containment closes
|
|
22
|
+
* that traversal at the storage boundary, protecting every caller.
|
|
23
|
+
*/
|
|
24
|
+
private resolveWithinRoot;
|
|
25
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.FileSystemMeshArtifactStore = void 0;
|
|
4
|
+
/** Port of Benzene.Mesh.Aggregator.FileSystemMeshArtifactStore. */
|
|
5
|
+
const promises_1 = require("node:fs/promises");
|
|
6
|
+
const node_path_1 = require("node:path");
|
|
7
|
+
/**
|
|
8
|
+
* An `IMeshArtifactStore` that publishes artifacts to a directory on local disk.
|
|
9
|
+
*
|
|
10
|
+
* `File.WriteAllTextAsync`/`ReadAllTextAsync`/`Directory.CreateDirectory` -> `node:fs/promises`;
|
|
11
|
+
* `Path.Combine`/`Path.GetFullPath` -> `node:path`'s `resolve`. A missing file on read surfaces as
|
|
12
|
+
* `ENOENT`, mapped to `undefined` (C# `File.Exists` check).
|
|
13
|
+
*/
|
|
14
|
+
class FileSystemMeshArtifactStore {
|
|
15
|
+
rootDirectory;
|
|
16
|
+
/**
|
|
17
|
+
* @param rootDirectory The directory artifacts are written under. Created on first write if it doesn't exist.
|
|
18
|
+
*/
|
|
19
|
+
constructor(rootDirectory) {
|
|
20
|
+
this.rootDirectory = rootDirectory;
|
|
21
|
+
}
|
|
22
|
+
async publishAsync(relativePath, content) {
|
|
23
|
+
const fullPath = this.resolveWithinRoot(relativePath);
|
|
24
|
+
const directory = (0, node_path_1.dirname)(fullPath);
|
|
25
|
+
if (directory.length > 0) {
|
|
26
|
+
await (0, promises_1.mkdir)(directory, { recursive: true });
|
|
27
|
+
}
|
|
28
|
+
await (0, promises_1.writeFile)(fullPath, content, 'utf8');
|
|
29
|
+
}
|
|
30
|
+
async tryReadAsync(relativePath) {
|
|
31
|
+
const fullPath = this.resolveWithinRoot(relativePath);
|
|
32
|
+
try {
|
|
33
|
+
return await (0, promises_1.readFile)(fullPath, 'utf8');
|
|
34
|
+
}
|
|
35
|
+
catch (ex) {
|
|
36
|
+
if (isNotFound(ex)) {
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
39
|
+
throw ex;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Resolves `relativePath` against the store root and asserts the result stays inside it. The relative
|
|
44
|
+
* path can carry a service name that originated in an untrusted push report (`services/{report.name}.json`),
|
|
45
|
+
* so a value like `"../../etc/passwd"` or a rooted path would otherwise let path-combination escape the
|
|
46
|
+
* root and read or overwrite an arbitrary file. Resolving to a full path and checking containment closes
|
|
47
|
+
* that traversal at the storage boundary, protecting every caller.
|
|
48
|
+
*/
|
|
49
|
+
resolveWithinRoot(relativePath) {
|
|
50
|
+
const rootFull = (0, node_path_1.resolve)(this.rootDirectory);
|
|
51
|
+
const combined = (0, node_path_1.resolve)(rootFull, relativePath);
|
|
52
|
+
const rootWithSeparator = rootFull.endsWith(node_path_1.sep) ? rootFull : rootFull + node_path_1.sep;
|
|
53
|
+
if (combined !== rootFull && !combined.startsWith(rootWithSeparator)) {
|
|
54
|
+
throw new Error(`The artifact path '${relativePath}' resolves outside the store root and was rejected.`);
|
|
55
|
+
}
|
|
56
|
+
return combined;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
exports.FileSystemMeshArtifactStore = FileSystemMeshArtifactStore;
|
|
60
|
+
function isNotFound(ex) {
|
|
61
|
+
return typeof ex === 'object' && ex !== null && ex.code === 'ENOENT';
|
|
62
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/** Port of Benzene.Mesh.Aggregator.HttpMeshServiceSource. */
|
|
2
|
+
import { MeshServiceRegistryEntry } from '@benzenejs/mesh-contracts';
|
|
3
|
+
import { IMeshServiceSource } from './IMeshServiceSource';
|
|
4
|
+
/** A `fetch`-like function - the port of C# `HttpClient` (`HttpClient` -> injectable `fetch`). */
|
|
5
|
+
export type ServiceFetch = (url: string, init?: RequestInit) => Promise<Response>;
|
|
6
|
+
/**
|
|
7
|
+
* The port of .NET's `HttpRequestException` - thrown by {@link HttpMeshServiceSource.fetchSpecAsync} on a
|
|
8
|
+
* non-2xx response (`GetStringAsync`'s `EnsureSuccessStatusCode`). `MeshAggregator` records only its
|
|
9
|
+
* `name` ("HttpRequestException"), never the message/body, so a failed fetch can't leak a service's payload.
|
|
10
|
+
*/
|
|
11
|
+
export declare class HttpRequestException extends Error {
|
|
12
|
+
constructor(message: string);
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* The default `IMeshServiceSource` - fetches `MeshServiceRegistryEntry.specUrl`/`healthUrl` over HTTP.
|
|
16
|
+
* This is `MeshAggregator`'s original (pre-`IMeshServiceSource`) behavior.
|
|
17
|
+
*
|
|
18
|
+
* `HttpClient` -> an injectable `fetch` (default global `fetch`, the `@benzenejs/health-checks-http` pattern).
|
|
19
|
+
* `GetStringAsync` throws on a non-2xx response, so the spec fetch throws when `!response.ok`; the health
|
|
20
|
+
* fetch deliberately reads the body regardless of status (see {@link fetchHealthAsync}).
|
|
21
|
+
*/
|
|
22
|
+
export declare class HttpMeshServiceSource implements IMeshServiceSource {
|
|
23
|
+
private readonly fetchFn;
|
|
24
|
+
constructor(fetchFn?: ServiceFetch);
|
|
25
|
+
get key(): string;
|
|
26
|
+
fetchSpecAsync(entry: MeshServiceRegistryEntry, cancellationToken?: AbortSignal): Promise<string>;
|
|
27
|
+
tryFetchSpecAsync(entry: MeshServiceRegistryEntry, specType: string, cancellationToken?: AbortSignal): Promise<string | undefined>;
|
|
28
|
+
fetchHealthAsync(entry: MeshServiceRegistryEntry, cancellationToken?: AbortSignal): Promise<string>;
|
|
29
|
+
/** Mirrors `HttpClient.GetStringAsync`: throws on a non-2xx response, otherwise returns the body text. */
|
|
30
|
+
private getStringAsync;
|
|
31
|
+
/**
|
|
32
|
+
* Derives an alternate-spec-type URL from the benzene `specUrl` by setting `type=<specType>&format=json`
|
|
33
|
+
* and preserving the rest of the URL - the benzene and asyncapi specs are the same `spec` endpoint
|
|
34
|
+
* differing only by `type`. Returns `undefined` for a blank spec URL.
|
|
35
|
+
*/
|
|
36
|
+
static specUrlForType(specUrl: string, specType: string): string | undefined;
|
|
37
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.HttpMeshServiceSource = exports.HttpRequestException = void 0;
|
|
4
|
+
/** Port of Benzene.Mesh.Aggregator.HttpMeshServiceSource. */
|
|
5
|
+
const mesh_contracts_1 = require("@benzenejs/mesh-contracts");
|
|
6
|
+
/**
|
|
7
|
+
* The port of .NET's `HttpRequestException` - thrown by {@link HttpMeshServiceSource.fetchSpecAsync} on a
|
|
8
|
+
* non-2xx response (`GetStringAsync`'s `EnsureSuccessStatusCode`). `MeshAggregator` records only its
|
|
9
|
+
* `name` ("HttpRequestException"), never the message/body, so a failed fetch can't leak a service's payload.
|
|
10
|
+
*/
|
|
11
|
+
class HttpRequestException extends Error {
|
|
12
|
+
constructor(message) {
|
|
13
|
+
super(message);
|
|
14
|
+
this.name = 'HttpRequestException';
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
exports.HttpRequestException = HttpRequestException;
|
|
18
|
+
/**
|
|
19
|
+
* The default `IMeshServiceSource` - fetches `MeshServiceRegistryEntry.specUrl`/`healthUrl` over HTTP.
|
|
20
|
+
* This is `MeshAggregator`'s original (pre-`IMeshServiceSource`) behavior.
|
|
21
|
+
*
|
|
22
|
+
* `HttpClient` -> an injectable `fetch` (default global `fetch`, the `@benzenejs/health-checks-http` pattern).
|
|
23
|
+
* `GetStringAsync` throws on a non-2xx response, so the spec fetch throws when `!response.ok`; the health
|
|
24
|
+
* fetch deliberately reads the body regardless of status (see {@link fetchHealthAsync}).
|
|
25
|
+
*/
|
|
26
|
+
class HttpMeshServiceSource {
|
|
27
|
+
fetchFn;
|
|
28
|
+
constructor(fetchFn = fetch) {
|
|
29
|
+
this.fetchFn = fetchFn;
|
|
30
|
+
}
|
|
31
|
+
get key() {
|
|
32
|
+
return mesh_contracts_1.MeshServiceSource.http;
|
|
33
|
+
}
|
|
34
|
+
async fetchSpecAsync(entry, cancellationToken) {
|
|
35
|
+
return this.getStringAsync(entry.specUrl, cancellationToken);
|
|
36
|
+
}
|
|
37
|
+
async tryFetchSpecAsync(entry, specType, cancellationToken) {
|
|
38
|
+
const url = HttpMeshServiceSource.specUrlForType(entry.specUrl, specType);
|
|
39
|
+
return url === undefined ? undefined : this.getStringAsync(url, cancellationToken);
|
|
40
|
+
}
|
|
41
|
+
async fetchHealthAsync(entry, cancellationToken) {
|
|
42
|
+
// Deliberately not throwing on a non-2xx status: Benzene's own health-check middleware maps an
|
|
43
|
+
// unhealthy aggregate result to HTTP 503, which a throw-on-error fetch would treat as a fetch
|
|
44
|
+
// failure indistinguishable from the service being genuinely unreachable. Reading the body
|
|
45
|
+
// regardless of status code lets a real 503-with-a-valid-unhealthy-body come through as Unhealthy
|
|
46
|
+
// instead of Unreachable - only a connection-level failure (fetch rejects) counts as unreachable.
|
|
47
|
+
const response = await this.fetchFn(entry.healthUrl, { signal: cancellationToken });
|
|
48
|
+
return response.text();
|
|
49
|
+
}
|
|
50
|
+
/** Mirrors `HttpClient.GetStringAsync`: throws on a non-2xx response, otherwise returns the body text. */
|
|
51
|
+
async getStringAsync(url, cancellationToken) {
|
|
52
|
+
const response = await this.fetchFn(url, { signal: cancellationToken });
|
|
53
|
+
if (!response.ok) {
|
|
54
|
+
throw new HttpRequestException(`GET ${url} failed with status ${response.status}.`);
|
|
55
|
+
}
|
|
56
|
+
return response.text();
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Derives an alternate-spec-type URL from the benzene `specUrl` by setting `type=<specType>&format=json`
|
|
60
|
+
* and preserving the rest of the URL - the benzene and asyncapi specs are the same `spec` endpoint
|
|
61
|
+
* differing only by `type`. Returns `undefined` for a blank spec URL.
|
|
62
|
+
*/
|
|
63
|
+
static specUrlForType(specUrl, specType) {
|
|
64
|
+
if (specUrl.trim().length === 0) {
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
67
|
+
const fragmentIndex = specUrl.indexOf('#');
|
|
68
|
+
const fragment = fragmentIndex >= 0 ? specUrl.substring(fragmentIndex) : '';
|
|
69
|
+
const withoutFragment = fragmentIndex >= 0 ? specUrl.substring(0, fragmentIndex) : specUrl;
|
|
70
|
+
const queryIndex = withoutFragment.indexOf('?');
|
|
71
|
+
const basePart = queryIndex >= 0 ? withoutFragment.substring(0, queryIndex) : withoutFragment;
|
|
72
|
+
const query = queryIndex >= 0 ? withoutFragment.substring(queryIndex + 1) : '';
|
|
73
|
+
// Keep any pre-existing query params except type/format, which we set ourselves.
|
|
74
|
+
const pairs = query
|
|
75
|
+
.split('&')
|
|
76
|
+
.filter((pair) => pair.length > 0 &&
|
|
77
|
+
!pair.toLowerCase().startsWith('type=') &&
|
|
78
|
+
!pair.toLowerCase().startsWith('format='));
|
|
79
|
+
pairs.unshift('format=json');
|
|
80
|
+
pairs.unshift('type=' + encodeURIComponent(specType));
|
|
81
|
+
return basePart + '?' + pairs.join('&') + fragment;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
exports.HttpMeshServiceSource = HttpMeshServiceSource;
|