|
| 1 | +/** |
| 2 | + * @since 0.3.0 |
| 3 | + */ |
| 4 | +import * as Subscriber from "@effect-messaging/core/Subscriber" |
| 5 | +import * as SubscriberError from "@effect-messaging/core/SubscriberError" |
| 6 | +import type * as NATSCore from "@nats-io/nats-core" |
| 7 | +import * as Cause from "effect/Cause" |
| 8 | +import * as Context from "effect/Context" |
| 9 | +import type * as Duration from "effect/Duration" |
| 10 | +import * as Effect from "effect/Effect" |
| 11 | +import * as Function from "effect/Function" |
| 12 | +import * as Layer from "effect/Layer" |
| 13 | +import * as Option from "effect/Option" |
| 14 | +import * as Predicate from "effect/Predicate" |
| 15 | +import * as Stream from "effect/Stream" |
| 16 | +import * as NATSConnection from "./NATSConnection.js" |
| 17 | +import * as NATSError from "./NATSError.js" |
| 18 | +import * as NATSHeaders from "./NATSHeaders.js" |
| 19 | +import type * as NATSMessage from "./NATSMessage.js" |
| 20 | +import type * as NATSSubscription from "./NATSSubscription.js" |
| 21 | + |
| 22 | +/** |
| 23 | + * @category type ids |
| 24 | + * @since 0.3.0 |
| 25 | + */ |
| 26 | +export const TypeId: unique symbol = Symbol.for("@effect-messaging/nats/NATSSubscriber") |
| 27 | + |
| 28 | +/** |
| 29 | + * @category type ids |
| 30 | + * @since 0.3.0 |
| 31 | + */ |
| 32 | +export type TypeId = typeof TypeId |
| 33 | + |
| 34 | +/** |
| 35 | + * @category models |
| 36 | + * @since 0.3.0 |
| 37 | + */ |
| 38 | +export interface NATSSubscriber extends Subscriber.Subscriber<NATSMessage.NATSMessage> { |
| 39 | + readonly [TypeId]: TypeId |
| 40 | +} |
| 41 | + |
| 42 | +/** |
| 43 | + * @category models |
| 44 | + * @since 0.3.0 |
| 45 | + */ |
| 46 | +export interface NATSSubscriberOptions { |
| 47 | + uninterruptible?: boolean |
| 48 | + handlerTimeout?: Duration.DurationInput |
| 49 | +} |
| 50 | + |
| 51 | +/** |
| 52 | + * Context tag for accessing the current NATS message in a handler |
| 53 | + * |
| 54 | + * @category tags |
| 55 | + * @since 0.3.0 |
| 56 | + */ |
| 57 | +export const NATSConsumeMessage = Context.GenericTag<NATSMessage.NATSMessage>( |
| 58 | + "@effect-messaging/nats/NATSConsumeMessage" |
| 59 | +) |
| 60 | + |
| 61 | +/** |
| 62 | + * Layer for providing the current NATS message to a handler |
| 63 | + * |
| 64 | + * @category layers |
| 65 | + * @since 0.3.0 |
| 66 | + */ |
| 67 | +export const layer = ( |
| 68 | + message: NATSMessage.NATSMessage |
| 69 | +): Layer.Layer<NATSMessage.NATSMessage> => Layer.succeed(NATSConsumeMessage, message) |
| 70 | + |
| 71 | +const ATTR_SERVER_ADDRESS = "server.address" as const |
| 72 | +const ATTR_SERVER_PORT = "server.port" as const |
| 73 | +const ATTR_MESSAGING_DESTINATION_NAME = "messaging.destination.name" as const |
| 74 | +const ATTR_MESSAGING_OPERATION_NAME = "messaging.operation.name" as const |
| 75 | +const ATTR_MESSAGING_OPERATION_TYPE = "messaging.operation.type" as const |
| 76 | +const ATTR_MESSAGING_SYSTEM = "messaging.system" as const |
| 77 | +const ATTR_MESSAGING_MESSAGE_ID = "messaging.message.id" as const |
| 78 | + |
| 79 | +/** @internal */ |
| 80 | +const subscribe = ( |
| 81 | + subscription: NATSSubscription.NATSSubscription, |
| 82 | + connectionInfo: NATSCore.ServerInfo, |
| 83 | + options: NATSSubscriberOptions |
| 84 | +) => |
| 85 | +<E, R>( |
| 86 | + handler: Effect.Effect<void, E, R | NATSMessage.NATSMessage> |
| 87 | +): Effect.Effect<void, SubscriberError.SubscriberError, Exclude<R, NATSMessage.NATSMessage>> => |
| 88 | + subscription.stream.pipe( |
| 89 | + Stream.runForEach((message) => |
| 90 | + Effect.fork( |
| 91 | + Effect.useSpan( |
| 92 | + `nats.consume ${message.subject}`, |
| 93 | + { |
| 94 | + parent: Option.getOrUndefined(NATSHeaders.decodeTraceContextOptional(message.headers)), |
| 95 | + kind: "consumer", |
| 96 | + captureStackTrace: false, |
| 97 | + attributes: { |
| 98 | + [ATTR_SERVER_ADDRESS]: connectionInfo.host, |
| 99 | + [ATTR_SERVER_PORT]: connectionInfo.port, |
| 100 | + [ATTR_MESSAGING_SYSTEM]: "nats", |
| 101 | + [ATTR_MESSAGING_OPERATION_TYPE]: "receive", |
| 102 | + [ATTR_MESSAGING_DESTINATION_NAME]: message.subject, |
| 103 | + [ATTR_MESSAGING_MESSAGE_ID]: message.sid |
| 104 | + } |
| 105 | + }, |
| 106 | + (span) => |
| 107 | + Effect.gen(function*() { |
| 108 | + yield* Effect.logDebug(`nats.consume ${message.subject}`) |
| 109 | + yield* handler.pipe( |
| 110 | + options.handlerTimeout |
| 111 | + ? Effect.timeoutFail({ |
| 112 | + duration: options.handlerTimeout, |
| 113 | + onTimeout: () => |
| 114 | + new SubscriberError.SubscriberError({ reason: "NATSSubscriber: handler timed out" }) |
| 115 | + }) |
| 116 | + : Function.identity |
| 117 | + ) |
| 118 | + span.attribute(ATTR_MESSAGING_OPERATION_NAME, "process") |
| 119 | + }).pipe( |
| 120 | + Effect.provide(layer(message)), |
| 121 | + Effect.tapErrorCause((cause) => |
| 122 | + Effect.gen(function*() { |
| 123 | + // Log the error - NATS Core has no ack/nak mechanism, so we just log and continue |
| 124 | + yield* Effect.logError(Cause.pretty(cause)) |
| 125 | + span.attribute(ATTR_MESSAGING_OPERATION_NAME, "error") |
| 126 | + span.attribute( |
| 127 | + "error.type", |
| 128 | + Cause.squashWith( |
| 129 | + cause, |
| 130 | + (_) => Predicate.hasProperty(_, "tag") ? _.tag : _ instanceof Error ? _.name : `${_}` |
| 131 | + ) |
| 132 | + ) |
| 133 | + span.attribute("error.stack", Cause.pretty(cause)) |
| 134 | + span.attribute( |
| 135 | + "error.message", |
| 136 | + Cause.squashWith( |
| 137 | + cause, |
| 138 | + (_) => Predicate.hasProperty(_, "reason") ? _.reason : _ instanceof Error ? _.message : `${_}` |
| 139 | + ) |
| 140 | + ) |
| 141 | + }) |
| 142 | + ), |
| 143 | + options.uninterruptible ? Effect.uninterruptible : Effect.interruptible, |
| 144 | + Effect.withParentSpan(span) |
| 145 | + ) |
| 146 | + ) |
| 147 | + ) |
| 148 | + ), |
| 149 | + Effect.mapError((error) => |
| 150 | + new SubscriberError.SubscriberError({ reason: "NATSSubscriber failed to subscribe", cause: error }) |
| 151 | + ) |
| 152 | + ) |
| 153 | + |
| 154 | +/** @internal */ |
| 155 | +const healthCheck = ( |
| 156 | + subscription: NATSSubscription.NATSSubscription |
| 157 | +): Effect.Effect<void, SubscriberError.SubscriberError, never> => |
| 158 | + subscription.isClosed.pipe( |
| 159 | + Effect.flatMap((isClosed) => |
| 160 | + isClosed |
| 161 | + ? Effect.fail(new SubscriberError.SubscriberError({ reason: "Subscription is closed" })) |
| 162 | + : Effect.void |
| 163 | + ), |
| 164 | + Effect.catchTag("NATSSubscriptionError", (error) => |
| 165 | + new SubscriberError.SubscriberError({ reason: "Healthcheck failed", cause: error })) |
| 166 | + ) |
| 167 | + |
| 168 | +/** |
| 169 | + * Create a NATSSubscriber from an existing NATSSubscription. |
| 170 | + * |
| 171 | + * Note: NATS Core subscriptions are fire-and-forget. Messages are not persisted |
| 172 | + * and there is no acknowledgment mechanism. If the handler fails or times out, |
| 173 | + * the message is lost. |
| 174 | + * |
| 175 | + * @category constructors |
| 176 | + * @since 0.3.0 |
| 177 | + */ |
| 178 | +export const fromSubscription = ( |
| 179 | + subscription: NATSSubscription.NATSSubscription, |
| 180 | + options: NATSSubscriberOptions = {} |
| 181 | +): Effect.Effect< |
| 182 | + NATSSubscriber, |
| 183 | + NATSError.NATSConnectionError, |
| 184 | + NATSConnection.NATSConnection |
| 185 | +> => |
| 186 | + Effect.gen(function*() { |
| 187 | + const connection = yield* NATSConnection.NATSConnection |
| 188 | + const connectionInfo = yield* Option.match(connection.info, { |
| 189 | + onNone: () => Effect.fail(new NATSError.NATSConnectionError({ reason: "Connection info not available" })), |
| 190 | + onSome: Effect.succeed |
| 191 | + }) |
| 192 | + |
| 193 | + const subscriber: NATSSubscriber = { |
| 194 | + [TypeId]: TypeId, |
| 195 | + [Subscriber.TypeId]: Subscriber.TypeId, |
| 196 | + subscribe: subscribe(subscription, connectionInfo, options), |
| 197 | + healthCheck: healthCheck(subscription) |
| 198 | + } |
| 199 | + |
| 200 | + return subscriber |
| 201 | + }) |
| 202 | + |
| 203 | +/** |
| 204 | + * Create a NATSSubscriber by subscribing to a subject. |
| 205 | + * |
| 206 | + * This is a convenience constructor that internally calls `subscribe()` on the connection. |
| 207 | + * |
| 208 | + * Note: NATS Core subscriptions are fire-and-forget. Messages are not persisted |
| 209 | + * and there is no acknowledgment mechanism. If the handler fails or times out, |
| 210 | + * the message is lost. |
| 211 | + * |
| 212 | + * @category constructors |
| 213 | + * @since 0.3.0 |
| 214 | + */ |
| 215 | +export const make = ( |
| 216 | + subject: string, |
| 217 | + subscriptionOptions?: NATSCore.SubscriptionOptions, |
| 218 | + options: NATSSubscriberOptions = {} |
| 219 | +): Effect.Effect< |
| 220 | + NATSSubscriber, |
| 221 | + NATSError.NATSConnectionError, |
| 222 | + NATSConnection.NATSConnection |
| 223 | +> => |
| 224 | + Effect.gen(function*() { |
| 225 | + const connection = yield* NATSConnection.NATSConnection |
| 226 | + const subscription = yield* connection.subscribe(subject, subscriptionOptions) |
| 227 | + return yield* fromSubscription(subscription, options) |
| 228 | + }) |
0 commit comments