@gomessaging/messaging 0.0.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/README.md +185 -0
- package/dist/cloudevents.d.ts +73 -0
- package/dist/cloudevents.d.ts.map +1 -0
- package/dist/cloudevents.js +148 -0
- package/dist/cloudevents.js.map +1 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +15 -0
- package/dist/index.js.map +1 -0
- package/dist/metrics.d.ts +40 -0
- package/dist/metrics.d.ts.map +1 -0
- package/dist/metrics.js +11 -0
- package/dist/metrics.js.map +1 -0
- package/dist/naming.d.ts +54 -0
- package/dist/naming.d.ts.map +1 -0
- package/dist/naming.js +78 -0
- package/dist/naming.js.map +1 -0
- package/dist/routing.d.ts +10 -0
- package/dist/routing.d.ts.map +1 -0
- package/dist/routing.js +41 -0
- package/dist/routing.js.map +1 -0
- package/dist/topology.d.ts +2 -0
- package/dist/topology.d.ts.map +1 -0
- package/dist/topology.js +4 -0
- package/dist/topology.js.map +1 -0
- package/dist/types.d.ts +73 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +5 -0
- package/dist/types.js.map +1 -0
- package/dist/validate.d.ts +12 -0
- package/dist/validate.d.ts.map +1 -0
- package/dist/validate.js +138 -0
- package/dist/validate.js.map +1 -0
- package/dist/visualize.d.ts +10 -0
- package/dist/visualize.d.ts.map +1 -0
- package/dist/visualize.js +134 -0
- package/dist/visualize.js.map +1 -0
- package/package.json +34 -0
- package/src/cloudevents.ts +166 -0
- package/src/index.ts +86 -0
- package/src/metrics.ts +58 -0
- package/src/naming.ts +94 -0
- package/src/routing.ts +39 -0
- package/src/topology.ts +13 -0
- package/src/types.ts +101 -0
- package/src/validate.ts +183 -0
- package/src/visualize.ts +167 -0
package/src/validate.ts
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
// MIT License
|
|
2
|
+
// Copyright (c) 2026 sparetimecoders
|
|
3
|
+
|
|
4
|
+
import type { Endpoint, Topology, Transport } from "./types.js";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Validate checks a single service's topology for internal consistency.
|
|
8
|
+
* Returns null if valid, or an error message string if invalid.
|
|
9
|
+
*/
|
|
10
|
+
export function validate(t: Topology): string | null {
|
|
11
|
+
if (!t.serviceName) {
|
|
12
|
+
return "service name must not be empty";
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const errs: string[] = [];
|
|
16
|
+
|
|
17
|
+
for (let i = 0; i < (t.endpoints ?? []).length; i++) {
|
|
18
|
+
const ep = t.endpoints[i];
|
|
19
|
+
|
|
20
|
+
if (!ep.exchangeName) {
|
|
21
|
+
errs.push(`endpoint[${i}]: exchange name must not be empty`);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (ep.direction === "consume" && !ep.queueName && !ep.ephemeral) {
|
|
25
|
+
errs.push(
|
|
26
|
+
`endpoint[${i}]: consume endpoint must have a queue name`,
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (!ep.routingKey && ep.exchangeKind !== "headers") {
|
|
31
|
+
errs.push(
|
|
32
|
+
`endpoint[${i}]: routing key must not be empty for ${ep.exchangeKind} exchange`,
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (ep.routingKey && ep.routingKey.includes(">")) {
|
|
37
|
+
errs.push(
|
|
38
|
+
`endpoint[${i}]: routing key must not contain '>' (use '#' for multi-level wildcard)`,
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const transportErr = validateExchangeNameForTransport(
|
|
43
|
+
t.transport,
|
|
44
|
+
ep,
|
|
45
|
+
i,
|
|
46
|
+
);
|
|
47
|
+
if (transportErr) {
|
|
48
|
+
errs.push(transportErr);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return errs.length > 0 ? errs.join("\n") : null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function validateExchangeNameForTransport(
|
|
56
|
+
transport: Transport | undefined,
|
|
57
|
+
ep: Endpoint,
|
|
58
|
+
index: number,
|
|
59
|
+
): string | null {
|
|
60
|
+
switch (transport) {
|
|
61
|
+
case "amqp":
|
|
62
|
+
return validateAMQPExchangeName(ep, index);
|
|
63
|
+
case "nats":
|
|
64
|
+
return validateNATSExchangeName(ep, index);
|
|
65
|
+
default:
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function validateAMQPExchangeName(
|
|
71
|
+
ep: Endpoint,
|
|
72
|
+
index: number,
|
|
73
|
+
): string | null {
|
|
74
|
+
const name = ep.exchangeName;
|
|
75
|
+
switch (ep.exchangeKind) {
|
|
76
|
+
case "topic":
|
|
77
|
+
if (!name.endsWith(".topic.exchange")) {
|
|
78
|
+
return `endpoint[${index}]: topic exchange "${name}" must end with .topic.exchange`;
|
|
79
|
+
}
|
|
80
|
+
break;
|
|
81
|
+
case "direct":
|
|
82
|
+
if (!name.endsWith(".direct.exchange.request")) {
|
|
83
|
+
return `endpoint[${index}]: direct exchange "${name}" must end with .direct.exchange.request`;
|
|
84
|
+
}
|
|
85
|
+
break;
|
|
86
|
+
case "headers":
|
|
87
|
+
if (!name.endsWith(".headers.exchange.response")) {
|
|
88
|
+
return `endpoint[${index}]: headers exchange "${name}" must end with .headers.exchange.response`;
|
|
89
|
+
}
|
|
90
|
+
break;
|
|
91
|
+
}
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function validateNATSExchangeName(
|
|
96
|
+
ep: Endpoint,
|
|
97
|
+
index: number,
|
|
98
|
+
): string | null {
|
|
99
|
+
const name = ep.exchangeName;
|
|
100
|
+
const amqpSuffixes = [
|
|
101
|
+
".topic.exchange",
|
|
102
|
+
".direct.exchange.request",
|
|
103
|
+
".headers.exchange.response",
|
|
104
|
+
];
|
|
105
|
+
for (const suffix of amqpSuffixes) {
|
|
106
|
+
if (name.endsWith(suffix)) {
|
|
107
|
+
return `endpoint[${index}]: NATS exchange "${name}" must not use AMQP suffix "${suffix}"`;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
if (/[\s]/.test(name)) {
|
|
111
|
+
return `endpoint[${index}]: NATS exchange "${name}" must not contain whitespace`;
|
|
112
|
+
}
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* ValidateTopologies checks cross-service consistency across multiple topologies.
|
|
118
|
+
* Returns null if valid, or an error message string if invalid.
|
|
119
|
+
*/
|
|
120
|
+
export function validateTopologies(topologies: Topology[]): string | null {
|
|
121
|
+
const errs: string[] = [];
|
|
122
|
+
|
|
123
|
+
for (const t of topologies) {
|
|
124
|
+
const err = validate(t);
|
|
125
|
+
if (err) {
|
|
126
|
+
errs.push(`service "${t.serviceName}": ${err}`);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Group topologies by transport for cross-validation.
|
|
131
|
+
const groups = new Map<string, Topology[]>();
|
|
132
|
+
for (const t of topologies) {
|
|
133
|
+
const key = t.transport ?? "";
|
|
134
|
+
const group = groups.get(key) ?? [];
|
|
135
|
+
group.push(t);
|
|
136
|
+
groups.set(key, group);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
for (const group of groups.values()) {
|
|
140
|
+
const err = crossValidateGroup(group);
|
|
141
|
+
if (err) {
|
|
142
|
+
errs.push(err);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return errs.length > 0 ? errs.join("\n") : null;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function crossValidateGroup(topologies: Topology[]): string | null {
|
|
150
|
+
const errs: string[] = [];
|
|
151
|
+
|
|
152
|
+
// Build a set of all published routing keys per exchange.
|
|
153
|
+
const published = new Map<string, string>(); // "exchange|routingKey" -> service name
|
|
154
|
+
for (const t of topologies) {
|
|
155
|
+
for (const ep of t.endpoints ?? []) {
|
|
156
|
+
if (ep.direction === "publish" && ep.routingKey) {
|
|
157
|
+
published.set(
|
|
158
|
+
`${ep.exchangeName}|${ep.routingKey}`,
|
|
159
|
+
t.serviceName,
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Check that each consumer has a matching publisher.
|
|
166
|
+
for (const t of topologies) {
|
|
167
|
+
for (const ep of t.endpoints ?? []) {
|
|
168
|
+
if (ep.direction !== "consume") continue;
|
|
169
|
+
if (!ep.routingKey) continue;
|
|
170
|
+
// Skip wildcard consumers.
|
|
171
|
+
if (/[#*>]/.test(ep.routingKey)) continue;
|
|
172
|
+
|
|
173
|
+
const key = `${ep.exchangeName}|${ep.routingKey}`;
|
|
174
|
+
if (!published.has(key)) {
|
|
175
|
+
errs.push(
|
|
176
|
+
`service "${t.serviceName}" consumes "${ep.routingKey}" on exchange "${ep.exchangeName}" but no service publishes it`,
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return errs.length > 0 ? errs.join("\n") : null;
|
|
183
|
+
}
|
package/src/visualize.ts
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
// MIT License
|
|
2
|
+
// Copyright (c) 2026 sparetimecoders
|
|
3
|
+
|
|
4
|
+
import type { ExchangeKind, Topology } from "./types.js";
|
|
5
|
+
|
|
6
|
+
interface ExchangeEntry {
|
|
7
|
+
kind: ExchangeKind;
|
|
8
|
+
transport: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface Edge {
|
|
12
|
+
from: string;
|
|
13
|
+
to: string;
|
|
14
|
+
label: string;
|
|
15
|
+
style: string; // "-->" or "-.->"
|
|
16
|
+
transport: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function sanitizeID(name: string): string {
|
|
20
|
+
return name.replace(/[^a-zA-Z0-9_]/g, "_");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function exchangeNode(
|
|
24
|
+
id: string,
|
|
25
|
+
name: string,
|
|
26
|
+
kind: ExchangeKind,
|
|
27
|
+
indent: string,
|
|
28
|
+
): string {
|
|
29
|
+
switch (kind) {
|
|
30
|
+
case "topic":
|
|
31
|
+
return `${indent}${id}{{"${name}"}}\n`;
|
|
32
|
+
case "headers":
|
|
33
|
+
return `${indent}${id}(("${name}"))\n`;
|
|
34
|
+
case "direct":
|
|
35
|
+
default:
|
|
36
|
+
return `${indent}${id}["${name}"]\n`;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Generates a Mermaid flowchart diagram from one or more topologies.
|
|
42
|
+
* The diagram shows services as rectangle nodes, exchanges as shaped nodes
|
|
43
|
+
* (based on exchange kind), and edges for publish/consume relationships.
|
|
44
|
+
* When topologies use multiple transports, exchanges are grouped into
|
|
45
|
+
* subgraphs by transport (e.g. AMQP, NATS).
|
|
46
|
+
*/
|
|
47
|
+
export function mermaid(topologies: Topology[]): string {
|
|
48
|
+
const services = new Set<string>();
|
|
49
|
+
const exchanges = new Map<string, ExchangeEntry>();
|
|
50
|
+
const edges: Edge[] = [];
|
|
51
|
+
const transports = new Set<string>();
|
|
52
|
+
|
|
53
|
+
for (const t of topologies) {
|
|
54
|
+
if (!t.serviceName) continue;
|
|
55
|
+
services.add(t.serviceName);
|
|
56
|
+
if (t.transport) {
|
|
57
|
+
transports.add(t.transport);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
for (const ep of t.endpoints) {
|
|
61
|
+
if (!ep.exchangeName) continue;
|
|
62
|
+
exchanges.set(ep.exchangeName, {
|
|
63
|
+
kind: ep.exchangeKind,
|
|
64
|
+
transport: t.transport ?? "",
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
const arrow = ep.pattern === "service-response" ? "-.->" : "-->";
|
|
68
|
+
|
|
69
|
+
if (ep.direction === "publish") {
|
|
70
|
+
edges.push({
|
|
71
|
+
from: t.serviceName,
|
|
72
|
+
to: ep.exchangeName,
|
|
73
|
+
label: ep.routingKey ?? "",
|
|
74
|
+
style: arrow,
|
|
75
|
+
transport: t.transport ?? "",
|
|
76
|
+
});
|
|
77
|
+
} else if (ep.direction === "consume") {
|
|
78
|
+
edges.push({
|
|
79
|
+
from: ep.exchangeName,
|
|
80
|
+
to: t.serviceName,
|
|
81
|
+
label: ep.routingKey ?? "",
|
|
82
|
+
style: arrow,
|
|
83
|
+
transport: t.transport ?? "",
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const useSubgraphs = transports.size > 1;
|
|
90
|
+
|
|
91
|
+
const exchID = (transport: string, name: string): string => {
|
|
92
|
+
if (useSubgraphs) {
|
|
93
|
+
return sanitizeID(`${transport}_${name}`);
|
|
94
|
+
}
|
|
95
|
+
return sanitizeID(name);
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
let out = "graph LR\n";
|
|
99
|
+
|
|
100
|
+
// Service nodes (sorted)
|
|
101
|
+
const sortedServices = [...services].sort();
|
|
102
|
+
for (const svc of sortedServices) {
|
|
103
|
+
out += ` ${sanitizeID(svc)}["${svc}"]\n`;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const sortedExchangeNames = [...exchanges.keys()].sort();
|
|
107
|
+
|
|
108
|
+
if (useSubgraphs) {
|
|
109
|
+
// Group exchanges by transport into subgraphs
|
|
110
|
+
const sortedTransports = [...transports].sort();
|
|
111
|
+
for (const tr of sortedTransports) {
|
|
112
|
+
out += ` subgraph ${tr.toUpperCase()}\n`;
|
|
113
|
+
for (const name of sortedExchangeNames) {
|
|
114
|
+
const entry = exchanges.get(name)!;
|
|
115
|
+
if (entry.transport !== tr) continue;
|
|
116
|
+
out += exchangeNode(exchID(tr, name), name, entry.kind, " ");
|
|
117
|
+
}
|
|
118
|
+
out += " end\n";
|
|
119
|
+
}
|
|
120
|
+
} else {
|
|
121
|
+
// Single transport or no transport — flat list (backward compatible)
|
|
122
|
+
for (const name of sortedExchangeNames) {
|
|
123
|
+
const entry = exchanges.get(name)!;
|
|
124
|
+
out += exchangeNode(
|
|
125
|
+
exchID(entry.transport, name),
|
|
126
|
+
name,
|
|
127
|
+
entry.kind,
|
|
128
|
+
" ",
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Edges (sorted by from/to/label for deterministic output)
|
|
134
|
+
edges.sort((a, b) => {
|
|
135
|
+
if (a.from !== b.from) return a.from < b.from ? -1 : 1;
|
|
136
|
+
if (a.to !== b.to) return a.to < b.to ? -1 : 1;
|
|
137
|
+
return a.label < b.label ? -1 : a.label > b.label ? 1 : 0;
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
for (const e of edges) {
|
|
141
|
+
const fromID = services.has(e.from)
|
|
142
|
+
? sanitizeID(e.from)
|
|
143
|
+
: exchID(e.transport, e.from);
|
|
144
|
+
const toID = services.has(e.to)
|
|
145
|
+
? sanitizeID(e.to)
|
|
146
|
+
: exchID(e.transport, e.to);
|
|
147
|
+
if (e.label) {
|
|
148
|
+
out += ` ${fromID} ${e.style}|"${e.label}"| ${toID}\n`;
|
|
149
|
+
} else {
|
|
150
|
+
out += ` ${fromID} ${e.style} ${toID}\n`;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Style declarations
|
|
155
|
+
if (sortedServices.length > 0) {
|
|
156
|
+
const ids = sortedServices.map(sanitizeID).join(",");
|
|
157
|
+
out += ` style ${ids} fill:#f9f,stroke:#333\n`;
|
|
158
|
+
}
|
|
159
|
+
if (sortedExchangeNames.length > 0) {
|
|
160
|
+
const ids = sortedExchangeNames
|
|
161
|
+
.map((name) => exchID(exchanges.get(name)!.transport, name))
|
|
162
|
+
.join(",");
|
|
163
|
+
out += ` style ${ids} fill:#bbf,stroke:#333\n`;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return out;
|
|
167
|
+
}
|