@ebarahona/loopback-transport-core 1.0.0 → 1.2.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 (105) hide show
  1. package/README.md +472 -35
  2. package/dist/client/client-proxy.d.ts +73 -42
  3. package/dist/client/client-proxy.js +71 -42
  4. package/dist/client/client-proxy.js.map +1 -1
  5. package/dist/client/index.d.ts +1 -1
  6. package/dist/client/index.js +3 -15
  7. package/dist/client/index.js.map +1 -1
  8. package/dist/context/execution-context.d.ts +58 -11
  9. package/dist/context/execution-context.js +62 -17
  10. package/dist/context/execution-context.js.map +1 -1
  11. package/dist/context/index.d.ts +2 -1
  12. package/dist/context/index.js +3 -15
  13. package/dist/context/index.js.map +1 -1
  14. package/dist/decorators/constants.d.ts +20 -10
  15. package/dist/decorators/constants.js +6 -2
  16. package/dist/decorators/constants.js.map +1 -1
  17. package/dist/decorators/event-handler.decorator.d.ts +10 -7
  18. package/dist/decorators/event-handler.decorator.js +18 -13
  19. package/dist/decorators/event-handler.decorator.js.map +1 -1
  20. package/dist/decorators/index.d.ts +5 -4
  21. package/dist/decorators/index.js +11 -18
  22. package/dist/decorators/index.js.map +1 -1
  23. package/dist/decorators/message-handler.decorator.d.ts +8 -5
  24. package/dist/decorators/message-handler.decorator.js +16 -11
  25. package/dist/decorators/message-handler.decorator.js.map +1 -1
  26. package/dist/decorators/payload.decorator.d.ts +16 -7
  27. package/dist/decorators/payload.decorator.js +16 -8
  28. package/dist/decorators/payload.decorator.js.map +1 -1
  29. package/dist/discovery/discovery.service.d.ts +143 -0
  30. package/dist/discovery/discovery.service.js +165 -0
  31. package/dist/discovery/discovery.service.js.map +1 -0
  32. package/dist/discovery/event-handler-discoverer.d.ts +19 -0
  33. package/dist/discovery/event-handler-discoverer.js +50 -0
  34. package/dist/discovery/event-handler-discoverer.js.map +1 -0
  35. package/dist/discovery/handler-discoverer.d.ts +48 -0
  36. package/dist/discovery/handler-discoverer.js +3 -0
  37. package/dist/discovery/handler-discoverer.js.map +1 -0
  38. package/dist/discovery/handler-kind.d.ts +23 -0
  39. package/dist/discovery/handler-kind.js +14 -0
  40. package/dist/discovery/handler-kind.js.map +1 -0
  41. package/dist/discovery/index.d.ts +7 -0
  42. package/dist/discovery/index.js +13 -0
  43. package/dist/discovery/index.js.map +1 -0
  44. package/dist/discovery/message-handler-discoverer.d.ts +19 -0
  45. package/dist/discovery/message-handler-discoverer.js +50 -0
  46. package/dist/discovery/message-handler-discoverer.js.map +1 -0
  47. package/dist/discovery/registered-handler.d.ts +35 -0
  48. package/dist/discovery/registered-handler.js +3 -0
  49. package/dist/discovery/registered-handler.js.map +1 -0
  50. package/dist/helpers/errors.d.ts +61 -0
  51. package/dist/helpers/errors.js +80 -0
  52. package/dist/helpers/errors.js.map +1 -0
  53. package/dist/helpers/index.d.ts +2 -0
  54. package/dist/helpers/index.js +14 -0
  55. package/dist/helpers/index.js.map +1 -0
  56. package/dist/helpers/register-server.d.ts +44 -0
  57. package/dist/helpers/register-server.js +72 -0
  58. package/dist/helpers/register-server.js.map +1 -0
  59. package/dist/index.d.ts +15 -7
  60. package/dist/index.js +33 -9
  61. package/dist/index.js.map +1 -1
  62. package/dist/interfaces/index.d.ts +4 -4
  63. package/dist/interfaces/index.js +0 -18
  64. package/dist/interfaces/index.js.map +1 -1
  65. package/dist/interfaces/message-handler.interface.d.ts +12 -6
  66. package/dist/interfaces/packet.interface.d.ts +20 -2
  67. package/dist/interfaces/transport-client.interface.d.ts +25 -13
  68. package/dist/interfaces/transport-server.interface.d.ts +36 -10
  69. package/dist/keys.d.ts +143 -46
  70. package/dist/keys.js +142 -68
  71. package/dist/keys.js.map +1 -1
  72. package/dist/registry/handler-registry.d.ts +94 -24
  73. package/dist/registry/handler-registry.js +279 -89
  74. package/dist/registry/handler-registry.js.map +1 -1
  75. package/dist/registry/index.d.ts +1 -1
  76. package/dist/registry/index.js +3 -15
  77. package/dist/registry/index.js.map +1 -1
  78. package/dist/serializers/cloudevents-serializer.d.ts +107 -0
  79. package/dist/serializers/cloudevents-serializer.js +130 -0
  80. package/dist/serializers/cloudevents-serializer.js.map +1 -0
  81. package/dist/serializers/index.d.ts +4 -1
  82. package/dist/serializers/index.js +7 -15
  83. package/dist/serializers/index.js.map +1 -1
  84. package/dist/serializers/serializer.interface.d.ts +49 -16
  85. package/dist/serializers/serializer.interface.js +41 -15
  86. package/dist/serializers/serializer.interface.js.map +1 -1
  87. package/dist/server/index.d.ts +2 -1
  88. package/dist/server/index.js +3 -15
  89. package/dist/server/index.js.map +1 -1
  90. package/dist/server/server-base.d.ts +125 -52
  91. package/dist/server/server-base.js +168 -57
  92. package/dist/server/server-base.js.map +1 -1
  93. package/dist/transport.component.d.ts +21 -8
  94. package/dist/transport.component.js +41 -18
  95. package/dist/transport.component.js.map +1 -1
  96. package/dist/transport.observer.d.ts +91 -0
  97. package/dist/transport.observer.js +297 -0
  98. package/dist/transport.observer.js.map +1 -0
  99. package/dist/utils/normalize-pattern.d.ts +11 -6
  100. package/dist/utils/normalize-pattern.js +18 -13
  101. package/dist/utils/normalize-pattern.js.map +1 -1
  102. package/package.json +58 -16
  103. package/dist/transport-booter.d.ts +0 -31
  104. package/dist/transport-booter.js +0 -117
  105. package/dist/transport-booter.js.map +0 -1
