@prosody-events/prosody 0.5.0 → 0.6.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.
@@ -0,0 +1,232 @@
1
+ /**
2
+ * The ProsodyClient class, the event handler it runs, and request outcomes.
3
+ * @module lib/client
4
+ */
5
+
6
+ import type { Configuration, ConsumerState, Timer } from "../bindings";
7
+ import type { Context } from "./context";
8
+ import type {
9
+ ExciseMessage,
10
+ JsonCompatible,
11
+ JsonValue,
12
+ MaybePromise,
13
+ Message,
14
+ } from "./payload";
15
+ import type {
16
+ DequeDefinition,
17
+ MapDefinition,
18
+ SetDefinition,
19
+ ValueDefinition,
20
+ } from "./state/definitions";
21
+ import type {
22
+ PublishedDeque,
23
+ PublishedMap,
24
+ PublishedSet,
25
+ PublishedValue,
26
+ } from "./state/published";
27
+
28
+ /** The callbacks that a subscription runs for each message, excise, and timer. */
29
+ export interface EventHandler<P = JsonValue, R = JsonValue> {
30
+ /** Handles an excise record. */
31
+ onExcise: (
32
+ context: Context,
33
+ message: ExciseMessage,
34
+ signal: AbortSignal,
35
+ ) => MaybePromise<R & JsonCompatible<R>>;
36
+ /**
37
+ * Callback function to handle incoming messages.
38
+ *
39
+ * @param context - The context of the message processing.
40
+ * @param message - The received Kafka message.
41
+ * @param signal - An AbortSignal that can be used to cancel the message processing.
42
+ * @returns A JSON response for subsystem requests. JSON null is valid.
43
+ */
44
+ onMessage: (
45
+ context: Context,
46
+ message: Message<P>,
47
+ signal: AbortSignal,
48
+ ) => MaybePromise<R & JsonCompatible<R>>;
49
+
50
+ /**
51
+ * Callback function to handle timers.
52
+ *
53
+ * @param context - The context of the message processing.
54
+ * @param timer - The triggered timer.
55
+ * @param signal - An AbortSignal that can be used to cancel the message processing.
56
+ * @returns No value.
57
+ */
58
+ onTimer: (
59
+ context: Context,
60
+ timer: Timer,
61
+ signal: AbortSignal,
62
+ ) => MaybePromise<void>;
63
+ }
64
+
65
+ /** One successful subsystem outcome. */
66
+ export interface Success<T> {
67
+ readonly ok: true;
68
+ readonly value: T;
69
+ }
70
+
71
+ /** One failed subsystem outcome. */
72
+ export interface Failure {
73
+ readonly ok: false;
74
+ readonly error: ResponseError;
75
+ }
76
+
77
+ /** One subsystem outcome. */
78
+ export type Outcome<T> = Success<T> | Failure;
79
+
80
+ /** One subsystem failure. */
81
+ export type ResponseError =
82
+ | { readonly kind: "handler"; readonly message: string }
83
+ | { readonly kind: "timeout"; readonly message: string }
84
+ | { readonly kind: "formatMismatch"; readonly message: string }
85
+ | { readonly kind: "malformedResponse"; readonly message: string };
86
+
87
+ /** Request targets, deadline, and cancellation. */
88
+ export interface RequestOptions {
89
+ /** The subsystems that must respond. */
90
+ readonly subsystems: readonly string[];
91
+ /** The response deadline in milliseconds. */
92
+ readonly timeoutMs: number;
93
+ /** Cancels the local wait. */
94
+ readonly signal?: AbortSignal;
95
+ }
96
+
97
+ /** The Prosody client: send, request, subscribe, and shut down. */
98
+ export declare class ProsodyClient implements AsyncDisposable {
99
+ private constructor();
100
+
101
+ /**
102
+ * Creates a Prosody client without blocking the Node.js event loop.
103
+ *
104
+ * @param config - The configuration options for the client.
105
+ */
106
+ static create(config: Configuration): Promise<ProsodyClient>;
107
+
108
+ /**
109
+ * Gets the current state of the consumer.
110
+ *
111
+ * @returns The current state of the consumer.
112
+ * @throws Error if the operation fails.
113
+ */
114
+ consumerState(): Promise<ConsumerState>;
115
+
116
+ /**
117
+ * Gets the number of partitions assigned to the consumer.
118
+ *
119
+ * @returns The number of assigned partitions, or 0 if the consumer is not in the Running state.
120
+ * @throws Error if the operation fails.
121
+ */
122
+ assignedPartitionCount(): Promise<number>;
123
+
124
+ /**
125
+ * Checks if the consumer is stalled.
126
+ *
127
+ * @returns Whether the consumer is stalled, or false if the consumer is not in the Running state.
128
+ * @throws Error if the operation fails.
129
+ */
130
+ isStalled(): Promise<boolean>;
131
+
132
+ /** Opens a read-only view of a published JSON value collection. */
133
+ state<T>(
134
+ subsystem: string,
135
+ definition: ValueDefinition<T>,
136
+ ): Promise<PublishedValue<T>>;
137
+ /** Opens a read-only view of a published JSON map collection. */
138
+ state<V>(
139
+ subsystem: string,
140
+ definition: MapDefinition<V>,
141
+ ): Promise<PublishedMap<V>>;
142
+ /** Opens a read-only view of a published set collection. */
143
+ state(subsystem: string, definition: SetDefinition): Promise<PublishedSet>;
144
+ /** Opens a read-only view of a published JSON deque collection. */
145
+ state<T>(
146
+ subsystem: string,
147
+ definition: DequeDefinition<T>,
148
+ ): Promise<PublishedDeque<T>>;
149
+
150
+ /**
151
+ * Gets the source system identifier configured for the client.
152
+ *
153
+ * @returns The source system identifier.
154
+ */
155
+ get sourceSystem(): string;
156
+
157
+ /**
158
+ * Sends a message to a specified topic.
159
+ *
160
+ * @param topic - The name of the topic to send the message to.
161
+ * @param key - The key of the message.
162
+ * @param payload - The message payload (must be JSON-serializable).
163
+ * @param signal - An optional AbortSignal that can be used to cancel the send operation. When aborted, the promise will reject with the abort reason.
164
+ * @returns A promise that resolves when the message has been successfully sent.
165
+ * @throws Error if the send operation fails or is aborted.
166
+ */
167
+ send<P>(
168
+ topic: string,
169
+ key: string,
170
+ payload: P & JsonCompatible<P>,
171
+ signal?: AbortSignal,
172
+ ): Promise<void>;
173
+
174
+ /** Sends an excise record for a key. */
175
+ excise(topic: string, key: string, signal?: AbortSignal): Promise<void>;
176
+
177
+ /**
178
+ * Sends a request and returns one outcome for each subsystem.
179
+ *
180
+ * The map contains every selected subsystem. A missing response has a timeout outcome.
181
+ * `R` types the response. `P` types the payload and is inferred when you give no type arguments.
182
+ * @throws Error if the input is invalid, sending fails, shutdown starts, or the signal aborts.
183
+ */
184
+ request<R = JsonValue, P = JsonValue>(
185
+ topic: string,
186
+ key: string,
187
+ payload: P & JsonCompatible<P>,
188
+ options: RequestOptions,
189
+ ): Promise<ReadonlyMap<string, Outcome<R>>>;
190
+
191
+ /** Sends an excise request and returns one outcome for each subsystem. */
192
+ requestExcise<R = JsonValue>(
193
+ topic: string,
194
+ key: string,
195
+ options: RequestOptions,
196
+ ): Promise<ReadonlyMap<string, Outcome<R>>>;
197
+
198
+ /**
199
+ * Subscribes to receive messages using the provided event handler.
200
+ *
201
+ * @param eventHandler - The event handler to process received messages and timers.
202
+ * @returns A promise that resolves when the subscription is successfully established and the consumer is ready to receive messages.
203
+ * @throws TypeError if any required handler method is missing.
204
+ * @throws Error if the subscription fails to establish.
205
+ */
206
+ subscribe<P = JsonValue, R = JsonValue>(
207
+ eventHandler: EventHandler<P, R>,
208
+ ): Promise<void>;
209
+
210
+ /**
211
+ * Stops the consumer. You can subscribe again later.
212
+ *
213
+ * @returns A promise that resolves when the unsubscribe operation is complete.
214
+ * @throws Error if the unsubscribe operation fails.
215
+ */
216
+ unsubscribe(): Promise<void>;
217
+
218
+ /**
219
+ * Shuts down the client and all its services.
220
+ * Concurrent and repeated calls await the same shutdown operation.
221
+ *
222
+ * @returns A promise that resolves when shutdown is complete.
223
+ * @throws Error if shutdown fails.
224
+ */
225
+ shutdown(): Promise<void>;
226
+
227
+ /**
228
+ * Shuts down the client when an `await using` block ends. It calls
229
+ * {@link ProsodyClient.shutdown}.
230
+ */
231
+ [Symbol.asyncDispose](): Promise<void>;
232
+ }
package/lib/client.js ADDED
@@ -0,0 +1,347 @@
1
+ /**
2
+ * The ProsodyClient class: producing, requests, subscription, and shutdown.
3
+ * @module lib/client
4
+ * @private
5
+ */
6
+
7
+ const {
8
+ context: otelContext,
9
+ propagation,
10
+ trace,
11
+ SpanStatusCode,
12
+ } = require("@opentelemetry/api");
13
+ const { NativeClient } = require("../bindings");
14
+ const { Context } = require("./context");
15
+ const { EventHandlerError, TransientError } = require("./errors");
16
+ const { captureException, getCurrentLogger } = require("./logging");
17
+ const {
18
+ defineHidden,
19
+ injectedCarrier,
20
+ toStateError,
21
+ } = require("./state/bridge");
22
+ const { eventMetadata, toJson, withParsedPayload } = require("./state/codec");
23
+ const { stateDefinitionAccess } = require("./state/definitions");
24
+
25
+ /** Proves that a constructor call comes from {@link wrapNative}. @private */
26
+ const CONSTRUCT = Symbol("ProsodyClient");
27
+
28
+ /** The Prosody client: send, request, subscribe, and shut down. */
29
+ class ProsodyClient {
30
+ constructor(token, nativeClient) {
31
+ if (token !== CONSTRUCT) {
32
+ throw new TypeError("Use await ProsodyClient.create(config)");
33
+ }
34
+ // `shutdownPromise` is the one shutdown operation that every shutdown
35
+ // call awaits.
36
+ defineHidden(this, { nativeClient, shutdownPromise: undefined });
37
+ }
38
+
39
+ static async create(config) {
40
+ return wrapNative(await NativeClient.create(config));
41
+ }
42
+
43
+ get sourceSystem() {
44
+ return this.nativeClient.sourceSystem;
45
+ }
46
+
47
+ consumerState() {
48
+ return this.nativeClient.consumerState();
49
+ }
50
+
51
+ assignedPartitionCount() {
52
+ return this.nativeClient.assignedPartitionCount();
53
+ }
54
+
55
+ isStalled() {
56
+ return this.nativeClient.isStalled();
57
+ }
58
+
59
+ async state(subsystem, definition) {
60
+ const access = stateDefinitionAccess.get(definition);
61
+ if (access?.published === undefined) {
62
+ throw new TypeError(
63
+ "definition must be a JSON value, map, or deque definition, or a set definition",
64
+ );
65
+ }
66
+ try {
67
+ return await access.published(
68
+ this.nativeClient,
69
+ subsystem,
70
+ definition.name,
71
+ definition.readCache,
72
+ );
73
+ } catch (error) {
74
+ throw toStateError(error);
75
+ }
76
+ }
77
+
78
+ async send(topic, key, payload, signal) {
79
+ const carrier = injectedCarrier();
80
+ const json = toJson(payload, TransientError);
81
+
82
+ await withAbort(signal, (aborted) =>
83
+ this.nativeClient.send(
84
+ topic,
85
+ key,
86
+ json,
87
+ eventMetadata(payload),
88
+ carrier,
89
+ aborted,
90
+ ),
91
+ );
92
+ }
93
+
94
+ async excise(topic, key, signal) {
95
+ const carrier = injectedCarrier();
96
+ await withAbort(signal, (aborted) =>
97
+ this.nativeClient.excise(topic, key, carrier, aborted),
98
+ );
99
+ }
100
+
101
+ async request(topic, key, payload, options) {
102
+ const carrier = injectedCarrier();
103
+ const request = {
104
+ topic,
105
+ key,
106
+ payload: toJson(payload, TransientError),
107
+ metadata: eventMetadata(payload),
108
+ subsystems: options.subsystems,
109
+ timeoutMs: options.timeoutMs,
110
+ };
111
+ const results = await withAbort(options.signal, (aborted) =>
112
+ this.nativeClient.request(request, carrier, aborted),
113
+ );
114
+ return responseOutcomes(results);
115
+ }
116
+
117
+ async requestExcise(topic, key, options) {
118
+ const carrier = injectedCarrier();
119
+ const request = {
120
+ topic,
121
+ key,
122
+ subsystems: options.subsystems,
123
+ timeoutMs: options.timeoutMs,
124
+ };
125
+ const results = await withAbort(options.signal, (aborted) =>
126
+ this.nativeClient.requestExcise(request, carrier, aborted),
127
+ );
128
+ return responseOutcomes(results);
129
+ }
130
+
131
+ async subscribe(eventHandler) {
132
+ for (const name of ["onMessage", "onExcise", "onTimer"]) {
133
+ if (typeof eventHandler?.[name] !== "function") {
134
+ throw new TypeError(`EventHandler.${name} must be a function`);
135
+ }
136
+ }
137
+
138
+ const tracer = trace.getTracer("prosody");
139
+ const { onExcise, onMessage, onTimer } = eventHandler;
140
+ // Runs one handler call under its span. `run` returns the response text.
141
+ // `failure` is the logger text for a failed call.
142
+ const runHandler = (
143
+ nativeContext,
144
+ carrier,
145
+ spanName,
146
+ eventType,
147
+ failure,
148
+ captureContext,
149
+ run,
150
+ ) => {
151
+ const ctx = propagation.extract(otelContext.active(), carrier);
152
+ return otelContext.with(ctx, () =>
153
+ tracer.startActiveSpan(spanName, async (span) => {
154
+ const controller = new AbortController();
155
+ let completed = false;
156
+ nativeContext.onCancel().then(() => {
157
+ if (!completed) {
158
+ span.setAttribute("cancelled", true);
159
+ controller.abort(new Error(`${eventType} cancelled`));
160
+ }
161
+ });
162
+
163
+ try {
164
+ return await run(new Context(nativeContext), controller.signal);
165
+ } catch (error) {
166
+ const cause = error.cause ?? error;
167
+ getCurrentLogger().error(failure, cause);
168
+ span.recordException(cause);
169
+ span.setStatus({
170
+ code: SpanStatusCode.ERROR,
171
+ message: cause.message,
172
+ });
173
+ captureException(error, eventType, captureContext);
174
+ throw error;
175
+ } finally {
176
+ completed = true;
177
+ span.end();
178
+ }
179
+ }),
180
+ );
181
+ };
182
+
183
+ // A result with no JSON form is a handler mistake. It is transient, so
184
+ // the event retries and the mistake stays visible.
185
+ const handleRecord = (
186
+ nativeContext,
187
+ message,
188
+ carrier,
189
+ handler,
190
+ spanName,
191
+ eventType,
192
+ ) =>
193
+ runHandler(
194
+ nativeContext,
195
+ carrier,
196
+ spanName,
197
+ eventType,
198
+ `${eventType} handler error`,
199
+ {
200
+ topic: message.topic,
201
+ partition: message.partition,
202
+ key: message.key,
203
+ offset: message.offset,
204
+ },
205
+ async (context, signal) =>
206
+ toJson(
207
+ (await handler(context, message, signal)) ?? null,
208
+ TransientError,
209
+ ),
210
+ );
211
+
212
+ await this.nativeClient.subscribe({
213
+ isPermanent: ([err]) => {
214
+ try {
215
+ return err instanceof EventHandlerError && err.isPermanent;
216
+ } catch {
217
+ return false;
218
+ }
219
+ },
220
+
221
+ onMessage: async (err, [nativeContext, message, carrier]) => {
222
+ if (err) throw err;
223
+ return handleRecord(
224
+ nativeContext,
225
+ withParsedPayload(message),
226
+ carrier,
227
+ onMessage,
228
+ "onMessage",
229
+ "message",
230
+ );
231
+ },
232
+
233
+ onExcise: async (err, [nativeContext, message, carrier]) => {
234
+ if (err) throw err;
235
+ return handleRecord(
236
+ nativeContext,
237
+ message,
238
+ carrier,
239
+ onExcise,
240
+ "onExcise",
241
+ "excise",
242
+ );
243
+ },
244
+
245
+ onTimer: async (err, [nativeContext, timer, carrier]) => {
246
+ if (err) throw err;
247
+ return runHandler(
248
+ nativeContext,
249
+ carrier,
250
+ "onTimer",
251
+ "timer",
252
+ "Timer handler error",
253
+ { key: timer.key, time: timer.time },
254
+ async (context, signal) => {
255
+ await onTimer(context, timer, signal);
256
+ return "null";
257
+ },
258
+ );
259
+ },
260
+ });
261
+ }
262
+
263
+ async unsubscribe() {
264
+ await this.nativeClient.unsubscribe();
265
+ }
266
+
267
+ async shutdown() {
268
+ this.shutdownPromise ??= this.nativeClient.shutdown();
269
+ await this.shutdownPromise;
270
+ }
271
+
272
+ [Symbol.asyncDispose]() {
273
+ return this.shutdown();
274
+ }
275
+ }
276
+
277
+ // napi-rs can only surface rejections whose value is an object, function, or
278
+ // symbol — napi_create_reference fails on primitives and the rejection becomes
279
+ // an opaque `InvalidArg: Create Error reference failed`. Coerce primitive
280
+ // reasons into Error instances so the original reason is preserved across the
281
+ // napi boundary.
282
+ const toAbortError = (reason) =>
283
+ reason instanceof Error
284
+ ? reason
285
+ : new Error(reason === undefined ? "aborted" : String(reason));
286
+
287
+ /**
288
+ * Runs a native call that stops when an optional abort signal fires.
289
+ *
290
+ * The call receives a promise that rejects with the abort reason. The abort
291
+ * listener is removed when the call settles, so one long-lived signal can
292
+ * serve any number of calls.
293
+ * @param {AbortSignal|undefined} signal - The abort signal to watch.
294
+ * @param {(aborted: Promise<never>|undefined) => Promise<*>} call - The
295
+ * native call.
296
+ * @returns {Promise<*>} The result of the call.
297
+ * @private
298
+ */
299
+ async function withAbort(signal, call) {
300
+ if (signal === undefined) return call(undefined);
301
+ let abort;
302
+ const aborted = new Promise((_, reject) => {
303
+ abort = () => reject(toAbortError(signal.reason));
304
+ });
305
+ if (signal.aborted) abort();
306
+ else signal.addEventListener("abort", abort, { once: true });
307
+ try {
308
+ return await call(aborted);
309
+ } finally {
310
+ signal.removeEventListener("abort", abort);
311
+ }
312
+ }
313
+
314
+ function responseOutcome(outcome) {
315
+ if (typeof outcome !== "string") return { ok: false, error: outcome };
316
+
317
+ try {
318
+ return { ok: true, value: JSON.parse(outcome) };
319
+ } catch (cause) {
320
+ return {
321
+ ok: false,
322
+ error: { kind: "malformedResponse", message: cause.message },
323
+ };
324
+ }
325
+ }
326
+
327
+ function responseOutcomes(results) {
328
+ const outcomes = new Map();
329
+ for (const { subsystem, outcome } of results)
330
+ outcomes.set(subsystem, responseOutcome(outcome));
331
+ return outcomes;
332
+ }
333
+
334
+ /**
335
+ * Wraps a native client. The unit tests wrap a stub through it.
336
+ * @param {object} native - The native client.
337
+ * @returns {ProsodyClient} The client.
338
+ * @private
339
+ */
340
+ function wrapNative(native) {
341
+ return new ProsodyClient(CONSTRUCT, native);
342
+ }
343
+
344
+ module.exports = {
345
+ ProsodyClient,
346
+ wrapNative,
347
+ };