@orion-js/echoes 4.4.0 → 4.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -2,38 +2,175 @@
2
2
  var config = {};
3
3
  var config_default = config;
4
4
 
5
- // src/requestsHandler/getEcho.ts
6
- function getEcho_default(method) {
7
- const echo2 = config_default.echoes[method];
8
- if (!echo2) {
9
- throw new Error(`Echo named ${method} not found in this service`);
5
+ // src/publish/index.ts
6
+ async function publish(options) {
7
+ if (!config_default.eventBus) {
8
+ throw new Error("You must initialize echoes configuration to use publish");
10
9
  }
11
- if (echo2.type !== "request") {
12
- throw new Error(`Echo named ${method} is not of type request`);
10
+ return await config_default.eventBus.publish(options);
11
+ }
12
+
13
+ // src/echo/deserialize.ts
14
+ function deserialize_default(serializedJavascript) {
15
+ try {
16
+ return eval("(" + serializedJavascript + ")");
17
+ } catch (error) {
18
+ throw new Error("Error deserializing echo message");
13
19
  }
14
- return echo2;
15
20
  }
16
21
 
17
22
  // src/publish/serialize.ts
18
23
  import serialize from "serialize-javascript";
19
- import { clone } from "@orion-js/helpers";
20
24
  function serialize_default(data) {
21
- const cloned = clone(data);
22
- const serialized = serialize(cloned, { ignoreFunction: true });
25
+ const serialized = serialize(data, { ignoreFunction: true });
23
26
  return serialized;
24
27
  }
25
28
 
29
+ // src/runtime.ts
30
+ import { AsyncLocalStorage } from "async_hooks";
31
+ import { randomUUID } from "crypto";
32
+
33
+ // src/errors.ts
34
+ var EchoesUserError = class extends Error {
35
+ isEchoesError = true;
36
+ isOrionError = true;
37
+ isUserError = true;
38
+ code;
39
+ extra;
40
+ constructor(code, message, extra) {
41
+ if (!message) {
42
+ message = code;
43
+ code = "error";
44
+ }
45
+ super(message);
46
+ this.name = "EchoesUserError";
47
+ this.code = code;
48
+ this.extra = extra;
49
+ }
50
+ getInfo() {
51
+ return { error: this.code, message: this.message, extra: this.extra };
52
+ }
53
+ };
54
+ var EchoesValidationError = class extends Error {
55
+ isEchoesError = true;
56
+ isOrionError = true;
57
+ isValidationError = true;
58
+ code = "validationError";
59
+ validationErrors;
60
+ labels;
61
+ constructor(validationErrors, labels = {}) {
62
+ const printableErrors = Object.entries(validationErrors).map(([key, value]) => `${key}: ${value}`).join(", ");
63
+ super(`Validation Error: {${printableErrors}}`);
64
+ this.name = "EchoesValidationError";
65
+ this.validationErrors = validationErrors;
66
+ this.labels = Object.fromEntries(
67
+ Object.keys(validationErrors).filter((key) => labels[key]).map((key) => [key, labels[key]])
68
+ );
69
+ }
70
+ getInfo() {
71
+ return {
72
+ error: this.code,
73
+ message: "Validation Error",
74
+ validationErrors: this.validationErrors,
75
+ labels: this.labels
76
+ };
77
+ }
78
+ };
79
+
80
+ // src/runtime.ts
81
+ var defaultLogger = {
82
+ debug: (message, metadata) => metadata === void 0 ? console.debug(message) : console.debug(message, metadata),
83
+ info: (message, metadata) => metadata === void 0 ? console.info(message) : console.info(message, metadata),
84
+ warn: (message, metadata) => metadata === void 0 ? console.warn(message) : console.warn(message, metadata),
85
+ error: (message, metadata) => metadata === void 0 ? console.error(message) : console.error(message, metadata)
86
+ };
87
+ var runtime = {};
88
+ var contextStorage = new AsyncLocalStorage();
89
+ function configureEchoesRuntime(adapter) {
90
+ const previous = runtime;
91
+ runtime = { ...runtime, ...adapter };
92
+ return () => {
93
+ runtime = previous;
94
+ };
95
+ }
96
+ function getEchoesRuntime() {
97
+ return runtime;
98
+ }
99
+ function getEchoesLogger() {
100
+ return runtime.logger || defaultLogger;
101
+ }
102
+ function getEchoesContext() {
103
+ return contextStorage.getStore();
104
+ }
105
+ async function runWithEchoesContext(context, callback) {
106
+ const contextWithId = { contextId: context.contextId || randomUUID(), ...context };
107
+ return await contextStorage.run(contextWithId, async () => {
108
+ if (runtime.runWithContext) {
109
+ return await runtime.runWithContext(contextWithId, callback);
110
+ }
111
+ return await callback();
112
+ });
113
+ }
114
+ function isSimpleSchemaLike(schema) {
115
+ return !!schema && typeof schema === "object" && typeof schema.clean === "function" && typeof schema.validate === "function";
116
+ }
117
+ function cloneValue(value) {
118
+ if (value === null || typeof value !== "object") return value;
119
+ if (value instanceof Date) return new Date(value.getTime());
120
+ if (Buffer.isBuffer(value)) return Buffer.from(value);
121
+ if (Array.isArray(value)) return value.map(cloneValue);
122
+ const prototype = Object.getPrototypeOf(value);
123
+ if (prototype !== Object.prototype && prototype !== null) return value;
124
+ const result = {};
125
+ for (const [key, child] of Object.entries(value)) {
126
+ result[key] = cloneValue(child);
127
+ }
128
+ return result;
129
+ }
130
+ async function cleanSimpleSchema(schema, value) {
131
+ const cloned = cloneValue(value);
132
+ return await schema.clean(cloned, { mutate: false });
133
+ }
134
+ async function cleanEchoesSchema(schema, value) {
135
+ if (isSimpleSchemaLike(schema)) {
136
+ return await cleanSimpleSchema(schema, value);
137
+ }
138
+ if (runtime.schema) {
139
+ return await runtime.schema.clean(schema, value);
140
+ }
141
+ throw new Error(
142
+ "Echoes received a schema it cannot execute. Use a SimpleSchema-compatible object or configure an Echoes schema adapter."
143
+ );
144
+ }
145
+ async function parseEchoesSchema(schema, value) {
146
+ if (isSimpleSchemaLike(schema)) {
147
+ const cleaned = await cleanSimpleSchema(schema, value);
148
+ await schema.validate(cleaned);
149
+ return cleaned;
150
+ }
151
+ if (runtime.schema) {
152
+ return await runtime.schema.parse(schema, value);
153
+ }
154
+ throw new Error(
155
+ "Echoes received a schema it cannot execute. Use a SimpleSchema-compatible object or configure an Echoes schema adapter."
156
+ );
157
+ }
158
+ function createEchoesValidationError(info) {
159
+ return runtime.createValidationError ? runtime.createValidationError(info) : new EchoesValidationError(info.validationErrors || {}, info.labels);
160
+ }
161
+ function createEchoesUserError(info) {
162
+ return runtime.createUserError ? runtime.createUserError(info) : new EchoesUserError(info.error, info.message, info.extra);
163
+ }
164
+
26
165
  // src/request/getSignature.ts
27
- import JSSHA from "jssha";
166
+ import { createHmac } from "crypto";
28
167
 
29
168
  // src/request/getPassword.ts
30
- import { logger } from "@orion-js/logger";
31
- import { internalGetEnv } from "@orion-js/env";
32
169
  function getEchoesPassword() {
33
170
  var _a, _b;
34
- const secret = ((_b = (_a = config_default) == null ? void 0 : _a.requests) == null ? void 0 : _b.key) || internalGetEnv("echoes_password", "ECHOES_PASSWORD");
171
+ const secret = ((_b = (_a = config_default) == null ? void 0 : _a.requests) == null ? void 0 : _b.key) || process.env.echoes_password || process.env.ECHOES_PASSWORD;
35
172
  if (!secret) {
36
- logger.warn(
173
+ getEchoesLogger().warn(
37
174
  'Warning: no secret key found for echoes requests. Init echoes or set the env var "echoes_password" or process.env.ECHOES_PASSWORD'
38
175
  );
39
176
  }
@@ -41,89 +178,244 @@ function getEchoesPassword() {
41
178
  }
42
179
 
43
180
  // src/request/getSignature.ts
44
- function getSignature_default(body) {
181
+ function getSignature_default(_body) {
45
182
  const password = getEchoesPassword();
46
- const shaObj = new JSSHA("SHA-1", "TEXT");
47
- shaObj.setHMACKey(password, "TEXT");
48
- shaObj.update(body);
49
- return shaObj.getHMAC("HEX");
183
+ return createHmac("sha1", password || "").update("").digest("hex");
50
184
  }
51
185
 
52
- // src/requestsHandler/checkSignature.ts
53
- function checkSignature_default(body, signature) {
54
- const generatedSignature = getSignature_default(body);
55
- if (generatedSignature !== signature) {
56
- throw new Error("Echoes invalid signature");
186
+ // src/request/getURL.ts
187
+ function getURL_default(serviceName) {
188
+ var _a, _b, _c;
189
+ if (serviceName.startsWith("http")) return serviceName;
190
+ const url = (_c = (_b = (_a = config_default) == null ? void 0 : _a.requests) == null ? void 0 : _b.services) == null ? void 0 : _c[serviceName];
191
+ if (!url) {
192
+ throw new Error(`No URL found in echoes config for service ${serviceName}`);
57
193
  }
194
+ return url;
58
195
  }
59
196
 
60
- // src/requestsHandler/index.ts
61
- import { route } from "@orion-js/http";
62
- var requestsHandler_default = (options) => route({
63
- method: "post",
64
- path: options.requests.handlerPath || "/echoes-services",
65
- bodyParser: "json",
66
- bodyParserOptions: {
67
- limit: "10mb"
68
- },
69
- async resolve(req) {
70
- try {
71
- const { body, signature } = req.body;
72
- checkSignature_default(body, signature);
73
- const { method, serializedParams } = body;
74
- const echo2 = getEcho_default(method);
75
- const result = await echo2.onRequest(serializedParams);
76
- return {
77
- body: {
78
- result: serialize_default(result)
197
+ // src/request/makeRequest.ts
198
+ import http from "http";
199
+ import https from "https";
200
+ async function executeWithRetries(callback, retries = 0, delayMs = 200) {
201
+ try {
202
+ return await callback();
203
+ } catch (error) {
204
+ if (retries <= 0) throw error;
205
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
206
+ return await executeWithRetries(callback, retries - 1, delayMs);
207
+ }
208
+ }
209
+ async function postJSON(urlValue, data, timeout, redirectsRemaining = 5) {
210
+ const url = new URL(urlValue);
211
+ const body = JSON.stringify(data);
212
+ const transport = url.protocol === "https:" ? https : http;
213
+ return await new Promise((resolve, reject) => {
214
+ const request2 = transport.request(
215
+ url,
216
+ {
217
+ method: "POST",
218
+ headers: {
219
+ "User-Agent": "Orionjs-Echoes/1.1",
220
+ "Content-Type": "application/json",
221
+ "Content-Length": Buffer.byteLength(body)
79
222
  }
80
- };
81
- } catch (error) {
82
- if (!error.getInfo) {
83
- console.error("Error at echo requests handler:", error);
223
+ },
224
+ (response) => {
225
+ const chunks = [];
226
+ const statusCode = response.statusCode || 0;
227
+ if (statusCode >= 300 && statusCode < 400 && response.headers.location) {
228
+ response.resume();
229
+ if (redirectsRemaining <= 0) {
230
+ reject(new Error("Echoes request exceeded the redirect limit"));
231
+ return;
232
+ }
233
+ resolve(
234
+ postJSON(
235
+ new URL(response.headers.location, url).toString(),
236
+ data,
237
+ timeout,
238
+ redirectsRemaining - 1
239
+ )
240
+ );
241
+ return;
242
+ }
243
+ response.on("data", (chunk) => chunks.push(Buffer.from(chunk)));
244
+ response.on("end", () => {
245
+ try {
246
+ const responseBody = Buffer.concat(chunks).toString("utf8");
247
+ if (statusCode < 200 || statusCode >= 300) {
248
+ reject(new Error(`Request failed with status code ${statusCode}`));
249
+ return;
250
+ }
251
+ resolve({
252
+ statusCode,
253
+ data: responseBody ? JSON.parse(responseBody) : {}
254
+ });
255
+ } catch (error) {
256
+ reject(error);
257
+ }
258
+ });
84
259
  }
85
- return {
86
- body: {
87
- error: error.message,
88
- errorInfo: error.getInfo ? error.getInfo() : null,
89
- isValidationError: !!error.isValidationError,
90
- isUserError: !!error.isUserError
260
+ );
261
+ request2.on("error", reject);
262
+ if (timeout) {
263
+ request2.setTimeout(timeout, () => {
264
+ request2.destroy(new Error(`Echoes request timed out after ${timeout}ms`));
265
+ });
266
+ }
267
+ request2.end(body);
268
+ });
269
+ }
270
+ var makeRequest = async (options) => {
271
+ return await executeWithRetries(
272
+ () => postJSON(options.url, options.data, options.timeout),
273
+ options.retries || 0
274
+ );
275
+ };
276
+
277
+ // src/request/index.ts
278
+ async function request(options) {
279
+ var _a, _b;
280
+ const { method, service, params } = options;
281
+ const serializedParams = serialize_default(params);
282
+ const date = /* @__PURE__ */ new Date();
283
+ const body = { method, service, serializedParams, date };
284
+ const signature = getSignature_default(body);
285
+ try {
286
+ const requestMaker = ((_b = (_a = config_default) == null ? void 0 : _a.requests) == null ? void 0 : _b.makeRequest) || makeRequest;
287
+ const requestOptions = {
288
+ url: getURL_default(service),
289
+ retries: options.retries,
290
+ timeout: options.timeout,
291
+ data: {
292
+ body,
293
+ signature
294
+ }
295
+ };
296
+ const result = await requestMaker(requestOptions);
297
+ if (result.statusCode !== 200) {
298
+ throw new Error(`Wrong status code ${result.statusCode}`);
299
+ }
300
+ const data = result.data;
301
+ if (data.error) {
302
+ const info = data.errorInfo;
303
+ if (info) {
304
+ if (data.isValidationError) {
305
+ throw createEchoesValidationError(info);
91
306
  }
92
- };
307
+ if (data.isUserError) {
308
+ throw createEchoesUserError(info);
309
+ }
310
+ }
311
+ throw new Error(`${data.error}`);
93
312
  }
313
+ const response = deserialize_default(data.result);
314
+ return response;
315
+ } catch (error) {
316
+ const caught = error;
317
+ if (caught.isOrionError || caught.isEchoesError) throw caught;
318
+ throw new Error(`Echoes request network error calling ${service}/${method}: ${caught.message}`);
94
319
  }
95
- });
320
+ }
96
321
 
97
322
  // src/startService/KafkaManager.ts
98
- import { Kafka } from "kafkajs";
99
- import { logger as logger2 } from "@orion-js/logger";
323
+ import { randomUUID as randomUUID2 } from "crypto";
100
324
  var HEARTBEAT_INTERVAL_SECONDS = 5;
101
325
  var CHECK_JOIN_CONSUMER_INTERVAL_SECONDS = 30;
102
326
  var DEFAULT_PARTITIONS_CONSUMED_CONCURRENTLY = 4;
103
327
  var DEFAULT_MEMBERS_TO_PARTITIONS_RATIO = 1;
104
328
  var KafkaManager = class {
329
+ name = "kafka";
105
330
  kafka;
106
331
  options;
107
332
  producer;
108
333
  consumer;
109
- topics;
110
- started;
334
+ topics = [];
335
+ subscriptions = /* @__PURE__ */ new Map();
336
+ onEvent;
337
+ consumerStarted = false;
338
+ producerConnected = false;
111
339
  interval;
112
340
  constructor(options) {
113
- this.kafka = new Kafka(options.client);
114
341
  this.options = options;
115
- this.producer = this.kafka.producer(options.producer);
116
- this.consumer = this.kafka.consumer(options.consumer);
117
- this.topics = Object.keys(options.echoes).filter((key) => options.echoes[key].type === "event");
342
+ }
343
+ async start(options) {
344
+ var _a;
345
+ let kafkaModule;
346
+ try {
347
+ kafkaModule = await import("kafkajs");
348
+ } catch (error) {
349
+ const wrapped = new Error(
350
+ "Echoes Kafka transport requires kafkajs to be installed in the application"
351
+ );
352
+ wrapped.cause = error;
353
+ throw wrapped;
354
+ }
355
+ this.kafka = new kafkaModule.Kafka(this.options.client);
356
+ this.onEvent = options.onEvent;
357
+ this.subscriptions = new Map(
358
+ options.subscriptions.map((subscription) => [subscription.topic, subscription])
359
+ );
360
+ this.topics = options.subscriptions.map((subscription) => subscription.topic);
361
+ if (options.publish || options.consume) {
362
+ this.producer = this.kafka.producer(this.options.producer);
363
+ await this.producer.connect();
364
+ this.producerConnected = true;
365
+ }
366
+ if (!options.consume || this.topics.length === 0) return;
367
+ if (!((_a = this.options.consumer) == null ? void 0 : _a.groupId)) {
368
+ throw new Error("Echoes Kafka consumers require consumer.groupId");
369
+ }
370
+ this.consumer = this.kafka.consumer(this.options.consumer);
371
+ this.consumerStarted = await this.conditionalStart();
372
+ if (this.consumerStarted) return;
373
+ getEchoesLogger().info("Echoes: Delaying consumer group join, waiting for conditions to be met");
374
+ this.interval = setInterval(async () => {
375
+ this.consumerStarted = await this.conditionalStart();
376
+ if (this.consumerStarted) clearInterval(this.interval);
377
+ }, CHECK_JOIN_CONSUMER_INTERVAL_SECONDS * 1e3);
378
+ }
379
+ async publish(options) {
380
+ if (!this.producer || !this.producerConnected) {
381
+ throw new Error("Echoes Kafka producer is not connected");
382
+ }
383
+ return await this.producer.send({
384
+ acks: options.acks,
385
+ timeout: options.timeout,
386
+ topic: options.topic,
387
+ messages: [
388
+ {
389
+ value: serialize_default({ params: options.params }),
390
+ headers: {
391
+ "echoes-event-id": randomUUID2()
392
+ }
393
+ }
394
+ ]
395
+ });
396
+ }
397
+ async close() {
398
+ var _a, _b;
399
+ const logger = getEchoesLogger();
400
+ logger.warn("Echoes: Stopping Kafka transport");
401
+ if (this.interval) clearInterval(this.interval);
402
+ await Promise.all([
403
+ (_a = this.consumer) == null ? void 0 : _a.disconnect(),
404
+ this.producerConnected ? (_b = this.producer) == null ? void 0 : _b.disconnect() : void 0
405
+ ]);
406
+ this.consumerStarted = false;
407
+ this.producerConnected = false;
118
408
  }
119
409
  async checkJoinConsumerGroupConditions() {
410
+ const logger = getEchoesLogger();
120
411
  const admin = this.kafka.admin();
121
412
  try {
122
413
  await admin.connect();
123
- const groupDescriptions = await admin.describeGroups([this.options.consumer.groupId]);
414
+ const groupId = this.options.consumer.groupId;
415
+ const groupDescriptions = await admin.describeGroups([groupId]);
124
416
  const group = groupDescriptions.groups[0];
125
417
  if (group.state === "Empty") {
126
- logger2.info(`Echoes: Consumer group ${this.options.consumer.groupId} is empty, joining`);
418
+ logger.info(`Echoes: Consumer group ${groupId} is empty, joining`);
127
419
  return true;
128
420
  }
129
421
  const topicsMetadata = await admin.fetchTopicMetadata({ topics: this.topics });
@@ -131,23 +423,24 @@ var KafkaManager = class {
131
423
  (acc, topic) => acc + topic.partitions.length,
132
424
  0
133
425
  );
134
- logger2.info(
135
- `Echoes: Consumer group ${this.options.consumer.groupId} has ${group.members.length} members and ${totalPartitions} partitions`
426
+ logger.info(
427
+ `Echoes: Consumer group ${groupId} has ${group.members.length} members and ${totalPartitions} partitions`
136
428
  );
137
429
  const partitionsRatio = this.options.membersToPartitionsRatio || DEFAULT_MEMBERS_TO_PARTITIONS_RATIO;
138
430
  const partitionsThreshold = Math.ceil(totalPartitions * partitionsRatio);
139
431
  if (partitionsThreshold > group.members.length) {
140
- logger2.info(
141
- `Echoes: Consumer group ${this.options.consumer.groupId} has room for more members ${group.members.length}/${partitionsThreshold}, joining`
432
+ logger.info(
433
+ `Echoes: Consumer group ${groupId} has room for more members ${group.members.length}/${partitionsThreshold}, joining`
142
434
  );
143
435
  return true;
144
436
  }
437
+ return false;
145
438
  } catch (error) {
146
- logger2.error("Echoes: Error checking consumer group conditions, join", { error });
439
+ logger.error("Echoes: Error checking consumer group conditions, join", { error });
147
440
  return true;
148
441
  } finally {
149
442
  await admin.disconnect().catch((error) => {
150
- logger2.error("Echoes: Error disconnecting admin client", { error });
443
+ logger.error("Echoes: Error disconnecting admin client", { error });
151
444
  });
152
445
  }
153
446
  }
@@ -164,67 +457,83 @@ var KafkaManager = class {
164
457
  await this.joinConsumerGroup();
165
458
  return true;
166
459
  }
167
- }
168
- async start() {
169
- if (this.started) return;
170
- await this.producer.connect();
171
- this.started = await this.conditionalStart();
172
- if (this.started) return;
173
- logger2.info("Echoes: Delaying consumer group join, waiting for conditions to be met");
174
- this.interval = setInterval(async () => {
175
- this.started = await this.conditionalStart();
176
- if (this.started) clearInterval(this.interval);
177
- }, CHECK_JOIN_CONSUMER_INTERVAL_SECONDS * 1e3);
178
- }
179
- async stop() {
180
- logger2.warn("Echoes: Stopping echoes");
181
- if (this.interval) clearInterval(this.interval);
182
- if (this.consumer) await this.consumer.disconnect();
183
- if (this.producer) await this.producer.disconnect();
460
+ return false;
184
461
  }
185
462
  async handleMessage(params) {
186
- const echo2 = this.options.echoes[params.topic];
187
- if (!echo2 || echo2.type !== "event") {
188
- logger2.warn(`Echoes: Received a message for an unknown topic: ${params.topic}, ignoring it`);
463
+ const logger = getEchoesLogger();
464
+ const subscription = this.subscriptions.get(params.topic);
465
+ if (!subscription) {
466
+ logger.warn(`Echoes: Received a message for an unknown topic: ${params.topic}, ignoring it`);
189
467
  return;
190
468
  }
191
469
  let intervalsCount = 0;
192
470
  const heartbeatInterval = setInterval(async () => {
193
471
  await params.heartbeat().catch((error) => {
194
- logger2.warn(`Echoes: Error sending heartbeat: ${error.message}`);
472
+ logger.warn(`Echoes: Error sending heartbeat: ${error.message}`);
195
473
  });
196
474
  intervalsCount++;
197
475
  if (intervalsCount * HEARTBEAT_INTERVAL_SECONDS % 30 === 0) {
198
- logger2.warn(
476
+ logger.warn(
199
477
  `Echoes: Event is taking too long to process: ${params.topic} ${intervalsCount * HEARTBEAT_INTERVAL_SECONDS}s`
200
478
  );
201
479
  }
202
480
  }, HEARTBEAT_INTERVAL_SECONDS * 1e3);
203
481
  try {
204
- await echo2.onMessage(params).catch((error) => this.handleRetries(echo2, params, error));
482
+ const event = this.createReceivedEvent(params);
483
+ await this.onEvent(event);
205
484
  } catch (error) {
206
- logger2.error("Echoes: error processing a message", { error, topic: params.topic });
207
- throw error;
485
+ try {
486
+ await this.handleRetries(subscription, params, error);
487
+ } catch (retryError) {
488
+ logger.error("Echoes: error processing a message", {
489
+ error: retryError,
490
+ topic: params.topic
491
+ });
492
+ throw retryError;
493
+ }
208
494
  } finally {
209
495
  clearInterval(heartbeatInterval);
210
496
  }
211
497
  }
212
- async handleRetries(echo2, params, error) {
498
+ createReceivedEvent(params) {
499
+ var _a, _b, _c, _d;
500
+ const { message, topic, partition } = params;
501
+ if (!message.value) {
502
+ throw new Error(`Echoes received an empty Kafka message for ${topic}`);
503
+ }
504
+ const data = deserialize_default(message.value.toString());
505
+ const retries = Number.parseInt(((_b = (_a = message.headers) == null ? void 0 : _a.retries) == null ? void 0 : _b.toString()) || "0", 10);
506
+ const timestamp = Number.parseInt(message.timestamp || "", 10);
507
+ return {
508
+ id: ((_d = (_c = message.headers) == null ? void 0 : _c["echoes-event-id"]) == null ? void 0 : _d.toString()) || `kafka:${topic}:${partition}:${message.offset}`,
509
+ topic,
510
+ data,
511
+ transport: "kafka",
512
+ headers: message.headers,
513
+ createdAt: Number.isFinite(timestamp) ? new Date(timestamp) : /* @__PURE__ */ new Date(),
514
+ attempt: retries + 1,
515
+ context: params
516
+ };
517
+ }
518
+ async handleRetries(subscription, params, error) {
213
519
  var _a, _b;
520
+ const logger = getEchoesLogger();
214
521
  const { message, topic } = params;
215
522
  const retries = Number.parseInt(((_b = (_a = message == null ? void 0 : message.headers) == null ? void 0 : _a.retries) == null ? void 0 : _b.toString()) || "0", 10);
216
- if (echo2.attemptsBeforeDeadLetter === void 0 || echo2.attemptsBeforeDeadLetter === null) {
523
+ if (subscription.attemptsBeforeDeadLetter === void 0 || subscription.attemptsBeforeDeadLetter === null) {
217
524
  throw error;
218
525
  }
219
- const maxRetries = echo2.attemptsBeforeDeadLetter || 0;
526
+ const maxRetries = subscription.attemptsBeforeDeadLetter || 0;
220
527
  const exceededMaxRetries = retries >= maxRetries;
221
528
  const nextTopic = exceededMaxRetries ? `DLQ-${topic}` : topic;
529
+ if (!message.value) throw error;
222
530
  await this.producer.send({
223
531
  topic: nextTopic,
224
532
  messages: [
225
533
  {
226
534
  value: message.value.toString(),
227
535
  headers: {
536
+ ...message.headers,
228
537
  retries: String(retries + 1),
229
538
  error: error.message
230
539
  }
@@ -232,180 +541,339 @@ var KafkaManager = class {
232
541
  ]
233
542
  });
234
543
  if (exceededMaxRetries) {
235
- logger2.error(
544
+ logger.error(
236
545
  "Echoes: a message has reached the maximum number of retries, sending it to DLQ",
237
546
  { topic: nextTopic }
238
547
  );
239
548
  } else {
240
- logger2.warn("Echoes: a retryable message failed", { error, topic: nextTopic });
549
+ logger.warn("Echoes: a retryable message failed", { error, topic: nextTopic });
241
550
  }
242
551
  }
243
552
  };
244
553
  var KafkaManager_default = KafkaManager;
245
554
 
246
- // src/startService/index.ts
247
- import { registerRoute } from "@orion-js/http";
248
- var kafkaManager = null;
249
- async function startService(options) {
250
- config_default.echoes = options.echoes;
251
- if (options.requests) {
252
- config_default.requests = options.requests;
253
- registerRoute(requestsHandler_default(options));
555
+ // src/events/EventBus.ts
556
+ var EventBus = class {
557
+ echoes;
558
+ transports;
559
+ consumeFrom;
560
+ publishTo;
561
+ startedTransports = [];
562
+ started = false;
563
+ closed = false;
564
+ constructor(options) {
565
+ this.echoes = options.echoes;
566
+ this.transports = options.transports;
567
+ this.consumeFrom = [...new Set(options.consumeFrom)];
568
+ this.publishTo = options.publishTo;
254
569
  }
255
- if (options.client) {
256
- kafkaManager = new KafkaManager_default(options);
257
- await kafkaManager.start();
258
- config_default.producer = kafkaManager.producer;
259
- config_default.consumer = kafkaManager.consumer;
570
+ async start() {
571
+ if (this.started) return;
572
+ const selected = new Set(this.consumeFrom);
573
+ if (this.publishTo) selected.add(this.publishTo);
574
+ for (const name of selected) {
575
+ if (!this.transports[name]) {
576
+ throw new Error(`Echoes event transport "${name}" is selected but not configured`);
577
+ }
578
+ }
579
+ const subscriptions = Object.entries(this.echoes).filter(([, echo2]) => echo2.type === "event").map(([topic, echo2]) => ({
580
+ topic,
581
+ attemptsBeforeDeadLetter: echo2.attemptsBeforeDeadLetter
582
+ }));
583
+ try {
584
+ for (const name of selected) {
585
+ const transport = this.transports[name];
586
+ this.startedTransports.push(transport);
587
+ await transport.start({
588
+ consume: this.consumeFrom.includes(name),
589
+ publish: this.publishTo === name,
590
+ subscriptions,
591
+ onEvent: (event) => this.handleEvent(event)
592
+ });
593
+ }
594
+ this.started = true;
595
+ } catch (error) {
596
+ await this.close().catch(() => void 0);
597
+ throw error;
598
+ }
260
599
  }
261
- }
262
- async function stopService() {
263
- if (kafkaManager) {
264
- console.info("Stoping echoes...");
265
- await kafkaManager.stop();
266
- console.info("Echoes stopped");
600
+ async publish(options) {
601
+ if (!this.started) {
602
+ throw new Error("You must initialize echoes configuration to use publish");
603
+ }
604
+ if (!this.publishTo) {
605
+ throw new Error("Echoes does not have a publish transport configured");
606
+ }
607
+ return await this.transports[this.publishTo].publish(options);
267
608
  }
268
- }
609
+ async close() {
610
+ if (this.closed) return;
611
+ this.closed = true;
612
+ await Promise.all(this.startedTransports.map((transport) => transport.close()));
613
+ }
614
+ async handleEvent(event) {
615
+ const logger = getEchoesLogger();
616
+ const echo2 = this.echoes[event.topic];
617
+ if (!echo2 || echo2.type !== "event") {
618
+ logger.warn(`Echoes: Received a message for an unknown topic: ${event.topic}, ignoring it`);
619
+ return;
620
+ }
621
+ if (event.transport === "kafka" && event.context) {
622
+ await echo2.onMessage(event.context);
623
+ return;
624
+ }
625
+ if (echo2.onEvent) {
626
+ await echo2.onEvent(event);
627
+ return;
628
+ }
629
+ await echo2.resolve(event.data.params, {
630
+ ...event.context || {},
631
+ transport: event.transport,
632
+ eventId: event.id,
633
+ topic: event.topic,
634
+ headers: event.headers,
635
+ createdAt: event.createdAt,
636
+ attempt: event.attempt,
637
+ data: event.data
638
+ });
639
+ }
640
+ };
269
641
 
270
- // src/publish/index.ts
271
- async function publish(options) {
272
- if (!config_default.producer) {
273
- throw new Error("You must initialize echoes configruation to use publish");
642
+ // src/events/PulseManager.ts
643
+ var PulseManager = class {
644
+ name = "pulse";
645
+ options;
646
+ pulse;
647
+ subscriptions = [];
648
+ constructor(options) {
649
+ this.options = options;
650
+ }
651
+ async start(options) {
652
+ let pulseModule;
653
+ try {
654
+ pulseModule = await import("@orion-js/pulse");
655
+ } catch (error) {
656
+ const wrapped = new Error(
657
+ "Echoes Pulse transport requires @orion-js/pulse to be installed in the application"
658
+ );
659
+ wrapped.cause = error;
660
+ throw wrapped;
661
+ }
662
+ const { subscription, ...connectOptions } = this.options;
663
+ this.pulse = pulseModule.connect(connectOptions);
664
+ await this.pulse.awaitConnection();
665
+ if (!options.consume) return;
666
+ this.subscriptions = await Promise.all(
667
+ options.subscriptions.map(
668
+ (definition) => this.pulse.subscribe(
669
+ definition.topic,
670
+ (event) => options.onEvent(this.createReceivedEvent(event)),
671
+ this.getSubscribeOptions(subscription, definition.attemptsBeforeDeadLetter)
672
+ )
673
+ )
674
+ );
675
+ }
676
+ async publish(options) {
677
+ if (!this.pulse) {
678
+ throw new Error("Echoes Pulse client is not connected");
679
+ }
680
+ return await this.pulse.publish({
681
+ topic: options.topic,
682
+ data: { params: options.params }
683
+ });
684
+ }
685
+ async close() {
686
+ var _a;
687
+ await ((_a = this.pulse) == null ? void 0 : _a.close());
688
+ this.subscriptions = [];
689
+ }
690
+ getSubscribeOptions(defaults = {}, attemptsBeforeDeadLetter) {
691
+ return {
692
+ ...defaults,
693
+ ...attemptsBeforeDeadLetter === void 0 ? {} : { maxRetries: attemptsBeforeDeadLetter }
694
+ };
274
695
  }
275
- const payload = {
276
- params: options.params
696
+ createReceivedEvent(event) {
697
+ return {
698
+ id: event.id,
699
+ topic: event.topic,
700
+ data: event.data,
701
+ transport: "pulse",
702
+ headers: event.headers,
703
+ createdAt: event.createdAt,
704
+ attempt: event.attempt,
705
+ context: event
706
+ };
707
+ }
708
+ };
709
+
710
+ // src/events/createEventBus.ts
711
+ function resolveEventsConfig(options) {
712
+ var _a, _b, _c, _d;
713
+ const legacyKafka = options.client ? {
714
+ client: options.client,
715
+ producer: options.producer,
716
+ consumer: options.consumer,
717
+ readTopicsFromBeginning: options.readTopicsFromBeginning,
718
+ partitionsConsumedConcurrently: options.partitionsConsumedConcurrently,
719
+ membersToPartitionsRatio: options.membersToPartitionsRatio
720
+ } : void 0;
721
+ const kafka = ((_a = options.events) == null ? void 0 : _a.kafka) || legacyKafka;
722
+ const pulse = (_b = options.events) == null ? void 0 : _b.pulse;
723
+ if (!kafka && !pulse && !options.events) return void 0;
724
+ const defaultTransport = kafka ? "kafka" : pulse ? "pulse" : void 0;
725
+ return {
726
+ kafka,
727
+ pulse,
728
+ consumeFrom: ((_c = options.events) == null ? void 0 : _c.consumeFrom) || (defaultTransport ? [defaultTransport] : []),
729
+ publishTo: ((_d = options.events) == null ? void 0 : _d.publishTo) || defaultTransport
277
730
  };
278
- return await config_default.producer.send({
279
- acks: options.acks,
280
- timeout: options.timeout,
281
- topic: options.topic,
282
- messages: [
283
- {
284
- value: serialize_default(payload)
285
- }
286
- ]
731
+ }
732
+ function createEventBus(options) {
733
+ const resolved = resolveEventsConfig(options);
734
+ if (!resolved) return void 0;
735
+ const transports = {};
736
+ if (resolved.kafka) transports.kafka = new KafkaManager_default(resolved.kafka);
737
+ if (resolved.pulse) transports.pulse = new PulseManager(resolved.pulse);
738
+ return new EventBus({
739
+ echoes: options.echoes,
740
+ transports,
741
+ consumeFrom: resolved.consumeFrom,
742
+ publishTo: resolved.publishTo
287
743
  });
288
744
  }
289
745
 
290
- // src/request/getURL.ts
291
- function getURL_default(serviceName) {
292
- var _a, _b;
293
- if (serviceName.startsWith("http")) return serviceName;
294
- const url = (_b = (_a = config_default) == null ? void 0 : _a.requests) == null ? void 0 : _b.services[serviceName];
295
- if (!url) {
296
- throw new Error(`No URL found in echoes config for service ${serviceName}`);
746
+ // src/requestsHandler/checkSignature.ts
747
+ function checkSignature_default(body, signature) {
748
+ const generatedSignature = getSignature_default(body);
749
+ if (generatedSignature !== signature) {
750
+ throw new Error("Echoes invalid signature");
297
751
  }
298
- return url;
299
752
  }
300
753
 
301
- // src/echo/deserialize.ts
302
- function deserialize_default(serializedJavascript) {
303
- try {
304
- return eval("(" + serializedJavascript + ")");
305
- } catch (error) {
306
- throw new Error("Error deserializing echo message");
754
+ // src/requestsHandler/getEcho.ts
755
+ function getEcho_default(method) {
756
+ const echo2 = config_default.echoes[method];
757
+ if (!echo2) {
758
+ throw new Error(`Echo named ${method} not found in this service`);
307
759
  }
760
+ if (echo2.type !== "request") {
761
+ throw new Error(`Echo named ${method} is not of type request`);
762
+ }
763
+ return echo2;
308
764
  }
309
765
 
310
- // src/request/makeRequest.ts
311
- import axios from "axios";
312
- import { executeWithRetries } from "@orion-js/helpers";
313
- var makeRequest = async (options) => {
314
- const result = await executeWithRetries(
315
- async () => {
316
- return await axios({
317
- method: "post",
318
- url: options.url,
319
- timeout: options.timeout,
320
- headers: {
321
- "User-Agent": "Orionjs-Echoes/1.1"
322
- },
323
- data: options.data
324
- });
325
- },
326
- options.retries,
327
- 200
328
- );
329
- return {
330
- data: result.data,
331
- statusCode: result.status
332
- };
333
- };
334
-
335
- // src/request/index.ts
336
- import { ValidationError } from "@orion-js/schema";
337
- import { UserError } from "@orion-js/helpers";
338
- async function request(options) {
339
- var _a, _b;
340
- const { method, service, params } = options;
341
- const serializedParams = serialize_default(params);
342
- const date = /* @__PURE__ */ new Date();
343
- const body = { method, service, serializedParams, date };
344
- const signature = getSignature_default(body);
345
- try {
346
- const requestMaker = ((_b = (_a = config_default) == null ? void 0 : _a.requests) == null ? void 0 : _b.makeRequest) || makeRequest;
347
- const requestOptions = {
348
- url: getURL_default(service),
349
- retries: options.retries,
350
- timeout: options.timeout,
351
- data: {
352
- body,
353
- signature
766
+ // src/requestsHandler/index.ts
767
+ var requestsHandler_default = (options) => ({
768
+ method: "post",
769
+ path: options.requests.handlerPath || "/echoes-services",
770
+ bodyLimit: "10mb",
771
+ async handle(requestBody) {
772
+ try {
773
+ const { body, signature } = requestBody;
774
+ checkSignature_default(body, signature);
775
+ const { method, serializedParams } = body;
776
+ const echo2 = getEcho_default(method);
777
+ const result = await echo2.onRequest(serializedParams);
778
+ return { result: serialize_default(result) };
779
+ } catch (error) {
780
+ const caught = error;
781
+ if (!caught.getInfo) {
782
+ getEchoesLogger().error("Error at echo requests handler:", { error: caught });
354
783
  }
355
- };
356
- const result = await requestMaker(requestOptions);
357
- if (result.statusCode !== 200) {
358
- throw new Error(`Wrong status code ${result.statusCode}`);
784
+ return {
785
+ error: caught.message,
786
+ errorInfo: caught.getInfo ? caught.getInfo() : null,
787
+ isValidationError: !!caught.isValidationError,
788
+ isUserError: !!caught.isUserError
789
+ };
359
790
  }
360
- const data = result.data;
361
- if (data.error) {
362
- const info = data.errorInfo;
363
- if (info) {
364
- if (data.isValidationError) {
365
- throw new ValidationError(info.validationErrors);
366
- }
367
- if (data.isUserError) {
368
- throw new UserError(info.error, info.message, info.extra);
369
- }
370
- }
371
- throw new Error(`${data.error}`);
791
+ }
792
+ });
793
+
794
+ // src/startService/index.ts
795
+ var eventBus = null;
796
+ async function startService(options) {
797
+ config_default.echoes = options.echoes;
798
+ if (options.requests) {
799
+ config_default.requests = options.requests;
800
+ const registerHandler = options.requests.registerHandler || getEchoesRuntime().registerRequestHandler;
801
+ if (!registerHandler) {
802
+ throw new Error(
803
+ "Echoes requests require requests.registerHandler in standalone servers. Orion applications can import @orion-js/echoes-orion once during startup."
804
+ );
372
805
  }
373
- const response = deserialize_default(data.result);
374
- return response;
375
- } catch (error) {
376
- if (error.isOrionError) throw error;
377
- throw new Error(`Echoes request network error calling ${service}/${method}: ${error.message}`);
806
+ await registerHandler(requestsHandler_default(options));
807
+ }
808
+ const nextEventBus = createEventBus(options);
809
+ if (nextEventBus) {
810
+ await nextEventBus.start();
811
+ eventBus = nextEventBus;
812
+ config_default.eventBus = eventBus;
813
+ }
814
+ }
815
+ async function stopService() {
816
+ if (eventBus) {
817
+ const logger = getEchoesLogger();
818
+ logger.info("Stopping Echoes...");
819
+ await eventBus.close();
820
+ eventBus = null;
821
+ config_default.eventBus = void 0;
822
+ logger.info("Echoes stopped");
378
823
  }
379
824
  }
380
-
381
- // src/service/index.ts
382
- import { runWithOrionAsyncContext } from "@orion-js/logger";
383
825
 
384
826
  // src/echo/index.ts
385
- import { clean, cleanAndValidate } from "@orion-js/schema";
386
827
  var echo = function createNewEcho(options) {
387
828
  const resolve = async (params, context) => {
388
- const cleaned = options.params ? await cleanAndValidate(options.params, params) : params ?? {};
829
+ const cleaned = options.params ? await parseEchoesSchema(options.params, params) : params ?? {};
389
830
  const result = await options.resolve(cleaned, context);
390
831
  if (options.returns) {
391
- return await clean(options.returns, result);
832
+ return await cleanEchoesSchema(options.returns, result);
392
833
  }
393
834
  return result;
394
835
  };
836
+ const onEvent = async (event) => {
837
+ const context = {
838
+ ...event.context || {},
839
+ transport: event.transport,
840
+ eventId: event.id,
841
+ topic: event.topic,
842
+ headers: event.headers,
843
+ createdAt: event.createdAt,
844
+ attempt: event.attempt,
845
+ data: event.data
846
+ };
847
+ await resolve(event.data.params, context);
848
+ };
395
849
  return {
396
850
  type: options.type,
397
851
  params: options.params,
398
852
  returns: options.returns,
399
853
  attemptsBeforeDeadLetter: options.type === "event" ? options.attemptsBeforeDeadLetter : void 0,
400
854
  resolve,
855
+ onEvent,
401
856
  onMessage: async (messageData) => {
857
+ var _a, _b, _c, _d;
402
858
  const { message } = messageData;
859
+ if (!message.value) {
860
+ throw new Error(`Echoes received an empty Kafka message for ${messageData.topic}`);
861
+ }
403
862
  const data = deserialize_default(message.value.toString());
404
- const context = {
405
- ...messageData,
406
- data
407
- };
408
- await resolve(data.params, context);
863
+ const retries = Number.parseInt(((_b = (_a = message.headers) == null ? void 0 : _a.retries) == null ? void 0 : _b.toString()) || "0", 10);
864
+ const timestamp = Number(message.timestamp);
865
+ const createdAt = Number.isFinite(timestamp) ? new Date(timestamp) : /* @__PURE__ */ new Date();
866
+ const eventId = ((_d = (_c = message.headers) == null ? void 0 : _c["echoes-event-id"]) == null ? void 0 : _d.toString()) || `kafka:${messageData.topic}:${messageData.partition}:${message.offset}`;
867
+ await onEvent({
868
+ id: eventId,
869
+ topic: messageData.topic,
870
+ data,
871
+ transport: "kafka",
872
+ headers: message.headers,
873
+ createdAt,
874
+ attempt: retries + 1,
875
+ context: messageData
876
+ });
409
877
  },
410
878
  onRequest: async (serializedParams) => {
411
879
  const context = {};
@@ -421,15 +889,21 @@ function createEchoEvent(options) {
421
889
  return echo({ ...options, type: "event" });
422
890
  }
423
891
 
892
+ // src/schema.ts
893
+ function typedEchoesSchema(schema) {
894
+ return schema;
895
+ }
896
+
424
897
  // src/service/index.ts
425
- import { getInstance, Service } from "@orion-js/services";
426
898
  var serviceMetadata = /* @__PURE__ */ new WeakMap();
427
899
  var echoesMetadata = /* @__PURE__ */ new WeakMap();
428
900
  var echoEntriesByClass = /* @__PURE__ */ new Map();
901
+ var standaloneInstances = /* @__PURE__ */ new WeakMap();
429
902
  var pendingEchoEntries = {};
430
903
  function Echoes() {
431
904
  return (target, context) => {
432
- Service()(target, context);
905
+ var _a, _b;
906
+ (_b = (_a = getEchoesRuntime()).decorateService) == null ? void 0 : _b.call(_a, target, context);
433
907
  serviceMetadata.set(target, { _serviceType: "echoes" });
434
908
  if (Object.keys(pendingEchoEntries).length > 0) {
435
909
  echoEntriesByClass.set(target, pendingEchoEntries);
@@ -446,7 +920,7 @@ function EchoEvent(options = {}) {
446
920
  return createEchoEvent({
447
921
  ...options,
448
922
  resolve: async (params, contextData) => {
449
- return await runWithOrionAsyncContext(
923
+ return await runWithEchoesContext(
450
924
  {
451
925
  controllerType: "echo",
452
926
  echoName: propertyKey,
@@ -475,7 +949,7 @@ function EchoRequest(options = {}) {
475
949
  return createEchoRequest({
476
950
  ...options,
477
951
  resolve: async (params, contextData) => {
478
- return await runWithOrionAsyncContext(
952
+ return await runWithEchoesContext(
479
953
  {
480
954
  controllerType: "echo",
481
955
  echoName: propertyKey,
@@ -505,7 +979,18 @@ function initializeEchoesIfNeeded(instance) {
505
979
  echoesMetadata.set(instance, echoes);
506
980
  }
507
981
  function getServiceEchoes(target) {
508
- const instance = getInstance(target);
982
+ let instance;
983
+ if (typeof target !== "function") {
984
+ instance = target;
985
+ } else if (getEchoesRuntime().getInstance) {
986
+ instance = getEchoesRuntime().getInstance(target);
987
+ } else {
988
+ instance = standaloneInstances.get(target);
989
+ if (!instance) {
990
+ instance = new target();
991
+ standaloneInstances.set(target, instance);
992
+ }
993
+ }
509
994
  if (!serviceMetadata.has(instance.constructor)) {
510
995
  throw new Error("You must pass a class decorated with @Echoes to getServiceEchoes");
511
996
  }
@@ -521,13 +1006,25 @@ export {
521
1006
  EchoEvent,
522
1007
  EchoRequest,
523
1008
  Echoes,
1009
+ EchoesUserError,
1010
+ EchoesValidationError,
1011
+ cleanEchoesSchema,
1012
+ configureEchoesRuntime,
524
1013
  createEchoEvent,
525
1014
  createEchoRequest,
1015
+ createEchoesUserError,
1016
+ createEchoesValidationError,
526
1017
  echo,
1018
+ getEchoesContext,
1019
+ getEchoesLogger,
1020
+ getEchoesRuntime,
527
1021
  getServiceEchoes,
1022
+ parseEchoesSchema,
528
1023
  publish,
529
1024
  request,
1025
+ runWithEchoesContext,
530
1026
  startService,
531
- stopService
1027
+ stopService,
1028
+ typedEchoesSchema
532
1029
  };
533
1030
  //# sourceMappingURL=index.js.map