@@ -1,48 +1,119 @@
1
- import { Application } from '@loopback/core';
2
- import { MessageHandler } from '../interfaces';
1
+ import { Application, type Constructor } from '@loopback/core';
2
+ import type { HandlerKind } from '../discovery';
3
+ import type { HandlerOptions } from '../decorators';
4
+ import type { MessageHandler } from '../interfaces';
3
5
  /**
4
- * Discovers @messageHandler and @eventHandler decorated methods
5
- * from controllers and registers them with the appropriate transport server.
6
+ * Internal registry entry tracking both the runtime handler function
7
+ * and the metadata required to construct a {@link RegisteredHandler}
8
+ * view for cross-cutting wrappers and the {@link DiscoveryService}.
9
+ *
10
+ * @internal
11
+ */
12
+ export interface RegistryEntry {
13
+ /** Original (unnormalized) pattern as supplied by the discoverer. */
14
+ readonly pattern: string | Record<string, unknown>;
15
+ /** Transport server tag (`'*'` for wildcard). */
16
+ readonly transport: string;
17
+ /** Handler kind. */
18
+ readonly kind: HandlerKind;
19
+ /** Controller class that declared this handler. */
20
+ readonly controllerClass: Constructor<unknown>;
21
+ /** Method name on the controller prototype. */
22
+ readonly methodName: string;
23
+ /** Id of the discoverer that produced this entry. */
24
+ readonly discovererId: string;
25
+ /** Discoverer-specific handler options. */
26
+ readonly options?: HandlerOptions;
27
+ /**
28
+ * Live handler function. Mutable so the lifecycle observer can swap
29
+ * in the wrapped version between discovery and server-binding.
30
+ */
31
+ handler: MessageHandler;
32
+ }
33
+ /**
34
+ * Discovers transport handlers from controllers via every bound
35
+ * {@link HandlerDiscoverer} and registers them with the appropriate
36
+ * transport server.
37
+ *
38
+ * The two built-in vocabularies (`@messageHandler` and `@eventHandler`)
39
+ * are implemented as default discoverers bound by `TransportComponent`.
40
+ * Plugins (gRPC, cron, WebSocket, etc.) contribute additional decorator
41
+ * vocabularies by binding their own `HandlerDiscoverer` implementations
42
+ * under `TransportBindings.tags.HANDLER_DISCOVERER`.
6
43
  *
7
44
  * This class does NOT run in the constructor. It is invoked by
8
- * TransportBooter after all controllers are registered.
45
+ * `TransportBooter` after all controllers are registered.
9
46
  *
10
47
  * Handler invocations use per-message child contexts for concurrency
11
48
  * safety. The application context is never mutated per-message.
12
49
  *
13
- * Context lifetime extends until the handler result (including Observable
14
- * streams) fully completes, errors, or times out.
50
+ * Context lifetime extends until the handler result (including
51
+ * Observable streams) fully completes, errors, or times out.
52
+ *
53
+ * @public
15
54
  */
