@ebarahona/loopback-transport-core 1.0.0 → 1.1.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/README.md +442 -35
- package/dist/client/client-proxy.d.ts +73 -42
- package/dist/client/client-proxy.js +71 -42
- package/dist/client/client-proxy.js.map +1 -1
- package/dist/client/index.d.ts +1 -1
- package/dist/client/index.js +3 -15
- package/dist/client/index.js.map +1 -1
- package/dist/context/execution-context.d.ts +58 -11
- package/dist/context/execution-context.js +62 -17
- package/dist/context/execution-context.js.map +1 -1
- package/dist/context/index.d.ts +2 -1
- package/dist/context/index.js +3 -15
- package/dist/context/index.js.map +1 -1
- package/dist/decorators/constants.d.ts +20 -10
- package/dist/decorators/constants.js +6 -2
- package/dist/decorators/constants.js.map +1 -1
- package/dist/decorators/event-handler.decorator.d.ts +10 -7
- package/dist/decorators/event-handler.decorator.js +18 -13
- package/dist/decorators/event-handler.decorator.js.map +1 -1
- package/dist/decorators/index.d.ts +5 -4
- package/dist/decorators/index.js +11 -18
- package/dist/decorators/index.js.map +1 -1
- package/dist/decorators/message-handler.decorator.d.ts +8 -5
- package/dist/decorators/message-handler.decorator.js +16 -11
- package/dist/decorators/message-handler.decorator.js.map +1 -1
- package/dist/decorators/payload.decorator.d.ts +16 -7
- package/dist/decorators/payload.decorator.js +16 -8
- package/dist/decorators/payload.decorator.js.map +1 -1
- package/dist/discovery/discovery.service.d.ts +143 -0
- package/dist/discovery/discovery.service.js +165 -0
- package/dist/discovery/discovery.service.js.map +1 -0
- package/dist/discovery/event-handler-discoverer.d.ts +19 -0
- package/dist/discovery/event-handler-discoverer.js +50 -0
- package/dist/discovery/event-handler-discoverer.js.map +1 -0
- package/dist/discovery/handler-discoverer.d.ts +48 -0
- package/dist/discovery/handler-discoverer.js +3 -0
- package/dist/discovery/handler-discoverer.js.map +1 -0
- package/dist/discovery/handler-kind.d.ts +23 -0
- package/dist/discovery/handler-kind.js +14 -0
- package/dist/discovery/handler-kind.js.map +1 -0
- package/dist/discovery/index.d.ts +7 -0
- package/dist/discovery/index.js +13 -0
- package/dist/discovery/index.js.map +1 -0
- package/dist/discovery/message-handler-discoverer.d.ts +19 -0
- package/dist/discovery/message-handler-discoverer.js +50 -0
- package/dist/discovery/message-handler-discoverer.js.map +1 -0
- package/dist/discovery/registered-handler.d.ts +35 -0
- package/dist/discovery/registered-handler.js +3 -0
- package/dist/discovery/registered-handler.js.map +1 -0
- package/dist/helpers/errors.d.ts +61 -0
- package/dist/helpers/errors.js +80 -0
- package/dist/helpers/errors.js.map +1 -0
- package/dist/helpers/index.d.ts +2 -0
- package/dist/helpers/index.js +14 -0
- package/dist/helpers/index.js.map +1 -0
- package/dist/helpers/register-server.d.ts +44 -0
- package/dist/helpers/register-server.js +72 -0
- package/dist/helpers/register-server.js.map +1 -0
- package/dist/index.d.ts +15 -7
- package/dist/index.js +33 -9
- package/dist/index.js.map +1 -1
- package/dist/interfaces/index.d.ts +4 -4
- package/dist/interfaces/index.js +0 -18
- package/dist/interfaces/index.js.map +1 -1
- package/dist/interfaces/message-handler.interface.d.ts +12 -6
- package/dist/interfaces/packet.interface.d.ts +20 -2
- package/dist/interfaces/transport-client.interface.d.ts +25 -13
- package/dist/interfaces/transport-server.interface.d.ts +36 -10
- package/dist/keys.d.ts +132 -46
- package/dist/keys.js +131 -68
- package/dist/keys.js.map +1 -1
- package/dist/registry/handler-registry.d.ts +94 -24
- package/dist/registry/handler-registry.js +195 -89
- package/dist/registry/handler-registry.js.map +1 -1
- package/dist/registry/index.d.ts +1 -1
- package/dist/registry/index.js +3 -15
- package/dist/registry/index.js.map +1 -1
- package/dist/serializers/cloudevents-serializer.d.ts +107 -0
- package/dist/serializers/cloudevents-serializer.js +130 -0
- package/dist/serializers/cloudevents-serializer.js.map +1 -0
- package/dist/serializers/index.d.ts +4 -1
- package/dist/serializers/index.js +7 -15
- package/dist/serializers/index.js.map +1 -1
- package/dist/serializers/serializer.interface.d.ts +49 -16
- package/dist/serializers/serializer.interface.js +41 -15
- package/dist/serializers/serializer.interface.js.map +1 -1
- package/dist/server/index.d.ts +2 -1
- package/dist/server/index.js +3 -15
- package/dist/server/index.js.map +1 -1
- package/dist/server/server-base.d.ts +125 -52
- package/dist/server/server-base.js +168 -57
- package/dist/server/server-base.js.map +1 -1
- package/dist/transport.component.d.ts +21 -8
- package/dist/transport.component.js +40 -18
- package/dist/transport.component.js.map +1 -1
- package/dist/transport.observer.d.ts +87 -0
- package/dist/transport.observer.js +296 -0
- package/dist/transport.observer.js.map +1 -0
- package/dist/utils/normalize-pattern.d.ts +11 -6
- package/dist/utils/normalize-pattern.js +18 -13
- package/dist/utils/normalize-pattern.js.map +1 -1
- package/package.json +58 -16
- package/dist/transport-booter.d.ts +0 -31
- package/dist/transport-booter.js +0 -117
- package/dist/transport-booter.js.map +0 -1
package/README.md
CHANGED
|
@@ -8,25 +8,29 @@ This package is **transport core only** -- it provides the framework abstraction
|
|
|
8
8
|
npm install @ebarahona/loopback-transport-core
|
|
9
9
|
```
|
|
10
10
|
|
|
11
|
+
> Part of the [`@ebarahona/loopback-*` plugin portfolio](https://github.com/ebarahona/loopback-plugins). See the [portfolio roadmap](https://github.com/ebarahona/loopback-plugins/blob/main/ROADMAP.md) for sibling plugins (`loopback-connector-mongodb`, planned `loopback-graphql`) and the shared infrastructure they use.
|
|
12
|
+
|
|
11
13
|
## What This Provides
|
|
12
14
|
|
|
13
|
-
| Export
|
|
14
|
-
|
|
15
|
-
| `TransportComponent`
|
|
16
|
-
| `@messageHandler(pattern)`
|
|
17
|
-
| `@eventHandler(pattern)`
|
|
18
|
-
| `@payload()`
|
|
19
|
-
| `@transportCtx()`
|
|
20
|
-
| `ClientProxy`
|
|
21
|
-
| `ServerBase`
|
|
22
|
-
| `ExecutionContext`
|
|
23
|
-
| `HandlerResult`
|
|
24
|
-
| `TransportBindings`
|
|
25
|
-
| `
|
|
26
|
-
| `
|
|
27
|
-
| `
|
|
28
|
-
| `
|
|
29
|
-
| `
|
|
15
|
+
| Export | Purpose |
|
|
16
|
+
| ----------------------------------------- | ----------------------------------------------------------------------------- |
|
|
17
|
+
| `TransportComponent` | LoopBack 4 component (registers registry + booter) |
|
|
18
|
+
| `@messageHandler(pattern)` | Request/response handler decorator |
|
|
19
|
+
| `@eventHandler(pattern)` | Fire-and-forget event handler decorator |
|
|
20
|
+
| `@payload()` | Injects the message data (transport invocation context, not HTTP) |
|
|
21
|
+
| `@transportCtx()` | Injects the broker-specific context (transport invocation context, not HTTP) |
|
|
22
|
+
| `ClientProxy` | Abstract client with `send()` (cold Observable) and `emit()` (Promise) |
|
|
23
|
+
| `ServerBase` | Abstract server with handler registry, dispatch, and structured results |
|
|
24
|
+
| `ExecutionContext` | Unified context across HTTP, RPC, and event transports |
|
|
25
|
+
| `HandlerResult` | Structured settlement result for adapter ack/nack decisions |
|
|
26
|
+
| `TransportBindings` | Typed binding keys and server registration helpers |
|
|
27
|
+
| `DiscoveryService` | Read-only enumeration of every discovered handler (NestJS-style) |
|
|
28
|
+
| `HandlerDiscoverer` | Plugin extension point for custom decorator vocabularies |
|
|
29
|
+
| `normalizePattern()` | Deterministic pattern key generation |
|
|
30
|
+
| `Serializer` / `Deserializer` | Pluggable serialization (JSON default) |
|
|
31
|
+
| `TransportServer` / `TransportClient` | Adapter interfaces |
|
|
32
|
+
| `TransportStatus` | Connection status type (`connected`, `disconnected`, `reconnecting`, `error`) |
|
|
33
|
+
| `ReadPacket` / `WritePacket` / `PacketId` | Request/response correlation types |
|
|
30
34
|
|
|
31
35
|
## Usage
|
|
32
36
|
|
|
@@ -34,7 +38,10 @@ npm install @ebarahona/loopback-transport-core
|
|
|
34
38
|
|
|
35
39
|
```typescript
|
|
36
40
|
import {Application} from '@loopback/core';
|
|
37
|
-
import {
|
|
41
|
+
import {
|
|
42
|
+
TransportComponent,
|
|
43
|
+
TransportBindings,
|
|
44
|
+
} from '@ebarahona/loopback-transport-core';
|
|
38
45
|
|
|
39
46
|
const app = new Application();
|
|
40
47
|
app.component(TransportComponent);
|
|
@@ -46,8 +53,16 @@ Works with `RestApplication` for hybrid HTTP + transport apps, or plain `Applica
|
|
|
46
53
|
|
|
47
54
|
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
55
|
|
|
56
|
+
<details>
|
|
57
|
+
<summary><b>Show example: controller with message and event handlers</b></summary>
|
|
58
|
+
|
|
49
59
|
```typescript
|
|
50
|
-
import {
|
|
60
|
+
import {
|
|
61
|
+
messageHandler,
|
|
62
|
+
eventHandler,
|
|
63
|
+
payload,
|
|
64
|
+
transportCtx,
|
|
65
|
+
} from '@ebarahona/loopback-transport-core';
|
|
51
66
|
|
|
52
67
|
class OrderController {
|
|
53
68
|
// String pattern -- request/response
|
|
@@ -80,14 +95,22 @@ class OrderController {
|
|
|
80
95
|
}
|
|
81
96
|
```
|
|
82
97
|
|
|
98
|
+
</details>
|
|
99
|
+
|
|
83
100
|
Non-JSON values in object patterns (undefined, functions, symbols, NaN, Infinity, BigInt, Date, RegExp, Map, Set, class instances) throw at decoration time.
|
|
84
101
|
|
|
85
102
|
### Client (Producer)
|
|
86
103
|
|
|
104
|
+
<details>
|
|
105
|
+
<summary><b>Show example: injecting a TransportClient and using send/emit</b></summary>
|
|
106
|
+
|
|
87
107
|
```typescript
|
|
88
108
|
import {inject} from '@loopback/core';
|
|
89
109
|
import {lastValueFrom} from 'rxjs';
|
|
90
|
-
import {
|
|
110
|
+
import {
|
|
111
|
+
TransportBindings,
|
|
112
|
+
TransportClient,
|
|
113
|
+
} from '@ebarahona/loopback-transport-core';
|
|
91
114
|
|
|
92
115
|
class NotificationService {
|
|
93
116
|
constructor(
|
|
@@ -109,10 +132,15 @@ class NotificationService {
|
|
|
109
132
|
}
|
|
110
133
|
```
|
|
111
134
|
|
|
135
|
+
</details>
|
|
136
|
+
|
|
112
137
|
### Registering Transport Servers
|
|
113
138
|
|
|
114
139
|
Transport adapters register with typed helpers. Class and provider registrations use singleton scope so the same instance receives handlers and gets started.
|
|
115
140
|
|
|
141
|
+
<details>
|
|
142
|
+
<summary><b>Show example: three server-registration shapes</b></summary>
|
|
143
|
+
|
|
116
144
|
```typescript
|
|
117
145
|
import {TransportBindings} from '@ebarahona/loopback-transport-core';
|
|
118
146
|
|
|
@@ -126,10 +154,15 @@ TransportBindings.registerServerClass(app, 'kafka', KafkaServer);
|
|
|
126
154
|
TransportBindings.registerServerProvider(app, 'kafka', KafkaServerProvider);
|
|
127
155
|
```
|
|
128
156
|
|
|
157
|
+
</details>
|
|
158
|
+
|
|
129
159
|
### Unified Execution Context
|
|
130
160
|
|
|
131
161
|
`ExecutionContext` provides a NestJS-style context model that adapters or higher-level integrations can use across HTTP, RPC, and event transports:
|
|
132
162
|
|
|
163
|
+
<details>
|
|
164
|
+
<summary><b>Show example: building an event ExecutionContext</b></summary>
|
|
165
|
+
|
|
133
166
|
```typescript
|
|
134
167
|
const ctx = ExecutionContext.forEvent(args, handler, controllerClass, {
|
|
135
168
|
getData: () => eventData,
|
|
@@ -142,20 +175,21 @@ const event = ctx.switchToEvent();
|
|
|
142
175
|
const pattern = event.getPattern();
|
|
143
176
|
```
|
|
144
177
|
|
|
178
|
+
</details>
|
|
179
|
+
|
|
145
180
|
Constructed via immutable factory methods (`forHttp`, `forRpc`, `forEvent`). `switchToX()` validates the context type before returning.
|
|
146
181
|
|
|
147
182
|
## Adapter Authors
|
|
148
183
|
|
|
149
184
|
### Extending ServerBase
|
|
150
185
|
|
|
186
|
+
<details>
|
|
187
|
+
<summary><b>Show example: KafkaServer extending ServerBase</b></summary>
|
|
188
|
+
|
|
151
189
|
```typescript
|
|
152
190
|
import {ServerBase} from '@ebarahona/loopback-transport-core';
|
|
153
191
|
|
|
154
192
|
class KafkaServer extends ServerBase {
|
|
155
|
-
constructor() {
|
|
156
|
-
super({handlerTimeoutMs: 10_000}); // default 30s
|
|
157
|
-
}
|
|
158
|
-
|
|
159
193
|
async listen(): Promise<void> {
|
|
160
194
|
await this.consumer.connect();
|
|
161
195
|
await this.consumer.subscribe({topics: this.getTopics()});
|
|
@@ -178,11 +212,16 @@ class KafkaServer extends ServerBase {
|
|
|
178
212
|
this.setStatus('disconnected');
|
|
179
213
|
}
|
|
180
214
|
|
|
181
|
-
unwrap<T>(): T {
|
|
215
|
+
unwrap<T>(): T {
|
|
216
|
+
return this.consumer as T;
|
|
217
|
+
}
|
|
182
218
|
}
|
|
183
219
|
```
|
|
184
220
|
|
|
221
|
+
</details>
|
|
222
|
+
|
|
185
223
|
**What the base class handles:**
|
|
224
|
+
|
|
186
225
|
- Handler registration and lookup via `addHandler()` / `getHandlersByPattern()`
|
|
187
226
|
- `clearHandlers()` for restart/rebind safety (called by the booter)
|
|
188
227
|
- Message dispatch with `handleMessage()` returning structured `HandlerResult`
|
|
@@ -192,12 +231,16 @@ class KafkaServer extends ServerBase {
|
|
|
192
231
|
- `dispose()` for permanent shutdown (completes the status stream)
|
|
193
232
|
|
|
194
233
|
**What adapters implement:**
|
|
234
|
+
|
|
195
235
|
- `listen()` -- connect to broker, start consuming, call `setStatus('connected')`
|
|
196
236
|
- `close()` -- disconnect from broker, call `setStatus('disconnected')`
|
|
197
237
|
- `unwrap<T>()` -- expose the native client
|
|
198
238
|
|
|
199
239
|
### Extending ClientProxy
|
|
200
240
|
|
|
241
|
+
<details>
|
|
242
|
+
<summary><b>Show example: KafkaClient extending ClientProxy</b></summary>
|
|
243
|
+
|
|
201
244
|
```typescript
|
|
202
245
|
import {ClientProxy} from '@ebarahona/loopback-transport-core';
|
|
203
246
|
|
|
@@ -211,7 +254,9 @@ class KafkaClient extends ClientProxy {
|
|
|
211
254
|
await this.producer.disconnect();
|
|
212
255
|
}
|
|
213
256
|
|
|
214
|
-
unwrap<T>(): T {
|
|
257
|
+
unwrap<T>(): T {
|
|
258
|
+
return this.producer as T;
|
|
259
|
+
}
|
|
215
260
|
|
|
216
261
|
protected publish(packet, callback): () => void {
|
|
217
262
|
// Send request, register correlation callback
|
|
@@ -223,7 +268,10 @@ class KafkaClient extends ClientProxy {
|
|
|
223
268
|
}
|
|
224
269
|
```
|
|
225
270
|
|
|
271
|
+
</details>
|
|
272
|
+
|
|
226
273
|
**What the base class handles:**
|
|
274
|
+
|
|
227
275
|
- Lazy connection on first `send()` / `emit()`
|
|
228
276
|
- Concurrent connect deduplication
|
|
229
277
|
- Epoch-based stale connection detection (close during connect)
|
|
@@ -233,6 +281,7 @@ class KafkaClient extends ClientProxy {
|
|
|
233
281
|
- Status stream (`status$`)
|
|
234
282
|
|
|
235
283
|
**What adapters implement:**
|
|
284
|
+
|
|
236
285
|
- `connect()` -- establish broker connection (idempotent)
|
|
237
286
|
- `doClose()` -- tear down native connection (may be called with partial connect)
|
|
238
287
|
- `unwrap<T>()` -- expose the native client
|
|
@@ -243,6 +292,9 @@ class KafkaClient extends ClientProxy {
|
|
|
243
292
|
|
|
244
293
|
`handleMessage()` returns a structured `HandlerResult` for broker settlement:
|
|
245
294
|
|
|
295
|
+
<details>
|
|
296
|
+
<summary><b>Show example: dispatching on HandlerResult.outcome</b></summary>
|
|
297
|
+
|
|
246
298
|
```typescript
|
|
247
299
|
const result = await this.handleMessage(request, respond, context);
|
|
248
300
|
|
|
@@ -262,6 +314,190 @@ switch (result.outcome) {
|
|
|
262
314
|
}
|
|
263
315
|
```
|
|
264
316
|
|
|
317
|
+
</details>
|
|
318
|
+
|
|
319
|
+
## Custom decorator vocabularies
|
|
320
|
+
|
|
321
|
+
Transport-core ships with two decorator vocabularies (`@messageHandler` and `@eventHandler`), but the discovery layer is open. Plugins (gRPC routes, cron schedules, WebSocket subscriptions, etc.) can contribute their own decorators by implementing `HandlerDiscoverer` and binding it under `HANDLER_DISCOVERER_TAG`. The handler registry consults every bound discoverer for every controller at boot, so the built-in decorators and any plugin decorators coexist without modifications to transport-core.
|
|
322
|
+
|
|
323
|
+
```typescript
|
|
324
|
+
import type {Constructor} from '@loopback/core';
|
|
325
|
+
import type {
|
|
326
|
+
HandlerKind,
|
|
327
|
+
HandlerOptions,
|
|
328
|
+
} from '@ebarahona/loopback-transport-core';
|
|
329
|
+
|
|
330
|
+
interface DiscoveredHandler {
|
|
331
|
+
pattern: string | Record<string, unknown>;
|
|
332
|
+
transport: string; // '*' for wildcard
|
|
333
|
+
kind: HandlerKind; // open string type; see below
|
|
334
|
+
methodName: string;
|
|
335
|
+
options?: HandlerOptions;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
interface HandlerDiscoverer {
|
|
339
|
+
readonly id: string;
|
|
340
|
+
discover(
|
|
341
|
+
controllerClass: Constructor<unknown>,
|
|
342
|
+
): DiscoveredHandler[] | Promise<DiscoveredHandler[]>;
|
|
343
|
+
}
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
The `kind` field is an open string type (`HandlerKind = 'request' | 'event' | (string & {})`). The well-known constants `HANDLER_KIND_REQUEST` and `HANDLER_KIND_EVENT` are exported for the default decorators; plugins are free to declare additional kinds such as `'cron'`, `'subscription'`, or `'stream'` without waiting for a transport-core release.
|
|
347
|
+
|
|
348
|
+
A hypothetical `loopback-transport-grpc` plugin defining `@grpcRoute` would look like:
|
|
349
|
+
|
|
350
|
+
<details>
|
|
351
|
+
<summary><b>Show example: a plugin contributing its own decorator vocabulary</b></summary>
|
|
352
|
+
|
|
353
|
+
```typescript
|
|
354
|
+
import {
|
|
355
|
+
Binding,
|
|
356
|
+
BindingScope,
|
|
357
|
+
Component,
|
|
358
|
+
Constructor,
|
|
359
|
+
MetadataInspector,
|
|
360
|
+
} from '@loopback/core';
|
|
361
|
+
import {MetadataAccessor, MethodDecoratorFactory} from '@loopback/metadata';
|
|
362
|
+
import {
|
|
363
|
+
HANDLER_DISCOVERER_TAG,
|
|
364
|
+
type DiscoveredHandler,
|
|
365
|
+
type HandlerDiscoverer,
|
|
366
|
+
} from '@ebarahona/loopback-transport-core';
|
|
367
|
+
|
|
368
|
+
interface GrpcRouteMetadata {
|
|
369
|
+
service: string;
|
|
370
|
+
method: string;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
const GRPC_ROUTE_METADATA = MetadataAccessor.create<
|
|
374
|
+
GrpcRouteMetadata,
|
|
375
|
+
MethodDecorator
|
|
376
|
+
>('grpc:route');
|
|
377
|
+
|
|
378
|
+
export function grpcRoute(service: string, method: string): MethodDecorator {
|
|
379
|
+
return MethodDecoratorFactory.createDecorator<GrpcRouteMetadata>(
|
|
380
|
+
GRPC_ROUTE_METADATA,
|
|
381
|
+
{service, method},
|
|
382
|
+
);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
export class GrpcRouteDiscoverer implements HandlerDiscoverer {
|
|
386
|
+
readonly id = 'grpc';
|
|
387
|
+
|
|
388
|
+
discover(controllerClass: Constructor<unknown>): DiscoveredHandler[] {
|
|
389
|
+
const methods = MetadataInspector.getAllMethodMetadata<GrpcRouteMetadata>(
|
|
390
|
+
GRPC_ROUTE_METADATA.key,
|
|
391
|
+
controllerClass.prototype,
|
|
392
|
+
);
|
|
393
|
+
if (!methods) return [];
|
|
394
|
+
return Object.entries(methods).map(([methodName, m]) => ({
|
|
395
|
+
pattern: `${m.service}/${m.method}`,
|
|
396
|
+
transport: 'grpc',
|
|
397
|
+
kind: 'request' as const,
|
|
398
|
+
methodName,
|
|
399
|
+
}));
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
export class GrpcComponent implements Component {
|
|
404
|
+
readonly bindings = [
|
|
405
|
+
Binding.bind('grpc.discoverer')
|
|
406
|
+
.toClass(GrpcRouteDiscoverer)
|
|
407
|
+
.tag(HANDLER_DISCOVERER_TAG)
|
|
408
|
+
.inScope(BindingScope.SINGLETON),
|
|
409
|
+
];
|
|
410
|
+
}
|
|
411
|
+
```
|
|
412
|
+
|
|
413
|
+
</details>
|
|
414
|
+
|
|
415
|
+
The built-in `@messageHandler` and `@eventHandler` are themselves implemented as default `HandlerDiscoverer` instances (`MessageHandlerDiscoverer`, `EventHandlerDiscoverer`) bound by `TransportComponent`, so plugin-supplied vocabularies operate on equal footing with the framework.
|
|
416
|
+
|
|
417
|
+
## Universal discovery
|
|
418
|
+
|
|
419
|
+
`DiscoveryService` is a read-only enumeration of every handler the application contains, regardless of which `HandlerDiscoverer` produced it or which transport server serves it. This mirrors the `DiscoveryService` exposed by NestJS's `@nestjs/core` package. Inject it from `TransportBindings.DISCOVERY_SERVICE` after boot to build cross-cutting integrations: schema export, audit registries, OpenAPI generation, observability boots.
|
|
420
|
+
|
|
421
|
+
Available methods:
|
|
422
|
+
|
|
423
|
+
- `getHandlers()`: every `RegisteredHandler` across every discoverer.
|
|
424
|
+
- `getHandlersForTransport(transport)`: handlers bound to a specific transport id.
|
|
425
|
+
- `getHandlersByKind(kind)`: filter by `'request'` or `'event'`.
|
|
426
|
+
- `getHandlersByDiscoverer(discovererId)`: handlers produced by one `HandlerDiscoverer`.
|
|
427
|
+
- `getDiscoverers()`: the live list of bound discoverers.
|
|
428
|
+
- `getTransportServers()`: every registered `TransportServer`.
|
|
429
|
+
- `getSerializers()`: every binding tagged with `SERIALIZER_TAG`.
|
|
430
|
+
- `getDeserializers()`: every binding tagged with `DESERIALIZER_TAG`.
|
|
431
|
+
- `getSerializerForTransport(transport)`: resolves the serializer for a transport using the same precedence as `ServerBase.resolveSerializer` (transport-scoped tag, then generic tag).
|
|
432
|
+
- `getDeserializerForTransport(transport)`: same precedence, applied to the inbound side.
|
|
433
|
+
|
|
434
|
+
<details>
|
|
435
|
+
<summary><b>Show example: a boot-time audit pass that catalogues every handler</b></summary>
|
|
436
|
+
|
|
437
|
+
```typescript
|
|
438
|
+
import {inject, lifeCycleObserver, LifeCycleObserver} from '@loopback/core';
|
|
439
|
+
import {
|
|
440
|
+
DiscoveryService,
|
|
441
|
+
TransportBindings,
|
|
442
|
+
} from '@ebarahona/loopback-transport-core';
|
|
443
|
+
|
|
444
|
+
@lifeCycleObserver()
|
|
445
|
+
export class HandlerAuditObserver implements LifeCycleObserver {
|
|
446
|
+
constructor(
|
|
447
|
+
@inject(TransportBindings.DISCOVERY_SERVICE)
|
|
448
|
+
private readonly discovery: DiscoveryService,
|
|
449
|
+
) {}
|
|
450
|
+
|
|
451
|
+
async start(): Promise<void> {
|
|
452
|
+
for (const h of this.discovery.getHandlers()) {
|
|
453
|
+
console.log(
|
|
454
|
+
`[audit] ${h.discovererId} ${h.transport} ${h.kind} ` +
|
|
455
|
+
`${h.controllerClass.name}.${h.methodName} pattern=${JSON.stringify(h.pattern)}`,
|
|
456
|
+
);
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
```
|
|
461
|
+
|
|
462
|
+
</details>
|
|
463
|
+
|
|
464
|
+
## Cross-cutting concerns
|
|
465
|
+
|
|
466
|
+
Cross-cutting work (metrics, tracing, audit, authorization) goes through LoopBack 4's standard `@globalInterceptor` mechanism. Every transport handler runs through the same `invokeMethod` pipeline as HTTP controllers, so a single global interceptor instruments the whole application without per-decorator wiring or a separate wrapper extension point.
|
|
467
|
+
|
|
468
|
+
<details>
|
|
469
|
+
<summary><b>Show example: a global metrics interceptor timing every method invocation</b></summary>
|
|
470
|
+
|
|
471
|
+
```typescript
|
|
472
|
+
import {
|
|
473
|
+
globalInterceptor,
|
|
474
|
+
Interceptor,
|
|
475
|
+
InvocationContext,
|
|
476
|
+
Provider,
|
|
477
|
+
ValueOrPromise,
|
|
478
|
+
} from '@loopback/core';
|
|
479
|
+
|
|
480
|
+
@globalInterceptor('metrics', {tags: {name: 'metrics'}})
|
|
481
|
+
export class MetricsInterceptor implements Provider<Interceptor> {
|
|
482
|
+
value(): Interceptor {
|
|
483
|
+
return async (invocationCtx: InvocationContext, next) => {
|
|
484
|
+
const label = `${invocationCtx.targetClass.name}.${invocationCtx.methodName}`;
|
|
485
|
+
const start = process.hrtime.bigint();
|
|
486
|
+
try {
|
|
487
|
+
return await next();
|
|
488
|
+
} finally {
|
|
489
|
+
const ns = Number(process.hrtime.bigint() - start);
|
|
490
|
+
metrics.observe(label, ns / 1_000_000);
|
|
491
|
+
}
|
|
492
|
+
};
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
```
|
|
496
|
+
|
|
497
|
+
</details>
|
|
498
|
+
|
|
499
|
+
If an interceptor needs handler metadata (pattern, transport, discovererId) at invocation time, inject `DiscoveryService` into the provider and look up the entry by `invocationCtx.targetClass` and `invocationCtx.methodName`.
|
|
500
|
+
|
|
265
501
|
## Lifecycle Behavior
|
|
266
502
|
|
|
267
503
|
- **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.
|
|
@@ -273,25 +509,196 @@ switch (result.outcome) {
|
|
|
273
509
|
|
|
274
510
|
Companion adapter packages, each implementing `TransportServer` and `TransportClient`:
|
|
275
511
|
|
|
276
|
-
| Package
|
|
277
|
-
|
|
278
|
-
| `@ebarahona/loopback-transport-kafka`
|
|
279
|
-
| `@ebarahona/loopback-transport-rabbitmq` | RabbitMQ
|
|
280
|
-
| `@ebarahona/loopback-transport-grpc`
|
|
281
|
-
| `@ebarahona/loopback-transport-mqtt`
|
|
282
|
-
| `@ebarahona/loopback-transport-nats`
|
|
512
|
+
| Package | Transport | Native Client |
|
|
513
|
+
| ---------------------------------------- | ------------ | --------------- |
|
|
514
|
+
| `@ebarahona/loopback-transport-kafka` | Apache Kafka | `kafkajs` |
|
|
515
|
+
| `@ebarahona/loopback-transport-rabbitmq` | RabbitMQ | `amqplib` |
|
|
516
|
+
| `@ebarahona/loopback-transport-grpc` | gRPC | `@grpc/grpc-js` |
|
|
517
|
+
| `@ebarahona/loopback-transport-mqtt` | MQTT | `mqtt` |
|
|
518
|
+
| `@ebarahona/loopback-transport-nats` | NATS | `nats` |
|
|
519
|
+
|
|
520
|
+
## Event envelopes
|
|
521
|
+
|
|
522
|
+
[CloudEvents 1.0](https://cloudevents.io) is a CNCF standard envelope format for event data. Knative, Dapr, Azure Event Grid, AWS EventBridge, NATS JetStream, and GCP Eventarc all speak CloudEvents natively. Binding `CloudEventsSerializer` / `CloudEventsDeserializer` into a transport adapter makes the adapter interoperable with that broader cloud-native event ecosystem.
|
|
523
|
+
|
|
524
|
+
The serializer is marked `@experimental` until validated by a real adapter. It plugs into the existing `Serializer` / `Deserializer` interface -- no architectural changes are required on the adapter side. Structured mode (the default) embeds attributes in the JSON payload and is broker-agnostic. Binary mode places attributes in transport headers and keeps the payload clean, suitable for Kafka, AMQP, and HTTP binary content mode.
|
|
525
|
+
|
|
526
|
+
The `cloudevents` SDK is a peer dependency; install it alongside transport-core when using these helpers.
|
|
527
|
+
|
|
528
|
+
<details>
|
|
529
|
+
<summary><b>Show example: binding CloudEventsSerializer into a transport adapter</b></summary>
|
|
530
|
+
|
|
531
|
+
```typescript
|
|
532
|
+
import {
|
|
533
|
+
CloudEventsSerializer,
|
|
534
|
+
CloudEventsDeserializer,
|
|
535
|
+
} from '@ebarahona/loopback-transport-core';
|
|
536
|
+
|
|
537
|
+
const serializer = new CloudEventsSerializer({
|
|
538
|
+
source: '/orders',
|
|
539
|
+
typePrefix: 'com.example.order',
|
|
540
|
+
mode: 'structured', // or 'binary' for Kafka/AMQP/HTTP
|
|
541
|
+
});
|
|
542
|
+
const deserializer = new CloudEventsDeserializer();
|
|
543
|
+
|
|
544
|
+
// Adapter wires these into its consume/produce loop in place of the
|
|
545
|
+
// default JsonSerializer / JsonDeserializer.
|
|
546
|
+
class KafkaServerCE extends ServerBase {
|
|
547
|
+
protected override serializer = serializer;
|
|
548
|
+
protected override deserializer = deserializer;
|
|
549
|
+
// ...
|
|
550
|
+
}
|
|
551
|
+
```
|
|
552
|
+
|
|
553
|
+
</details>
|
|
554
|
+
|
|
555
|
+
<details>
|
|
556
|
+
<summary><b>Show example: handler receiving a deserialized CloudEvent payload</b></summary>
|
|
557
|
+
|
|
558
|
+
```typescript
|
|
559
|
+
import {eventHandler, payload} from '@ebarahona/loopback-transport-core';
|
|
560
|
+
|
|
561
|
+
class OrderController {
|
|
562
|
+
// CloudEvents `type` arrives as the handler's pattern, prefixed by
|
|
563
|
+
// CloudEventsSerializerOptions.typePrefix. The packet's `data`
|
|
564
|
+
// attribute is the CloudEvent `data` payload; `correlationId` is the
|
|
565
|
+
// CloudEvent `id`.
|
|
566
|
+
@eventHandler('com.example.order.placed')
|
|
567
|
+
async onPlaced(@payload() data: OrderDto): Promise<void> {
|
|
568
|
+
await this.notificationService.send(data);
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
```
|
|
572
|
+
|
|
573
|
+
</details>
|
|
574
|
+
|
|
575
|
+
See https://cloudevents.io and the [CNCF JavaScript SDK](https://github.com/cloudevents/sdk-javascript) for protocol details.
|
|
576
|
+
|
|
577
|
+
Serializers and deserializers are tag-based extension points. Bind your implementation under `TransportBindings.tags.SERIALIZER` (or `DESERIALIZER` for the inbound side) to make it discoverable by every `ServerBase` instance at boot. Add `TransportBindings.tags.NAME` set to a transport tag (for example `'kafka'`) to scope a serializer to a single transport; without that tag the serializer is treated as generic and applies wherever no transport-scoped match exists. Subclass-level defaults set via the `protected serializer` field on a `ServerBase` subclass still work as the final fallback.
|
|
578
|
+
|
|
579
|
+
<details>
|
|
580
|
+
<summary><b>Show example: registering a MsgPack serializer via tag</b></summary>
|
|
581
|
+
|
|
582
|
+
```typescript
|
|
583
|
+
import {Binding, BindingScope, Component} from '@loopback/core';
|
|
584
|
+
import {
|
|
585
|
+
DESERIALIZER_TAG,
|
|
586
|
+
SERIALIZER_TAG,
|
|
587
|
+
type Deserializer,
|
|
588
|
+
type Serializer,
|
|
589
|
+
} from '@ebarahona/loopback-transport-core';
|
|
590
|
+
import {decode, encode} from '@msgpack/msgpack';
|
|
591
|
+
|
|
592
|
+
class MsgPackSerializer implements Serializer {
|
|
593
|
+
serialize(value: unknown): Uint8Array {
|
|
594
|
+
return encode(value);
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
class MsgPackDeserializer implements Deserializer {
|
|
599
|
+
deserialize(bytes: Uint8Array): unknown {
|
|
600
|
+
return decode(bytes);
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
export class MsgPackComponent implements Component {
|
|
605
|
+
readonly bindings = [
|
|
606
|
+
Binding.bind('serializers.msgpack')
|
|
607
|
+
.toClass(MsgPackSerializer)
|
|
608
|
+
.tag(SERIALIZER_TAG)
|
|
609
|
+
.inScope(BindingScope.SINGLETON),
|
|
610
|
+
Binding.bind('deserializers.msgpack')
|
|
611
|
+
.toClass(MsgPackDeserializer)
|
|
612
|
+
.tag(DESERIALIZER_TAG)
|
|
613
|
+
.inScope(BindingScope.SINGLETON),
|
|
614
|
+
];
|
|
615
|
+
}
|
|
616
|
+
```
|
|
617
|
+
|
|
618
|
+
`app.component(MsgPackComponent)` and every `ServerBase` instance resolves to MsgPack at boot; no per-transport wiring required.
|
|
619
|
+
|
|
620
|
+
#### Scoping to a specific transport
|
|
621
|
+
|
|
622
|
+
If you need different codecs per transport (for example Avro on Kafka via a schema registry, JSON on Redis Pub/Sub for mixed consumers), add `TransportBindings.tags.NAME` to the binding to scope it to one transport. Resolution at boot is transport-scoped first, then generic, then the subclass-default `protected serializer` field.
|
|
623
|
+
|
|
624
|
+
```typescript
|
|
625
|
+
Binding.bind('serializers.avro-kafka')
|
|
626
|
+
.toClass(AvroSerializer)
|
|
627
|
+
.tag(SERIALIZER_TAG)
|
|
628
|
+
.tag({[TransportBindings.tags.NAME]: 'kafka'})
|
|
629
|
+
.inScope(BindingScope.SINGLETON);
|
|
630
|
+
```
|
|
631
|
+
|
|
632
|
+
</details>
|
|
633
|
+
|
|
634
|
+
#### Implementing TransportServer directly
|
|
635
|
+
|
|
636
|
+
`resolveSerializer(app: Application): Promise<void>` is a required member of the `TransportServer` interface. Subclasses of `ServerBase` inherit a default implementation that performs the tag-based resolution described above. Servers that implement `TransportServer` directly (without extending `ServerBase`) must define `resolveSerializer` themselves; a no-op is acceptable for servers that hardcode their codec.
|
|
637
|
+
|
|
638
|
+
## Reaching the native driver
|
|
639
|
+
|
|
640
|
+
This package exposes the transport surface via typed helpers, and the native broker client is one method call away through documented escape hatches. The goal is that users never need to leave LoopBack 4's DI surface to use a broker-specific feature -- everything the core does not abstract is reachable through `unwrap<T>()` on the registered `TransportServer` or `TransportClient`.
|
|
641
|
+
|
|
642
|
+
### Reaching the native server
|
|
643
|
+
|
|
644
|
+
`ServerBase` exposes the underlying broker consumer via `unwrap<T>()`. Adapter authors implement it; consumers call it after acquiring the server from the container.
|
|
645
|
+
|
|
646
|
+
```typescript
|
|
647
|
+
import {TransportBindings} from '@ebarahona/loopback-transport-core';
|
|
648
|
+
|
|
649
|
+
const server = await app.get(TransportBindings.server('kafka'));
|
|
650
|
+
const consumer = server.unwrap<Consumer>(); // kafkajs Consumer
|
|
651
|
+
consumer.on('consumer.crash', err => log.error(err));
|
|
652
|
+
```
|
|
653
|
+
|
|
654
|
+
### Reaching the native client
|
|
655
|
+
|
|
656
|
+
`ClientProxy` exposes the underlying broker producer via `unwrap<T>()`. The same pattern applies to every transport.
|
|
657
|
+
|
|
658
|
+
```typescript
|
|
659
|
+
const client = await app.get(TransportBindings.client('kafka'));
|
|
660
|
+
const producer = client.unwrap<Producer>(); // kafkajs Producer
|
|
661
|
+
await producer.send({topic: 'audit', messages: [{value: payload}]});
|
|
662
|
+
```
|
|
663
|
+
|
|
664
|
+
### Reaching the execution arguments
|
|
665
|
+
|
|
666
|
+
`ExecutionContext.getArgs<T>()` returns a copy of the handler arguments tuple, and `getArgByIndex<T>(i)` returns a single argument typed as `T`. Both methods cast through the supplied generic so adapters can recover the original tuple shape at the call site.
|
|
667
|
+
|
|
668
|
+
```typescript
|
|
669
|
+
const [data, ctx] = exec.getArgs<readonly [OrderDto, KafkaContext]>();
|
|
670
|
+
const handlerData = exec.getArgByIndex<OrderDto>(0);
|
|
671
|
+
```
|
|
672
|
+
|
|
673
|
+
These generic casts are marked `@experimental`; see [Known limitations](#known-limitations).
|
|
674
|
+
|
|
675
|
+
## Known limitations
|
|
676
|
+
|
|
677
|
+
These APIs are marked `@experimental` for the first release. They work but have documented edge cases pending follow-up work.
|
|
678
|
+
|
|
679
|
+
### Generic casts on `ExecutionContext` accessors
|
|
680
|
+
|
|
681
|
+
`ExecutionContext.getClass<T>()`, `getArgs<T>()`, and `getArgByIndex<T>()` accept a generic and return the underlying value cast to that generic without runtime validation. Misalignment between the caller's `T` and the actual handler shape produces a silent type-system lie, not a runtime error. Validate inputs at the boundary if the handler shape is not fixed by adapter convention. A future release will replace these with overloaded signatures keyed off the recorded handler metadata.
|
|
682
|
+
|
|
683
|
+
### `ClientProxy.assignPacketId` correlation IDs
|
|
684
|
+
|
|
685
|
+
The default `assignPacketId` uses `crypto.randomUUID()` for correlation IDs. The format is hardcoded; adapters that need a transport-specific ID shape (Kafka headers with binary keys, gRPC `request-id` semantics, MQTT 5 `Correlation Data` byte arrays) currently override the entire method to substitute their own generator. A future release will expose an injectable `IdGenerator` interface so adapters can swap the generator without overriding the protected method.
|
|
283
686
|
|
|
284
687
|
## Requirements
|
|
285
688
|
|
|
286
|
-
- Node.js >=
|
|
689
|
+
- Node.js >= 20.19.0
|
|
287
690
|
- LoopBack 4 application
|
|
288
691
|
|
|
289
|
-
Peer dependencies
|
|
692
|
+
Peer dependencies: `@loopback/core` (>=7.0.0 <8.0.0), `@loopback/metadata` (>=8.0.0 <9.0.0). Runtime dependencies: `rxjs` 7.x, `debug`.
|
|
290
693
|
|
|
291
694
|
## Contributing
|
|
292
695
|
|
|
293
696
|
See [CONTRIBUTING.md](CONTRIBUTING.md).
|
|
294
697
|
|
|
698
|
+
## Documentation
|
|
699
|
+
|
|
700
|
+
The full API reference is published at https://ebarahona.github.io/loopback-transport-core (generated by TypeDoc on every release).
|
|
701
|
+
|
|
295
702
|
## License
|
|
296
703
|
|
|
297
704
|
MIT
|