@ebarahona/loopback-transport-core 1.0.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.
Files changed (81) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +297 -0
  3. package/dist/client/client-proxy.d.ts +147 -0
  4. package/dist/client/client-proxy.js +225 -0
  5. package/dist/client/client-proxy.js.map +1 -0
  6. package/dist/client/index.d.ts +1 -0
  7. package/dist/client/index.js +18 -0
  8. package/dist/client/index.js.map +1 -0
  9. package/dist/context/execution-context.d.ts +102 -0
  10. package/dist/context/execution-context.js +110 -0
  11. package/dist/context/execution-context.js.map +1 -0
  12. package/dist/context/index.d.ts +1 -0
  13. package/dist/context/index.js +18 -0
  14. package/dist/context/index.js.map +1 -0
  15. package/dist/decorators/constants.d.ts +41 -0
  16. package/dist/decorators/constants.js +13 -0
  17. package/dist/decorators/constants.js.map +1 -0
  18. package/dist/decorators/event-handler.decorator.d.ts +28 -0
  19. package/dist/decorators/event-handler.decorator.js +42 -0
  20. package/dist/decorators/event-handler.decorator.js.map +1 -0
  21. package/dist/decorators/index.d.ts +4 -0
  22. package/dist/decorators/index.js +21 -0
  23. package/dist/decorators/index.js.map +1 -0
  24. package/dist/decorators/message-handler.decorator.d.ts +26 -0
  25. package/dist/decorators/message-handler.decorator.js +40 -0
  26. package/dist/decorators/message-handler.decorator.js.map +1 -0
  27. package/dist/decorators/payload.decorator.d.ts +30 -0
  28. package/dist/decorators/payload.decorator.js +42 -0
  29. package/dist/decorators/payload.decorator.js.map +1 -0
  30. package/dist/index.d.ts +11 -0
  31. package/dist/index.js +42 -0
  32. package/dist/index.js.map +1 -0
  33. package/dist/interfaces/index.d.ts +4 -0
  34. package/dist/interfaces/index.js +21 -0
  35. package/dist/interfaces/index.js.map +1 -0
  36. package/dist/interfaces/message-handler.interface.d.ts +21 -0
  37. package/dist/interfaces/message-handler.interface.js +3 -0
  38. package/dist/interfaces/message-handler.interface.js.map +1 -0
  39. package/dist/interfaces/packet.interface.d.ts +42 -0
  40. package/dist/interfaces/packet.interface.js +3 -0
  41. package/dist/interfaces/packet.interface.js.map +1 -0
  42. package/dist/interfaces/transport-client.interface.d.ts +41 -0
  43. package/dist/interfaces/transport-client.interface.js +3 -0
  44. package/dist/interfaces/transport-client.interface.js.map +1 -0
  45. package/dist/interfaces/transport-server.interface.d.ts +44 -0
  46. package/dist/interfaces/transport-server.interface.js +3 -0
  47. package/dist/interfaces/transport-server.interface.js.map +1 -0
  48. package/dist/keys.d.ts +88 -0
  49. package/dist/keys.js +123 -0
  50. package/dist/keys.js.map +1 -0
  51. package/dist/registry/handler-registry.d.ts +77 -0
  52. package/dist/registry/handler-registry.js +265 -0
  53. package/dist/registry/handler-registry.js.map +1 -0
  54. package/dist/registry/index.d.ts +1 -0
  55. package/dist/registry/index.js +18 -0
  56. package/dist/registry/index.js.map +1 -0
  57. package/dist/serializers/index.d.ts +1 -0
  58. package/dist/serializers/index.js +18 -0
  59. package/dist/serializers/index.js.map +1 -0
  60. package/dist/serializers/serializer.interface.d.ts +62 -0
  61. package/dist/serializers/serializer.interface.js +52 -0
  62. package/dist/serializers/serializer.interface.js.map +1 -0
  63. package/dist/server/index.d.ts +1 -0
  64. package/dist/server/index.js +18 -0
  65. package/dist/server/index.js.map +1 -0
  66. package/dist/server/server-base.d.ts +154 -0
  67. package/dist/server/server-base.js +287 -0
  68. package/dist/server/server-base.js.map +1 -0
  69. package/dist/transport-booter.d.ts +31 -0
  70. package/dist/transport-booter.js +117 -0
  71. package/dist/transport-booter.js.map +1 -0
  72. package/dist/transport.component.d.ts +26 -0
  73. package/dist/transport.component.js +42 -0
  74. package/dist/transport.component.js.map +1 -0
  75. package/dist/utils/index.d.ts +1 -0
  76. package/dist/utils/index.js +6 -0
  77. package/dist/utils/index.js.map +1 -0
  78. package/dist/utils/normalize-pattern.d.ts +18 -0
  79. package/dist/utils/normalize-pattern.js +75 -0
  80. package/dist/utils/normalize-pattern.js.map +1 -0
  81. package/package.json +73 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ed Barahona
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,297 @@
1
+ # @ebarahona/loopback-transport-core
2
+
3
+ Unified transport abstraction for LoopBack 4. Enables NestJS-style microservices with message handlers, event patterns, unified execution context, and client proxies.
4
+
5
+ This package is **transport core only** -- it provides the framework abstractions, not broker-specific implementations. Kafka, RabbitMQ, gRPC, MQTT, and NATS adapters are separate packages that extend `ServerBase` and `ClientProxy`.
6
+
7
+ ```bash
8
+ npm install @ebarahona/loopback-transport-core
9
+ ```
10
+
11
+ ## What This Provides
12
+
13
+ | Export | Purpose |
14
+ |---|---|
15
+ | `TransportComponent` | LoopBack 4 component (registers registry + booter) |
16
+ | `@messageHandler(pattern)` | Request/response handler decorator |
17
+ | `@eventHandler(pattern)` | Fire-and-forget event handler decorator |
18
+ | `@payload()` | Injects the message data (transport invocation context, not HTTP) |
19
+ | `@transportCtx()` | Injects the broker-specific context (transport invocation context, not HTTP) |
20
+ | `ClientProxy` | Abstract client with `send()` (cold Observable) and `emit()` (Promise) |
21
+ | `ServerBase` | Abstract server with handler registry, dispatch, and structured results |
22
+ | `ExecutionContext` | Unified context across HTTP, RPC, and event transports |
23
+ | `HandlerResult` | Structured settlement result for adapter ack/nack decisions |
24
+ | `TransportBindings` | Typed binding keys and server registration helpers |
25
+ | `normalizePattern()` | Deterministic pattern key generation |
26
+ | `Serializer` / `Deserializer` | Pluggable serialization (JSON default) |
27
+ | `TransportServer` / `TransportClient` | Adapter interfaces |
28
+ | `TransportStatus` | Connection status type (`connected`, `disconnected`, `reconnecting`, `error`) |
29
+ | `ReadPacket` / `WritePacket` / `PacketId` | Request/response correlation types |
30
+
31
+ ## Usage
32
+
33
+ ### App Setup
34
+
35
+ ```typescript
36
+ import {Application} from '@loopback/core';
37
+ import {TransportComponent, TransportBindings} from '@ebarahona/loopback-transport-core';
38
+
39
+ const app = new Application();
40
+ app.component(TransportComponent);
41
+ ```
42
+
43
+ Works with `RestApplication` for hybrid HTTP + transport apps, or plain `Application` for transport-only microservices.
44
+
45
+ ### Controller
46
+
47
+ Patterns can be strings or objects. Object patterns are normalized (deep key sort) so `{cmd: 'get', service: 'order'}` and `{service: 'order', cmd: 'get'}` match the same handler.
48
+
49
+ ```typescript
50
+ import {messageHandler, eventHandler, payload, transportCtx} from '@ebarahona/loopback-transport-core';
51
+
52
+ class OrderController {
53
+ // String pattern -- request/response
54
+ @messageHandler('order.get')
55
+ async getOrder(@payload() data: {id: string}): Promise<Order> {
56
+ return this.orderService.findById(data.id);
57
+ }
58
+
59
+ // Object pattern -- request/response
60
+ @messageHandler({cmd: 'order.create', version: 2})
61
+ async createOrder(@payload() data: CreateOrderDto): Promise<Order> {
62
+ return this.orderService.create(data);
63
+ }
64
+
65
+ // Fire-and-forget event (multiple handlers allowed per pattern)
66
+ @eventHandler('order.placed')
67
+ async handleOrderPlaced(@payload() data: OrderDto): Promise<void> {
68
+ await this.notificationService.send(data);
69
+ }
70
+
71
+ // Access broker-specific context
72
+ @messageHandler('order.process')
73
+ async processOrder(
74
+ @payload() data: OrderDto,
75
+ @transportCtx() ctx: KafkaContext,
76
+ ): Promise<void> {
77
+ const {topic, partition, offset} = ctx;
78
+ // ...
79
+ }
80
+ }
81
+ ```
82
+
83
+ Non-JSON values in object patterns (undefined, functions, symbols, NaN, Infinity, BigInt, Date, RegExp, Map, Set, class instances) throw at decoration time.
84
+
85
+ ### Client (Producer)
86
+
87
+ ```typescript
88
+ import {inject} from '@loopback/core';
89
+ import {lastValueFrom} from 'rxjs';
90
+ import {TransportBindings, TransportClient} from '@ebarahona/loopback-transport-core';
91
+
92
+ class NotificationService {
93
+ constructor(
94
+ @inject(TransportBindings.client('kafka'))
95
+ private kafka: TransportClient,
96
+ ) {}
97
+
98
+ // Fire-and-forget
99
+ async notifyShipped(order: Order): Promise<void> {
100
+ await this.kafka.emit('order.shipped', order);
101
+ }
102
+
103
+ // Request/response (object pattern)
104
+ async getOrderStatus(id: string): Promise<OrderStatus> {
105
+ return lastValueFrom(
106
+ this.kafka.send<OrderStatus>({cmd: 'order.status'}, {id}),
107
+ );
108
+ }
109
+ }
110
+ ```
111
+
112
+ ### Registering Transport Servers
113
+
114
+ Transport adapters register with typed helpers. Class and provider registrations use singleton scope so the same instance receives handlers and gets started.
115
+
116
+ ```typescript
117
+ import {TransportBindings} from '@ebarahona/loopback-transport-core';
118
+
119
+ // Concrete instance (tests, simple cases)
120
+ TransportBindings.registerServer(app, 'kafka', kafkaServer);
121
+
122
+ // Class -- IoC container instantiates with full DI (recommended)
123
+ TransportBindings.registerServerClass(app, 'kafka', KafkaServer);
124
+
125
+ // Provider -- async factory with DI
126
+ TransportBindings.registerServerProvider(app, 'kafka', KafkaServerProvider);
127
+ ```
128
+
129
+ ### Unified Execution Context
130
+
131
+ `ExecutionContext` provides a NestJS-style context model that adapters or higher-level integrations can use across HTTP, RPC, and event transports:
132
+
133
+ ```typescript
134
+ const ctx = ExecutionContext.forEvent(args, handler, controllerClass, {
135
+ getData: () => eventData,
136
+ getPattern: () => 'order.placed',
137
+ getContext: () => brokerContext,
138
+ });
139
+
140
+ const type = ctx.getType(); // 'http' | 'rpc' | 'event'
141
+ const event = ctx.switchToEvent();
142
+ const pattern = event.getPattern();
143
+ ```
144
+
145
+ Constructed via immutable factory methods (`forHttp`, `forRpc`, `forEvent`). `switchToX()` validates the context type before returning.
146
+
147
+ ## Adapter Authors
148
+
149
+ ### Extending ServerBase
150
+
151
+ ```typescript
152
+ import {ServerBase} from '@ebarahona/loopback-transport-core';
153
+
154
+ class KafkaServer extends ServerBase {
155
+ constructor() {
156
+ super({handlerTimeoutMs: 10_000}); // default 30s
157
+ }
158
+
159
+ async listen(): Promise<void> {
160
+ await this.consumer.connect();
161
+ await this.consumer.subscribe({topics: this.getTopics()});
162
+ await this.consumer.run({
163
+ eachMessage: async ({topic, partition, message}) => {
164
+ const data = await this.deserializer.deserialize(message.value);
165
+ const result = await this.handleMessage(
166
+ {pattern: topic, data, id: message.key?.toString() ?? ''},
167
+ packet => this.sendResponse(packet, topic, partition),
168
+ {topic, partition, offset: message.offset},
169
+ );
170
+ // Use result.outcome for ack/nack
171
+ },
172
+ });
173
+ this.setStatus('connected');
174
+ }
175
+
176
+ async close(): Promise<void> {
177
+ await this.consumer.disconnect();
178
+ this.setStatus('disconnected');
179
+ }
180
+
181
+ unwrap<T>(): T { return this.consumer as T; }
182
+ }
183
+ ```
184
+
185
+ **What the base class handles:**
186
+ - Handler registration and lookup via `addHandler()` / `getHandlersByPattern()`
187
+ - `clearHandlers()` for restart/rebind safety (called by the booter)
188
+ - Message dispatch with `handleMessage()` returning structured `HandlerResult`
189
+ - Event fan-out with `handleEvent()` (all handlers for a pattern execute)
190
+ - Observable handler timeout (default 30s, configurable via `handlerTimeoutMs`)
191
+ - Status stream (`status$`) that survives restart cycles
192
+ - `dispose()` for permanent shutdown (completes the status stream)
193
+
194
+ **What adapters implement:**
195
+ - `listen()` -- connect to broker, start consuming, call `setStatus('connected')`
196
+ - `close()` -- disconnect from broker, call `setStatus('disconnected')`
197
+ - `unwrap<T>()` -- expose the native client
198
+
199
+ ### Extending ClientProxy
200
+
201
+ ```typescript
202
+ import {ClientProxy} from '@ebarahona/loopback-transport-core';
203
+
204
+ class KafkaClient extends ClientProxy {
205
+ async connect(): Promise<void> {
206
+ await this.producer.connect();
207
+ }
208
+
209
+ protected async doClose(): Promise<void> {
210
+ // Called by base close() -- must handle partially opened resources
211
+ await this.producer.disconnect();
212
+ }
213
+
214
+ unwrap<T>(): T { return this.producer as T; }
215
+
216
+ protected publish(packet, callback): () => void {
217
+ // Send request, register correlation callback
218
+ }
219
+
220
+ protected async dispatchEvent(packet): Promise<void> {
221
+ // Fire-and-forget publish
222
+ }
223
+ }
224
+ ```
225
+
226
+ **What the base class handles:**
227
+ - Lazy connection on first `send()` / `emit()`
228
+ - Concurrent connect deduplication
229
+ - Epoch-based stale connection detection (close during connect)
230
+ - Close idempotency and deduplication
231
+ - Bounded wait for pending connect during close (`closeConnectTimeoutMs`, default 5s)
232
+ - State machine: `idle` -> `connecting` -> `connected` -> `closing` -> `idle`
233
+ - Status stream (`status$`)
234
+
235
+ **What adapters implement:**
236
+ - `connect()` -- establish broker connection (idempotent)
237
+ - `doClose()` -- tear down native connection (may be called with partial connect)
238
+ - `unwrap<T>()` -- expose the native client
239
+ - `publish(packet, callback)` -- send request, return teardown function
240
+ - `dispatchEvent(packet)` -- fire-and-forget publish
241
+
242
+ ### Handler Results
243
+
244
+ `handleMessage()` returns a structured `HandlerResult` for broker settlement:
245
+
246
+ ```typescript
247
+ const result = await this.handleMessage(request, respond, context);
248
+
249
+ switch (result.outcome) {
250
+ case 'success':
251
+ // Handler completed normally. Ack.
252
+ await channel.ack(msg);
253
+ break;
254
+ case 'handler-error':
255
+ // Application error. Error response sent to caller. Ack.
256
+ await channel.ack(msg);
257
+ break;
258
+ case 'infrastructure-error':
259
+ // No handler, respond() failure, or framework error. Nack/dead-letter.
260
+ await channel.nack(msg, false, false);
261
+ break;
262
+ }
263
+ ```
264
+
265
+ ## Lifecycle Behavior
266
+
267
+ - **Startup**: The `TransportBooter` lifecycle observer discovers decorated handlers from controllers, binds them to registered servers, and starts servers sequentially. If any server fails to start, all previously started servers are rolled back.
268
+ - **Shutdown**: All servers stop in parallel (best-effort). Individual stop failures are logged but do not prevent other servers from stopping.
269
+ - **Restart**: Handlers are cleared from servers and rebound on each start. Status streams survive `close()` -- only `dispose()` completes them permanently.
270
+ - **Singleton scope**: `registerServerClass()` and `registerServerProvider()` bind in singleton scope so the same instance receives handlers and gets started.
271
+
272
+ ## Transport Modules
273
+
274
+ Companion adapter packages, each implementing `TransportServer` and `TransportClient`:
275
+
276
+ | Package | Transport | Native Client |
277
+ |---|---|---|
278
+ | `@ebarahona/loopback-transport-kafka` | Apache Kafka | `kafkajs` |
279
+ | `@ebarahona/loopback-transport-rabbitmq` | RabbitMQ | `amqplib` |
280
+ | `@ebarahona/loopback-transport-grpc` | gRPC | `@grpc/grpc-js` |
281
+ | `@ebarahona/loopback-transport-mqtt` | MQTT | `mqtt` |
282
+ | `@ebarahona/loopback-transport-nats` | NATS | `nats` |
283
+
284
+ ## Requirements
285
+
286
+ - Node.js >= 18
287
+ - LoopBack 4 application
288
+
289
+ Peer dependencies (`@loopback/core`, `@loopback/metadata`) are satisfied by any LoopBack 4 project. Runtime dependencies (`rxjs`, `debug`) are installed automatically.
290
+
291
+ ## Contributing
292
+
293
+ See [CONTRIBUTING.md](CONTRIBUTING.md).
294
+
295
+ ## License
296
+
297
+ MIT
@@ -0,0 +1,147 @@
1
+ import { Observable, ReplaySubject } from 'rxjs';
2
+ import { TransportClient, TransportStatus, WritePacket, ReadPacket, PacketId } from '../interfaces';
3
+ /**
4
+ * Abstract base class for transport clients.
5
+ *
6
+ * ## Lifecycle contract
7
+ *
8
+ * - `send()`/`emit()` lazily connect on first use.
9
+ * - Concurrent send/emit calls share one connect().
10
+ * - `close()` transitions to 'closing', waits for any pending connect
11
+ * (bounded by `closeConnectTimeoutMs`, default 5s), calls doClose(),
12
+ * then transitions to 'idle'.
13
+ * - If the pending connect does not settle within the timeout, close
14
+ * proceeds anyway. The adapter's doClose() must safely tear down
15
+ * partially opened native resources in this case.
16
+ * - `send()`/`emit()` during 'closing' fail fast.
17
+ * - After close, the next send/emit reconnects.
18
+ * - A stale connect that resolves after close always rejects for its
19
+ * original caller, even if a newer connection is already healthy.
20
+ * - State is always reset in finally -- failed doClose() does not
21
+ * leave the client stuck.
22
+ *
23
+ * ## Stale async detection
24
+ *
25
+ * A monotonic `epoch` counter increments on each close(). The
26
+ * connect callback only transitions to 'connected' if the epoch
27
+ * has not changed, preventing a late-resolving connect from
28
+ * overriding a close that happened while it was pending.
29
+ *
30
+ * The connectionPromise is only cleared by its own .finally()
31
+ * callback if it is still the current promise, preventing a stale
32
+ * promise from clobbering a newer one.
33
+ */
34
+ export declare abstract class ClientProxy implements TransportClient {
35
+ protected readonly statusSubject: ReplaySubject<TransportStatus>;
36
+ private state;
37
+ private connectionPromise?;
38
+ private connectionEpoch?;
39
+ private closePromise?;
40
+ private epoch;
41
+ private readonly closeConnectTimeoutMs;
42
+ readonly status$: Observable<TransportStatus>;
43
+ constructor(options?: {
44
+ closeConnectTimeoutMs?: number;
45
+ });
46
+ /**
47
+ * Establish connection to the broker.
48
+ * Implementations should be idempotent (safe to call multiple times).
49
+ */
50
+ abstract connect(): Promise<void>;
51
+ /**
52
+ * Close the native connection. Implemented by transport adapters.
53
+ * Called by the base close() template method after waiting for any
54
+ * pending connect (bounded by closeConnectTimeoutMs). If the timeout
55
+ * fires before connect settles, doClose() is still called -- adapters
56
+ * must handle partially opened native resources in that case.
57
+ */
58
+ protected abstract doClose(): Promise<void>;
59
+ /**
60
+ * Close the connection and reset state.
61
+ *
62
+ * - If idle with no pending connect, returns immediately (no-op).
63
+ * - If already closing, returns the existing close promise (dedupe).
64
+ * - Otherwise:
65
+ * 1. Increments epoch to invalidate any in-flight connect().
66
+ * 2. Transitions to 'closing' so new send/emit fails fast.
67
+ * 3. Waits for pending connect (bounded by closeConnectTimeoutMs).
68
+ * 4. Calls doClose() for adapter-specific teardown.
69
+ * 5. Resets to 'idle' in finally (even if doClose throws).
70
+ *
71
+ * After close, the client is reusable: the next send/emit
72
+ * will trigger a fresh connect().
73
+ */
74
+ close(): Promise<void>;
75
+ private doCloseInternal;
76
+ /**
77
+ * Wait for a pending connect to settle, bounded by timeout.
78
+ * If connect does not settle in time, proceed anyway -- the epoch
79
+ * invalidation ensures the stale connect result is ignored.
80
+ */
81
+ private waitForPendingConnect;
82
+ /**
83
+ * Access the underlying native client.
84
+ */
85
+ abstract unwrap<T>(): T;
86
+ /**
87
+ * Send a message and register a callback for the correlated response.
88
+ * Returns a teardown function to cancel the pending response.
89
+ */
90
+ protected abstract publish(packet: ReadPacket & PacketId, callback: (packet: WritePacket) => void): () => void;
91
+ /**
92
+ * Dispatch a fire-and-forget event.
93
+ */
94
+ protected abstract dispatchEvent(packet: ReadPacket): Promise<void>;
95
+ /**
96
+ * Send a request and receive a response (request/response pattern).
97
+ *
98
+ * Returns a cold Observable: the message is sent only when subscribed.
99
+ * The connection is established lazily on first subscription.
100
+ */
101
+ send<TResult = unknown, TInput = unknown>(pattern: string | Record<string, unknown>, data: TInput): Observable<TResult>;
102
+ /**
103
+ * Emit an event with no response expected (fire-and-forget pattern).
104
+ *
105
+ * Returns a Promise that resolves when the event is dispatched.
106
+ * The connection is established lazily if not already connected.
107
+ */
108
+ emit<TInput = unknown>(pattern: string | Record<string, unknown>, data: TInput): Promise<void>;
109
+ /**
110
+ * Assign a unique correlation ID to a packet.
111
+ * Uses crypto.randomUUID() for distributed-system-safe IDs.
112
+ */
113
+ protected assignPacketId(packet: ReadPacket): ReadPacket & PacketId;
114
+ /**
115
+ * Create an Observer callback that maps WritePacket to Observable emissions.
116
+ */
117
+ protected createObserver<T>(observer: {
118
+ next: (value: T) => void;
119
+ error: (err: unknown) => void;
120
+ complete: () => void;
121
+ }): (packet: WritePacket) => void;
122
+ /**
123
+ * Verify the client is in connected state.
124
+ * Throws if a concurrent close() prevented the transition.
125
+ */
126
+ private assertConnected;
127
+ /**
128
+ * Ensure the client is connected. Concurrent calls share the same
129
+ * connection promise to prevent duplicate connections.
130
+ *
131
+ * - 'closing': reject immediately.
132
+ * - 'connected': return immediately.
133
+ * - 'connecting': await the shared promise.
134
+ * - 'idle': start connect, capture epoch, only transition to
135
+ * 'connected' if epoch hasn't changed.
136
+ *
137
+ * After await, the caller validates that the epoch it waited on
138
+ * still matches. A stale connect that was invalidated by close()
139
+ * always rejects for its original caller, even if a newer
140
+ * connection is already healthy.
141
+ *
142
+ * The connectionPromise/connectionEpoch are only cleared in
143
+ * .finally() if the finishing promise is still the current one,
144
+ * preventing a stale promise from clobbering a newer one.
145
+ */
146
+ private ensureConnected;
147
+ }
@@ -0,0 +1,225 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ClientProxy = void 0;
4
+ const crypto_1 = require("crypto");
5
+ const rxjs_1 = require("rxjs");
6
+ const utils_1 = require("../utils");
7
+ /**
8
+ * Abstract base class for transport clients.
9
+ *
10
+ * ## Lifecycle contract
11
+ *
12
+ * - `send()`/`emit()` lazily connect on first use.
13
+ * - Concurrent send/emit calls share one connect().
14
+ * - `close()` transitions to 'closing', waits for any pending connect
15
+ * (bounded by `closeConnectTimeoutMs`, default 5s), calls doClose(),
16
+ * then transitions to 'idle'.
17
+ * - If the pending connect does not settle within the timeout, close
18
+ * proceeds anyway. The adapter's doClose() must safely tear down
19
+ * partially opened native resources in this case.
20
+ * - `send()`/`emit()` during 'closing' fail fast.
21
+ * - After close, the next send/emit reconnects.
22
+ * - A stale connect that resolves after close always rejects for its
23
+ * original caller, even if a newer connection is already healthy.
24
+ * - State is always reset in finally -- failed doClose() does not
25
+ * leave the client stuck.
26
+ *
27
+ * ## Stale async detection
28
+ *
29
+ * A monotonic `epoch` counter increments on each close(). The
30
+ * connect callback only transitions to 'connected' if the epoch
31
+ * has not changed, preventing a late-resolving connect from
32
+ * overriding a close that happened while it was pending.
33
+ *
34
+ * The connectionPromise is only cleared by its own .finally()
35
+ * callback if it is still the current promise, preventing a stale
36
+ * promise from clobbering a newer one.
37
+ */
38
+ class ClientProxy {
39
+ constructor(options) {
40
+ this.statusSubject = new rxjs_1.ReplaySubject(1);
41
+ this.state = 'idle';
42
+ this.epoch = 0;
43
+ this.status$ = this.statusSubject.asObservable();
44
+ this.closeConnectTimeoutMs = options?.closeConnectTimeoutMs ?? 5000;
45
+ }
46
+ /**
47
+ * Close the connection and reset state.
48
+ *
49
+ * - If idle with no pending connect, returns immediately (no-op).
50
+ * - If already closing, returns the existing close promise (dedupe).
51
+ * - Otherwise:
52
+ * 1. Increments epoch to invalidate any in-flight connect().
53
+ * 2. Transitions to 'closing' so new send/emit fails fast.
54
+ * 3. Waits for pending connect (bounded by closeConnectTimeoutMs).
55
+ * 4. Calls doClose() for adapter-specific teardown.
56
+ * 5. Resets to 'idle' in finally (even if doClose throws).
57
+ *
58
+ * After close, the client is reusable: the next send/emit
59
+ * will trigger a fresh connect().
60
+ */
61
+ async close() {
62
+ if (this.state === 'idle' && !this.connectionPromise)
63
+ return;
64
+ if (this.closePromise)
65
+ return this.closePromise;
66
+ this.closePromise = this.doCloseInternal().finally(() => {
67
+ this.closePromise = undefined;
68
+ });
69
+ return this.closePromise;
70
+ }
71
+ async doCloseInternal() {
72
+ this.epoch++;
73
+ this.state = 'closing';
74
+ // Wait for pending connect with timeout so close never hangs
75
+ await this.waitForPendingConnect();
76
+ try {
77
+ await this.doClose();
78
+ }
79
+ finally {
80
+ this.state = 'idle';
81
+ this.connectionPromise = undefined;
82
+ this.connectionEpoch = undefined;
83
+ this.statusSubject.next('disconnected');
84
+ }
85
+ }
86
+ /**
87
+ * Wait for a pending connect to settle, bounded by timeout.
88
+ * If connect does not settle in time, proceed anyway -- the epoch
89
+ * invalidation ensures the stale connect result is ignored.
90
+ */
91
+ async waitForPendingConnect() {
92
+ const pending = this.connectionPromise;
93
+ if (!pending)
94
+ return;
95
+ await Promise.race([
96
+ pending.catch(() => undefined),
97
+ new Promise(resolve => setTimeout(resolve, this.closeConnectTimeoutMs)),
98
+ ]);
99
+ }
100
+ /**
101
+ * Send a request and receive a response (request/response pattern).
102
+ *
103
+ * Returns a cold Observable: the message is sent only when subscribed.
104
+ * The connection is established lazily on first subscription.
105
+ */
106
+ send(pattern, data) {
107
+ const normalizedPattern = (0, utils_1.normalizePattern)(pattern);
108
+ return (0, rxjs_1.defer)(async () => this.ensureConnected()).pipe((0, rxjs_1.mergeMap)(() => new rxjs_1.Observable(observer => {
109
+ const packet = this.assignPacketId({
110
+ pattern: normalizedPattern,
111
+ data,
112
+ });
113
+ const callback = this.createObserver(observer);
114
+ return this.publish(packet, callback);
115
+ })));
116
+ }
117
+ /**
118
+ * Emit an event with no response expected (fire-and-forget pattern).
119
+ *
120
+ * Returns a Promise that resolves when the event is dispatched.
121
+ * The connection is established lazily if not already connected.
122
+ */
123
+ async emit(pattern, data) {
124
+ const normalizedPattern = (0, utils_1.normalizePattern)(pattern);
125
+ await this.ensureConnected();
126
+ await this.dispatchEvent({ pattern: normalizedPattern, data });
127
+ }
128
+ /**
129
+ * Assign a unique correlation ID to a packet.
130
+ * Uses crypto.randomUUID() for distributed-system-safe IDs.
131
+ */
132
+ assignPacketId(packet) {
133
+ return { ...packet, id: (0, crypto_1.randomUUID)() };
134
+ }
135
+ /**
136
+ * Create an Observer callback that maps WritePacket to Observable emissions.
137
+ */
138
+ createObserver(observer) {
139
+ return (packet) => {
140
+ if ('err' in packet && packet.err !== undefined) {
141
+ return observer.error(packet.err);
142
+ }
143
+ if ('response' in packet) {
144
+ observer.next(packet.response);
145
+ }
146
+ if (packet.isDisposed) {
147
+ return observer.complete();
148
+ }
149
+ };
150
+ }
151
+ /**
152
+ * Verify the client is in connected state.
153
+ * Throws if a concurrent close() prevented the transition.
154
+ */
155
+ assertConnected() {
156
+ if (this.state !== 'connected') {
157
+ throw new Error('Client was closed during connection');
158
+ }
159
+ }
160
+ /**
161
+ * Ensure the client is connected. Concurrent calls share the same
162
+ * connection promise to prevent duplicate connections.
163
+ *
164
+ * - 'closing': reject immediately.
165
+ * - 'connected': return immediately.
166
+ * - 'connecting': await the shared promise.
167
+ * - 'idle': start connect, capture epoch, only transition to
168
+ * 'connected' if epoch hasn't changed.
169
+ *
170
+ * After await, the caller validates that the epoch it waited on
171
+ * still matches. A stale connect that was invalidated by close()
172
+ * always rejects for its original caller, even if a newer
173
+ * connection is already healthy.
174
+ *
175
+ * The connectionPromise/connectionEpoch are only cleared in
176
+ * .finally() if the finishing promise is still the current one,
177
+ * preventing a stale promise from clobbering a newer one.
178
+ */
179
+ async ensureConnected() {
180
+ if (this.state === 'closing') {
181
+ throw new Error('Client is closing');
182
+ }
183
+ if (this.state === 'connected')
184
+ return;
185
+ // Capture the epoch this caller will wait on
186
+ const awaitedEpoch = this.connectionEpoch ?? this.epoch;
187
+ if (!this.connectionPromise) {
188
+ const ep = this.epoch;
189
+ this.connectionEpoch = ep;
190
+ this.state = 'connecting';
191
+ const promise = this.connect()
192
+ .then(() => {
193
+ if (this.epoch === ep) {
194
+ this.state = 'connected';
195
+ this.statusSubject.next('connected');
196
+ }
197
+ else {
198
+ throw new Error('Client was closed during connection');
199
+ }
200
+ })
201
+ .catch(err => {
202
+ if (this.epoch === ep) {
203
+ this.state = 'idle';
204
+ }
205
+ throw err;
206
+ })
207
+ .finally(() => {
208
+ if (this.connectionPromise === promise) {
209
+ this.connectionPromise = undefined;
210
+ this.connectionEpoch = undefined;
211
+ }
212
+ });
213
+ this.connectionPromise = promise;
214
+ }
215
+ await this.connectionPromise;
216
+ // Validate that this caller's epoch is still current.
217
+ // A stale caller whose connect was invalidated must not proceed.
218
+ if (this.epoch !== awaitedEpoch) {
219
+ throw new Error('Client was closed during connection');
220
+ }
221
+ this.assertConnected();
222
+ }
223
+ }
224
+ exports.ClientProxy = ClientProxy;
225
+ //# sourceMappingURL=client-proxy.js.map