16
55
  export declare class HandlerRegistry {
17
56
  /**
18
57
  * Registry keyed by `transport::pattern` to support the same pattern
19
- * on different transports without collision.
58
+ * on different transports without collision. Each value is the chain
59
+ * of entries for that pattern (multiple allowed for event fan-out).
20
60
  */
21
- private readonly handlers;
61
+ private readonly entries;
22
62
  private discovered;
23
63
  /**
24
64
  * Scan all controllers in the application for transport decorators
25
65
  * and register the handlers.
26
66
  *
67
+ * Discovers handlers by consulting every `HandlerDiscoverer` bound
68
+ * under `TransportBindings.tags.HANDLER_DISCOVERER`, including the
69
+ * built-in `MessageHandlerDiscoverer` and `EventHandlerDiscoverer`
70
+ * registered by `TransportComponent`, plus any plugin-contributed
71
+ * discoverers.
72
+ *
27
73
  * Idempotent: clears previously discovered handlers before scanning.
74
+ *
75
+ * @public
76
+ * @param app - The LoopBack application instance.
77
+ * @returns Resolves once every controller has been scanned.
78
+ * @throws TransportConfigError When the same pattern is targeted by
79
+ * both `@messageHandler` and `@eventHandler`, or by two
80
+ * `@messageHandler` declarations.
28
81
  */
29
82
  discoverHandlers(app: Application): Promise<void>;
30
83
  /**
31
84
  * Bind discovered handlers to the registered transport servers.
32
- * Throws if discovery has not been run.
33
85
  *
34
- * Handles precedence: transport-specific handlers override wildcard
35
- * handlers for the same pattern on that transport's server.
86
+ * Precedence rules:
87
+ *
88
+ * - Request handlers: transport-specific overrides wildcard (only one
89
+ * per pattern survives).
90
+ * - Event handlers: both specific and wildcard run (fan-out).
91
+ *
92
+ * @public
93
+ * @param app - The LoopBack application instance.
94
+ * @returns Resolves once every handler has been bound.
95
+ * @throws TransportConfigError When `discoverHandlers()` has not been
96
+ * called first, or when a transport server binding is missing the
97
+ * `TRANSPORT_NAME_TAG` tag.
36
98
  */
37
99
  bindToServers(app: Application): Promise<void>;
38
100
  /**
39
- * Get all discovered handlers.
101
+ * Get all discovered handlers. The returned map is a defensive copy.
102
+ *
103
+ * @public
104
+ * @returns A read-only snapshot of every registered pattern and its
105
+ * handler chain.
40
106
  */
41
107
  getHandlers(): ReadonlyMap<string, readonly MessageHandler[]>;
42
108
  /**
43
- * Whether discovery has been run.
109
+ * Whether {@link discoverHandlers} has been run.
110
+ *
111
+ * @public
44
112
  */
45
113
  isDiscovered(): boolean;
114
+ /** @internal Package-scoped accessor used by TransportObserver to build the DiscoveryService snapshot. */
115
+ _getEntries(): IterableIterator<RegistryEntry>;
116
+ private iterateEntries;
46
117
  /**
47
118
  * Build the registry key from transport and raw pattern.
48
119
  * Normalizes the pattern (sorts object keys) before keying.
@@ -53,21 +124,20 @@ export declare class HandlerRegistry {
53
124
  * Extract the pattern portion from a registry key.
54
125
  */
55
126
  private extractPattern;
56
- private scanMessageHandlers;
57
- private scanEventHandlers;
58
127
  /**
59
- * Shared scanner for both @messageHandler and @eventHandler metadata.
60
- * The isEvent flag controls duplicate/chaining rules.
128
+ * Convert a `DiscoveredHandler` into a runtime `MessageHandler`,
129
+ * applying validation rules (no mixed kinds per pattern, no duplicate
130
+ * request handlers) before inserting into the registry.
61
131
  */
62
- private scanHandlers;
132
+ private registerDiscovered;
63
133
  /**
64
- * Create a handler function that invokes the controller method through
65
- * LoopBack's native invocation pipeline.
134
+ * Create a handler function that invokes the controller method
135
+ * through LoopBack's native invocation pipeline.
66
136
  *
67
137
  * Each invocation creates a uniquely named child Context from the
68
- * application context. Per-message bindings (payload, transport context)
69
- * are scoped to the child context and cannot collide with concurrent
70
- * messages.
138
+ * application context. Per-message bindings (payload, transport
139
+ * context) are scoped to the child context and cannot collide with
140
+ * concurrent messages.
71
141
  *
72
142
  * The child context lifetime extends until the handler result fully
73
143
  * resolves. For Observable results, the context stays open until the
@@ -5,13 +5,17 @@ var __decorate = (this && this.__decorate) || function (decorators, target, key,
5
5
  else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
6
6
  return c > 3 && r && Object.defineProperty(target, key, r), r;
7
7
  };
8
+ var __importDefault = (this && this.__importDefault) || function (mod) {
9
+ return (mod && mod.__esModule) ? mod : { "default": mod };
10
+ };
8
11
  Object.defineProperty(exports, "__esModule", { value: true });
9
12
  exports.HandlerRegistry = void 0;
10
13
  const core_1 = require("@loopback/core");
11
14
  const crypto_1 = require("crypto");
12
- const debug_1 = require("debug");
15
+ const debug_1 = __importDefault(require("debug"));
13
16
  const rxjs_1 = require("rxjs");
14
- const decorators_1 = require("../decorators");
17
+ const discovery_1 = require("../discovery");
18
+ const errors_1 = require("../helpers/errors");
15
19
  const keys_1 = require("../keys");
16
20
  const utils_1 = require("../utils");
17
21
  const debug = (0, debug_1.default)('loopback:transport:registry');
@@ -21,77 +25,220 @@ const debug = (0, debug_1.default)('loopback:transport:registry');
21
25
  */
22
26
  const KEY_SEPARATOR = '::';
23
27
  /**
24
- * Discovers @messageHandler and @eventHandler decorated methods
25
- * from controllers and registers them with the appropriate transport server.
28
+ * Discovers transport handlers from controllers via every bound
29
+ * {@link HandlerDiscoverer} and registers them with the appropriate
30
+ * transport server.
31
+ *
32
+ * The two built-in vocabularies (`@messageHandler` and `@eventHandler`)
33
+ * are implemented as default discoverers bound by `TransportComponent`.
34
+ * Plugins (gRPC, cron, WebSocket, etc.) contribute additional decorator
35
+ * vocabularies by binding their own `HandlerDiscoverer` implementations
36
+ * under `TransportBindings.tags.HANDLER_DISCOVERER`.
26
37
  *
27
38
  * This class does NOT run in the constructor. It is invoked by
28
- * TransportBooter after all controllers are registered.
39
+ * `TransportBooter` after all controllers are registered.
29
40
  *
30
41
  * Handler invocations use per-message child contexts for concurrency
31
42
  * safety. The application context is never mutated per-message.
32
43
  *
33
- * Context lifetime extends until the handler result (including Observable
34
- * streams) fully completes, errors, or times out.
44
+ * Context lifetime extends until the handler result (including
45
+ * Observable streams) fully completes, errors, or times out.
46
+ *
47
+ * @public
35
48
  */
36
49
  let HandlerRegistry = class HandlerRegistry {
37
- constructor() {
38
- /**
39
- * Registry keyed by `transport::pattern` to support the same pattern
40
- * on different transports without collision.
41
- */
42
- this.handlers = new Map();
43
- this.discovered = false;
44
- }
50
+ /**
51
+ * Registry keyed by `transport::pattern` to support the same pattern
52
+ * on different transports without collision. Each value is the chain
53
+ * of entries for that pattern (multiple allowed for event fan-out).
54
+ */
55
+ entries = new Map();
56
+ discovered = false;
45
57
  /**
46
58
  * Scan all controllers in the application for transport decorators
47
59
  * and register the handlers.
48
60
  *
61
+ * Discovers handlers by consulting every `HandlerDiscoverer` bound
62
+ * under `TransportBindings.tags.HANDLER_DISCOVERER`, including the
63
+ * built-in `MessageHandlerDiscoverer` and `EventHandlerDiscoverer`
64
+ * registered by `TransportComponent`, plus any plugin-contributed
65
+ * discoverers.
66
+ *
49
67
  * Idempotent: clears previously discovered handlers before scanning.
68
+ *
69
+ * @public
70
+ * @param app - The LoopBack application instance.
71
+ * @returns Resolves once every controller has been scanned.
72
+ * @throws TransportConfigError When the same pattern is targeted by
73
+ * both `@messageHandler` and `@eventHandler`, or by two
74
+ * `@messageHandler` declarations.
50
75
  */
51
76
  async discoverHandlers(app) {
52
- this.handlers.clear();
77
+ this.entries.clear();
53
78
  this.discovered = false;
79
+ const discovererBindings = app.findByTag(keys_1.HANDLER_DISCOVERER_TAG);
80
+ const discoverers = [];
81
+ for (const binding of discovererBindings) {
82
+ discoverers.push(await app.get(binding.key));
83
+ }
54
84
  const controllerBindings = app.findByTag('controller');
55
- debug('scanning %d controllers for transport handlers', controllerBindings.length);
85
+ debug('scanning %d controllers with %d discoverers', controllerBindings.length, discoverers.length);
86
+ const perDiscovererCounts = new Map();
56
87
  for (const binding of controllerBindings) {
57
88
  const controllerClass = binding.valueConstructor;
58
89
  if (!controllerClass)
59
90
  continue;
60
91
  const bindingKey = binding.key;
61
- this.scanMessageHandlers(controllerClass, bindingKey, app);
62
- this.scanEventHandlers(controllerClass, bindingKey, app);
92
+ for (const discoverer of discoverers) {
93
+ const found = await discoverer.discover(controllerClass);
94
+ if (found.length === 0)
95
+ continue;
96
+ perDiscovererCounts.set(discoverer.id, (perDiscovererCounts.get(discoverer.id) ?? 0) + found.length);
97
+ for (const item of found) {
98
+ this.registerDiscovered(item, controllerClass, bindingKey, app, discoverer.id);
99
+ }
100
+ }
63
101
  }
64
102
  this.discovered = true;
65
- debug('discovered %d transport handlers', this.handlers.size);
103
+ if (debug.enabled) {
104
+ for (const [id, count] of perDiscovererCounts) {
105
+ debug('discoverer [%s] contributed %d handlers', id, count);
106
+ }
107
+ debug('discovered %d transport handlers total', this.entries.size);
108
+ }
66
109
  }
67
110
  /**
68
111
  * Bind discovered handlers to the registered transport servers.
69
- * Throws if discovery has not been run.
70
112
  *
71
- * Handles precedence: transport-specific handlers override wildcard
72
- * handlers for the same pattern on that transport's server.
113
+ * Precedence rules:
114
+ *
115
+ * - Request handlers: transport-specific overrides wildcard (only one
116
+ * per pattern survives).
117
+ * - Event handlers: both specific and wildcard run (fan-out).
118
+ *
119
+ * @public
120
+ * @param app - The LoopBack application instance.
121
+ * @returns Resolves once every handler has been bound.
122
+ * @throws TransportConfigError When `discoverHandlers()` has not been
123
+ * called first, or when a transport server binding is missing the
124
+ * `TRANSPORT_NAME_TAG` tag.
73
125
  */
74
126
  async bindToServers(app) {
75
127
  if (!this.discovered) {
76
- throw new Error('Cannot bind handlers to servers: discoverHandlers() has not been called.');
128
+ throw new errors_1.TransportConfigError('Cannot bind handlers to servers: discoverHandlers() has not been called.');
77
129
  }
78
130
  const serverBindings = app.findByTag(keys_1.TRANSPORT_SERVER_TAG);
131
+ // Boot-time validation: surface misconfigurations loudly before any
132
+ // handler is bound to a server. Four guards run here:
133
+ // 1. Handlers referencing unknown transports.
134
+ // 2. Handlers exist but no transport servers registered.
135
+ // 3. Duplicate NAME tags across TransportServer bindings.
136
+ // 4. TransportServer binding without a NAME tag.
137
+ let strictBinding = true;
138
+ if (app.isBound(keys_1.TransportBindings.STRICT_BINDING)) {
139
+ strictBinding = await app.get(keys_1.TransportBindings.STRICT_BINDING);
140
+ }
141
+ // Build the set of known transport names and detect duplicate NAME
142
+ // tags (Guard 3) and missing NAME tags (Guard 4) in a single pass.
143
+ const knownTransports = new Set();
144
+ const nameToBindingKeys = new Map();
79
145
  for (const serverBinding of serverBindings) {
146
+ const tag = serverBinding.tagMap?.[keys_1.TRANSPORT_NAME_TAG];
147
+ if (typeof tag !== 'string' || tag.length === 0) {
148
+ debug('%s is registered as a TransportServer but has no NAME tag; the server is skipped during binding and will not receive handlers (the lifecycle observer also skips it during startup)', String(serverBinding.key));
149
+ continue;
150
+ }
151
+ knownTransports.add(tag);
152
+ const list = nameToBindingKeys.get(tag) ?? [];
153
+ list.push(String(serverBinding.key));
154
+ nameToBindingKeys.set(tag, list);
155
+ }
156
+ // Guard 3: duplicate NAME tags. Always throws regardless of strict mode.
157
+ for (const [name, keys] of nameToBindingKeys) {
158
+ if (keys.length > 1) {
159
+ throw new errors_1.TransportConfigError(`duplicate transport name "${name}": multiple TransportServer ` +
160
+ `bindings tagged with the same NAME (${keys.join(', ')}). ` +
161
+ 'Each transport must have a unique name.');
162
+ }
163
+ }
164
+ // Guard 2: no transport servers but handlers exist. Always warns.
165
+ if (serverBindings.length === 0 && this.entries.size > 0) {
166
+ let handlerCount = 0;
167
+ for (const chain of this.entries.values())
168
+ handlerCount += chain.length;
169
+ debug('no transport servers registered but %d handlers are bound; handlers will not receive events', handlerCount);
170
+ }
171
+ // Guard 1: handlers referencing unknown transports.
172
+ // Group orphaned entries by transport name for the error/log message.
173
+ const orphansByTransport = new Map();
174
+ for (const chain of this.entries.values()) {
175
+ for (const entry of chain) {
176
+ if (entry.transport === undefined || entry.transport === '*')
177
+ continue;
178
+ if (knownTransports.has(entry.transport))
179
+ continue;
180
+ const list = orphansByTransport.get(entry.transport) ?? [];
181
+ list.push({
182
+ controllerName: entry.controllerClass.name,
183
+ methodName: entry.methodName,
184
+ pattern: (0, utils_1.normalizePattern)(entry.pattern),
185
+ discovererId: entry.discovererId,
186
+ });
187
+ orphansByTransport.set(entry.transport, list);
188
+ }
189
+ }
190
+ if (orphansByTransport.size > 0) {
191
+ const knownList = knownTransports.size === 0
192
+ ? '(none)'
193
+ : Array.from(knownTransports).join(', ');
194
+ const summaryParts = [];
195
+ for (const [name, list] of orphansByTransport) {
196
+ summaryParts.push(`${name} (${list.length} handlers)`);
197
+ }
198
+ const detailLines = [];
199
+ for (const [, list] of orphansByTransport) {
200
+ for (const o of list) {
201
+ detailLines.push(` - ${o.controllerName}.${o.methodName} on pattern "${o.pattern}" (discoverer "${o.discovererId}")`);
202
+ }
203
+ }
204
+ const message = `handlers reference unknown transport(s): ${summaryParts.join(', ')}.\n` +
205
+ ` Known transports: ${knownList}\n` +
206
+ ` Orphaned handlers:\n${detailLines.join('\n')}`;
207
+ if (strictBinding) {
208
+ throw new errors_1.TransportConfigError(message);
209
+ }
210
+ for (const [name, list] of orphansByTransport) {
211
+ const perTransportDetails = list
212
+ .map(o => ` - ${o.controllerName}.${o.methodName} on pattern "${o.pattern}" (discoverer "${o.discovererId}")`)
213
+ .join('\n');
214
+ debug('handlers reference unknown transport "%s" (%d handlers).\n Known transports: %s\n Orphaned handlers:\n%s', name, list.length, knownList, perTransportDetails);
215
+ }
216
+ }
217
+ for (const serverBinding of serverBindings) {
218
+ const tag = serverBinding.tagMap?.[keys_1.TRANSPORT_NAME_TAG];
219
+ if (typeof tag !== 'string' || tag.length === 0) {
220
+ // Guard 4 already emitted a debug warning above; only wildcard
221
+ // handlers can ever reach this server, so skip the routing loop.
222
+ continue;
223
+ }
224
+ const transportName = tag;
80
225
  const server = await app.get(serverBinding.key);
81
- const transportName = serverBinding.tagMap?.[keys_1.TRANSPORT_NAME_TAG];
82
226
  // Collect handlers for this server.
83
227
  // Precedence rules differ by handler type:
84
228
  // - Request handlers: specific overrides wildcard (only one per pattern)
85
229
  // - Event handlers: both specific and wildcard run (fan-out)
86
230
  const boundRequestPatterns = new Set();
87
231
  // First pass: transport-specific handlers
88
- for (const [registryKey, handlers] of this.handlers) {
89
- if (handlers[0].transport && handlers[0].transport === transportName) {
232
+ for (const [registryKey, entryChain] of this.entries) {
233
+ const first = entryChain[0];
234
+ if (!first)
235
+ continue;
236
+ if (first.transport !== '*' && first.transport === transportName) {
90
237
  const pattern = this.extractPattern(registryKey);
91
- for (const handler of handlers) {
92
- server.addHandler(pattern, handler);
238
+ for (const entry of entryChain) {
239
+ server.addHandler(pattern, entry.handler);
93
240
  }
94
- if (!handlers[0].isEventHandler) {
241
+ if (first.kind !== discovery_1.HANDLER_KIND_EVENT) {
95
242
  boundRequestPatterns.add(pattern);
96
243
  }
97
244
  debug('bound handler [%s] to transport [%s] (specific)', pattern, transportName);
@@ -100,35 +247,56 @@ let HandlerRegistry = class HandlerRegistry {
100
247
  // Second pass: wildcard handlers
101
248
  // - Request handlers: skip if transport-specific already bound
102
249
  // - Event handlers: always bind (fan-out semantics)
103
- for (const [registryKey, handlers] of this.handlers) {
104
- if (!handlers[0].transport) {
250
+ for (const [registryKey, entryChain] of this.entries) {
251
+ const first = entryChain[0];
252
+ if (!first)
253
+ continue;
254
+ if (first.transport === '*') {
105
255
  const pattern = this.extractPattern(registryKey);
106
- if (handlers[0].isEventHandler || !boundRequestPatterns.has(pattern)) {
107
- for (const handler of handlers) {
108
- server.addHandler(pattern, handler);
256
+ if (first.kind === discovery_1.HANDLER_KIND_EVENT ||
257
+ !boundRequestPatterns.has(pattern)) {
258
+ for (const entry of entryChain) {
259
+ server.addHandler(pattern, entry.handler);
109
260
  }
110
- debug('bound handler [%s] to transport [%s] (wildcard)', pattern, transportName ?? 'default');
261
+ debug('bound handler [%s] to transport [%s] (wildcard)', pattern, transportName);
111
262
  }
112
263
  }
113
264
  }
114
265
  }
115
266
  }
116
267
  /**
117
- * Get all discovered handlers.
268
+ * Get all discovered handlers. The returned map is a defensive copy.
269
+ *
270
+ * @public
271
+ * @returns A read-only snapshot of every registered pattern and its
272
+ * handler chain.
118
273
  */
119
274
  getHandlers() {
120
275
  const copy = new Map();
121
- for (const [key, handlers] of this.handlers) {
122
- copy.set(key, [...handlers]);
276
+ for (const [key, entries] of this.entries) {
277
+ copy.set(key, entries.map(e => e.handler));
123
278
  }
124
279
  return copy;
125
280
  }
126
281
  /**
127
- * Whether discovery has been run.
282
+ * Whether {@link discoverHandlers} has been run.
283
+ *
284
+ * @public
128
285
  */
129
286
  isDiscovered() {
130
287
  return this.discovered;
131
288
  }
289
+ /** @internal Package-scoped accessor used by TransportObserver to build the DiscoveryService snapshot. */
290
+ _getEntries() {
291
+ return this.iterateEntries();
292
+ }
293
+ *iterateEntries() {
294
+ for (const chain of this.entries.values()) {
295
+ for (const entry of chain) {
296
+ yield entry;
297
+ }
298
+ }
299
+ }
132
300
  /**
133
301
  * Build the registry key from transport and raw pattern.
134
302
  * Normalizes the pattern (sorts object keys) before keying.
@@ -146,56 +314,77 @@ let HandlerRegistry = class HandlerRegistry {
146
314
  ? key.slice(separatorIndex + KEY_SEPARATOR.length)
147
315
  : key;
148
316
  }
149
- scanMessageHandlers(controllerClass, controllerBindingKey, app) {
150
- this.scanHandlers(controllerClass, controllerBindingKey, app, decorators_1.MESSAGE_HANDLER_METADATA.key, false);
151
- }
152
- scanEventHandlers(controllerClass, controllerBindingKey, app) {
153
- this.scanHandlers(controllerClass, controllerBindingKey, app, decorators_1.EVENT_HANDLER_METADATA.key, true);
154
- }
155
317
  /**
156
- * Shared scanner for both @messageHandler and @eventHandler metadata.
157
- * The isEvent flag controls duplicate/chaining rules.
318
+ * Convert a `DiscoveredHandler` into a runtime `MessageHandler`,
319
+ * applying validation rules (no mixed kinds per pattern, no duplicate
320
+ * request handlers) before inserting into the registry.
158
321
  */
159
- scanHandlers(controllerClass, controllerBindingKey, app, metadataKey, isEvent) {
160
- const methods = core_1.MetadataInspector.getAllMethodMetadata(metadataKey, controllerClass.prototype);
161
- if (!methods)
162
- return;
163
- const decoratorName = isEvent ? '@eventHandler' : '@messageHandler';
164
- for (const [methodName, metadata] of Object.entries(methods)) {
165
- const key = this.buildKey(metadata.transport, metadata.pattern);
166
- const existing = this.handlers.get(key);
167
- // Reject mixed handler types on the same pattern
168
- if (existing && existing[0].isEventHandler !== isEvent) {
169
- throw new Error(`Cannot mix @messageHandler and @eventHandler for pattern: ${(0, utils_1.normalizePattern)(metadata.pattern)}. ` +
170
- 'A pattern must be exclusively request/response or event.');
171
- }
172
- // Reject duplicate request/response handlers
173
- if (existing && !isEvent) {
174
- throw new Error(`Duplicate @messageHandler for pattern: ${(0, utils_1.normalizePattern)(metadata.pattern)}. ` +
175
- 'Only one request/response handler per pattern is allowed.');
176
- }
177
- const handler = this.createHandler(controllerBindingKey, methodName, app);
178
- handler.isEventHandler = isEvent;
179
- handler.transport = metadata.transport;
180
- handler.extras = metadata.extras;
181
- if (existing) {
182
- existing.push(handler);
183
- debug('chained %s [%s] on %s.%s', decoratorName, (0, utils_1.normalizePattern)(metadata.pattern), controllerClass.name, methodName);
184
- }
185
- else {
186
- this.handlers.set(key, [handler]);
187
- debug('discovered %s [%s] on %s.%s', decoratorName, (0, utils_1.normalizePattern)(metadata.pattern), controllerClass.name, methodName);
322
+ registerDiscovered(discovered, controllerClass, controllerBindingKey, app, discovererId) {
323
+ const isEvent = discovered.kind === discovery_1.HANDLER_KIND_EVENT;
324
+ const rawTransport = discovered.transport;
325
+ const transportForKey = rawTransport === '*' ? undefined : rawTransport;
326
+ const key = this.buildKey(transportForKey, discovered.pattern);
327
+ const keyOrPattern = (0, utils_1.normalizePattern)(discovered.pattern);
328
+ const existing = this.entries.get(key);
329
+ const firstExisting = existing?.[0];
330
+ // Reject mixed handler types on the same pattern.
331
+ if (firstExisting && firstExisting.kind !== discovered.kind) {
332
+ throw new errors_1.TransportConfigError(`Cannot mix handler kinds for pattern "${keyOrPattern}" on transport "${rawTransport}": ` +
333
+ `existing kind "${firstExisting.kind}" from ` +
334
+ `${firstExisting.controllerClass.name}.${firstExisting.methodName} ` +
335
+ `(discoverer "${firstExisting.discovererId}") conflicts with ` +
336
+ `new kind "${discovered.kind}" from ` +
337
+ `${controllerClass.name}.${discovered.methodName} ` +
338
+ `(discoverer "${discovererId}").`);
339
+ }
340
+ // Reject duplicate request/response handlers.
341
+ if (firstExisting && !isEvent) {
342
+ throw new errors_1.TransportConfigError(`Duplicate request handler for pattern "${keyOrPattern}" on transport "${rawTransport}": ` +
343
+ `pattern is already bound to ` +
344
+ `${firstExisting.controllerClass.name}.${firstExisting.methodName} ` +
345
+ `contributed by discoverer "${firstExisting.discovererId}".`);
346
+ }
347
+ const handler = this.createHandler(controllerBindingKey, discovered.methodName, app);
348
+ handler.isEventHandler = isEvent;
349
+ if (transportForKey !== undefined) {
350
+ handler.transport = transportForKey;
351
+ }
352
+ if (discovered.options?.extras !== undefined) {
353
+ handler.extras = discovered.options.extras;
354
+ }
355
+ const entry = {
356
+ pattern: discovered.pattern,
357
+ transport: rawTransport,
358
+ kind: discovered.kind,
359
+ controllerClass,
360
+ methodName: discovered.methodName,
361
+ discovererId,
362
+ ...(discovered.options !== undefined
363
+ ? { options: discovered.options }
364
+ : {}),
365
+ handler,
366
+ };
367
+ const decoratorLabel = `@${discovererId}Handler`;
368
+ if (existing) {
369
+ if (firstExisting && firstExisting.discovererId !== entry.discovererId) {
370
+ debug('event handler chain on pattern %s (transport %s) now spans discoverers: %s + %s', keyOrPattern, rawTransport, firstExisting.discovererId, entry.discovererId);
188
371
  }
372
+ existing.push(entry);
373
+ debug('chained %s [%s] on %s.%s', decoratorLabel, keyOrPattern, controllerClass.name, discovered.methodName);
374
+ }
375
+ else {
376
+ this.entries.set(key, [entry]);
377
+ debug('discovered %s [%s] on %s.%s', decoratorLabel, (0, utils_1.normalizePattern)(discovered.pattern), controllerClass.name, discovered.methodName);
189
378
  }
190
379
  }
191
380
  /**
192
- * Create a handler function that invokes the controller method through
193
- * LoopBack's native invocation pipeline.
381
+ * Create a handler function that invokes the controller method
382
+ * through LoopBack's native invocation pipeline.
194
383
  *
195
384
  * Each invocation creates a uniquely named child Context from the
196
- * application context. Per-message bindings (payload, transport context)
197
- * are scoped to the child context and cannot collide with concurrent
198
- * messages.
385
+ * application context. Per-message bindings (payload, transport
386
+ * context) are scoped to the child context and cannot collide with
387
+ * concurrent messages.
199
388
  *
200
389
  * The child context lifetime extends until the handler result fully
201
390
  * resolves. For Observable results, the context stays open until the
@@ -205,7 +394,8 @@ let HandlerRegistry = class HandlerRegistry {
205
394
  const handler = async (data, context) => {
206
395
  const invocationCtx = new core_1.Context(app, `transport-invocation-${(0, crypto_1.randomUUID)()}`);
207
396
  // Idempotent cleanup guard: Context.close() is synchronous but
208
- // may be reached from multiple paths (error + teardown) for Observables
397
+ // may be reached from multiple paths (error + teardown) for
398
+ // Observables.
209
399
  let closed = false;
210
400
  const cleanup = () => {
211
401
  if (!closed) {
@@ -219,13 +409,13 @@ let HandlerRegistry = class HandlerRegistry {
219
409
  const controller = await invocationCtx.get(controllerBindingKey);
220
410
  const method = controller[methodName];
221
411
  if (typeof method !== 'function') {
222
- throw new Error(`Transport handler method "${methodName}" not found on controller "${controllerBindingKey}"`);
412
+ throw new errors_1.TransportConfigError(`Transport handler method "${methodName}" not found on controller "${controllerBindingKey}"`);
223
413
  }
224
- // Invoke through LoopBack's method invocation pipeline
225
- // This honors parameter injection, interceptors, and other
226
- // framework invocation semantics
414
+ // Invoke through LoopBack's method invocation pipeline so we
415
+ // honour parameter injection, interceptors, and other framework
416
+ // invocation semantics.
227
417
  const result = await (0, core_1.invokeMethod)(controller, methodName, invocationCtx, [data, context]);
228
- // For Observable results, defer context cleanup until stream completes
418
+ // For Observable results, defer context cleanup until stream completes.
229
419
  if ((0, rxjs_1.isObservable)(result)) {
230
420
  const obs = result;
231
421
  return new rxjs_1.Observable(subscriber => {
@@ -246,7 +436,7 @@ let HandlerRegistry = class HandlerRegistry {
246
436
  };
247
437
  });
248
438
  }
249
- // For non-Observable results, close context immediately
439
+ // For non-Observable results, close context immediately.
250
440
  cleanup();
251
441
  return result;
252
442
  }