Skip to content

Commit fc8bb6d

Browse files
committed
feat: ama-mfe consumer accept semver messages
1 parent 1b21cbf commit fc8bb6d

10 files changed

Lines changed: 216 additions & 153 deletions

File tree

packages/@ama-mfe/messages/README.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,14 @@ The message interfaces exposed in this package cover common use cases such as:
1515
- [Theme](https://github.com/AmadeusITGroup/otter/blob/main/packages/%40ama-mfe/ng-utils/src/theme/: a host application requests its embedded module to apply a common css theme.
1616

1717
Messages are identified thanks to their type property and are versioned for backward compatibility.
18+
19+
## Navigation messages
20+
### v1.0
21+
Carries the target `url` only.
22+
23+
### v1.1
24+
Adds an optional `extras` field to forward a subset of Angular `NavigationExtras` across the iframe boundary so the
25+
receiving router can reproduce the original history/location semantics. Currently supports:
26+
- `replaceUrl`: when set, the host navigates by replacing the current history entry instead of pushing a new one. This is
27+
the key fix for modules that call `router.navigate(..., { replaceUrl: true })` to skip a route from history — without
28+
forwarding, the host still pushes a new entry and the browser back button gets stuck in a loop.

packages/@ama-mfe/messages/src/messages/navigation/navigation-versions.ts

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,13 +24,9 @@ export interface NavigationV1_0 extends VersionedMessage {
2424
* Carries a subset of NavigationExtras alongside the updated url so the receiving router
2525
* can reproduce the original history/location semantics (e.g. skipping a route from history).
2626
*/
27-
export interface NavigationV1_1 extends VersionedMessage {
28-
/** @inheritdoc */
29-
type: typeof NAVIGATION_MESSAGE_TYPE;
27+
export interface NavigationV1_1 extends Omit<NavigationV1_0, 'version'> {
3028
/** @inheritdoc */
3129
version: '1.1';
32-
/** The url updated */
33-
url: string;
3430
/** Subset of NavigationExtras forwarded across the iframe boundary. */
3531
extras?: {
3632
/** Navigate while replacing the current history entry instead of pushing a new one. */

packages/@ama-mfe/ng-utils/README.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,21 @@ export interface CustomMessageV1_0 extends Message {
180180
export type CustomMessageVersions = CustomMessageV1_0;
181181
```
182182

183+
#### Message version compatibility
184+
The `ConsumerManagerService` dispatches incoming messages with a semver-compatible fallback, applied uniformly to every
185+
message type in this package (navigation, theme, resize, user-activity, history, and custom messages).
186+
187+
Within a major version, a consumer's highest declared minor ≤ the incoming minor, is used. This lets producers add
188+
optional fields in a new minor version (e.g. `v1.1`) without breaking consumers that only implement `v1.0` — the older
189+
handler runs and simply ignores the unknown fields.
190+
191+
Majors are isolated — no cross-major fallback in either direction:
192+
- An incoming v2.x will never fall back to a v1.x handler (breaking changes are expected across majors).
193+
- A consumer declaring only v2.x will not match an incoming v1.x (the consumer is ahead of the producer).
194+
195+
Both cross-major cases produce a `version_mismatch` error. Introducing a new major version therefore requires coordinated
196+
updates on both the producer and consumer sides.
197+
183198
#### Consumer
184199
A consumer should implement the `MessageConsumer` interface and inject the `ConsumeManagerService` which handles the
185200
registration to the communication protocol.

packages/@ama-mfe/ng-utils/src/managers/consumer-manager-service.spec.ts

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,4 +203,122 @@ describe('ConsumerManagerService', () => {
203203
expect(loggerServiceMock.error).toHaveBeenCalledWith('Error while consuming message', internalError);
204204
expect(errorHelpers.sendError).toHaveBeenCalledWith(messagePeerService, { reason: 'internal_error', source: message.payload });
205205
});
206+
207+
describe('semver-compatible dispatch', () => {
208+
it('should fall back to the highest declared minor when the incoming minor is newer', async () => {
209+
const handlerV10 = jest.fn();
210+
const consumer: BasicMessageConsumer = { type: 'base_message', supportedVersions: { '1.0': handlerV10 } };
211+
service.register(consumer);
212+
const message: RoutedMessage<VersionedMessage> = { payload: { type: 'base_message', version: '1.1' }, from: 'a', to: [] };
213+
messagesSubjectMock.next(message);
214+
await jest.runAllTimersAsync();
215+
expect(handlerV10).toHaveBeenCalledWith(message);
216+
expect(loggerServiceMock.warn).not.toHaveBeenCalled();
217+
});
218+
219+
it('should prefer the exact minor when declared', async () => {
220+
const handlerV10 = jest.fn();
221+
const handlerV11 = jest.fn();
222+
const consumer: BasicMessageConsumer = { type: 'base_message', supportedVersions: { '1.0': handlerV10, 1.1: handlerV11 } };
223+
service.register(consumer);
224+
const message: RoutedMessage<VersionedMessage> = { payload: { type: 'base_message', version: '1.1' }, from: 'a', to: [] };
225+
messagesSubjectMock.next(message);
226+
await jest.runAllTimersAsync();
227+
expect(handlerV11).toHaveBeenCalledWith(message);
228+
expect(handlerV10).not.toHaveBeenCalled();
229+
});
230+
231+
it('should pick the highest compatible minor lower than or equal to the incoming minor', async () => {
232+
const handlerV10 = jest.fn();
233+
const handlerV12 = jest.fn();
234+
const handlerV15 = jest.fn();
235+
const consumer: BasicMessageConsumer = {
236+
type: 'base_message',
237+
supportedVersions: { '1.0': handlerV10, 1.2: handlerV12, 1.5: handlerV15 }
238+
};
239+
service.register(consumer);
240+
const message: RoutedMessage<VersionedMessage> = { payload: { type: 'base_message', version: '1.3' }, from: 'a', to: [] };
241+
messagesSubjectMock.next(message);
242+
await jest.runAllTimersAsync();
243+
expect(handlerV12).toHaveBeenCalledWith(message);
244+
expect(handlerV10).not.toHaveBeenCalled();
245+
expect(handlerV15).not.toHaveBeenCalled();
246+
});
247+
248+
it('should not cross major version boundaries when the consumer only declares an older major', async () => {
249+
const handlerV10 = jest.fn();
250+
const handlerV11 = jest.fn();
251+
const consumer: BasicMessageConsumer = { type: 'base_message', supportedVersions: { '1.0': handlerV10, 1.1: handlerV11 } };
252+
service.register(consumer);
253+
const message: RoutedMessage<VersionedMessage> = { payload: { type: 'base_message', version: '2.0' }, from: 'a', to: [] };
254+
messagesSubjectMock.next(message);
255+
await jest.runAllTimersAsync();
256+
expect(handlerV10).not.toHaveBeenCalled();
257+
expect(handlerV11).not.toHaveBeenCalled();
258+
expect(loggerServiceMock.warn).toHaveBeenCalledWith('No consumer found for message version: 2.0');
259+
expect(errorHelpers.sendError).toHaveBeenCalledWith(messagePeerService, { reason: 'version_mismatch', source: message.payload });
260+
});
261+
262+
it('should not match a consumer that only declares a newer major than the incoming message', async () => {
263+
const handlerV20 = jest.fn();
264+
const consumer: BasicMessageConsumer = { type: 'base_message', supportedVersions: { '2.0': handlerV20 } };
265+
service.register(consumer);
266+
const message: RoutedMessage<VersionedMessage> = { payload: { type: 'base_message', version: '1.5' }, from: 'a', to: [] };
267+
messagesSubjectMock.next(message);
268+
await jest.runAllTimersAsync();
269+
expect(handlerV20).not.toHaveBeenCalled();
270+
expect(loggerServiceMock.warn).toHaveBeenCalledWith('No consumer found for message version: 1.5');
271+
expect(errorHelpers.sendError).toHaveBeenCalledWith(messagePeerService, { reason: 'version_mismatch', source: message.payload });
272+
});
273+
274+
it('should dispatch within the matching major when the consumer declares multiple majors', async () => {
275+
const handlerV10 = jest.fn();
276+
const handlerV20 = jest.fn();
277+
const consumer: BasicMessageConsumer = { type: 'base_message', supportedVersions: { '1.0': handlerV10, '2.0': handlerV20 } };
278+
service.register(consumer);
279+
const message: RoutedMessage<VersionedMessage> = { payload: { type: 'base_message', version: '2.3' }, from: 'a', to: [] };
280+
messagesSubjectMock.next(message);
281+
await jest.runAllTimersAsync();
282+
expect(handlerV20).toHaveBeenCalledWith(message);
283+
expect(handlerV10).not.toHaveBeenCalled();
284+
});
285+
286+
it('should not match a consumer declaring only a higher minor within the same major', async () => {
287+
const handlerV11 = jest.fn();
288+
const consumer: BasicMessageConsumer = { type: 'base_message', supportedVersions: { 1.1: handlerV11 } };
289+
service.register(consumer);
290+
const message: RoutedMessage<VersionedMessage> = { payload: { type: 'base_message', version: '1.0' }, from: 'a', to: [] };
291+
messagesSubjectMock.next(message);
292+
await jest.runAllTimersAsync();
293+
expect(handlerV11).not.toHaveBeenCalled();
294+
expect(errorHelpers.sendError).toHaveBeenCalledWith(messagePeerService, { reason: 'version_mismatch', source: message.payload });
295+
});
296+
297+
it('should reject malformed incoming version strings', async () => {
298+
const handlerV10 = jest.fn();
299+
const consumer: BasicMessageConsumer = { type: 'base_message', supportedVersions: { '1.0': handlerV10 } };
300+
service.register(consumer);
301+
const message: RoutedMessage<VersionedMessage> = { payload: { type: 'base_message', version: 'not-a-version' }, from: 'a', to: [] };
302+
messagesSubjectMock.next(message);
303+
await jest.runAllTimersAsync();
304+
expect(handlerV10).not.toHaveBeenCalled();
305+
expect(errorHelpers.sendError).toHaveBeenCalledWith(messagePeerService, { reason: 'version_mismatch', source: message.payload });
306+
});
307+
308+
it('should resolve each consumer to its own best-matching minor when multiple consumers share a type', async () => {
309+
const handlerAV10 = jest.fn();
310+
const handlerBV10 = jest.fn();
311+
const handlerBV11 = jest.fn();
312+
const consumerA: BasicMessageConsumer = { type: 'base_message', supportedVersions: { '1.0': handlerAV10 } };
313+
const consumerB: BasicMessageConsumer = { type: 'base_message', supportedVersions: { '1.0': handlerBV10, 1.1: handlerBV11 } };
314+
service.register(consumerA);
315+
service.register(consumerB);
316+
const message: RoutedMessage<VersionedMessage> = { payload: { type: 'base_message', version: '1.1' }, from: 'a', to: [] };
317+
messagesSubjectMock.next(message);
318+
await jest.runAllTimersAsync();
319+
expect(handlerAV10).toHaveBeenCalledWith(message);
320+
expect(handlerBV11).toHaveBeenCalledWith(message);
321+
expect(handlerBV10).not.toHaveBeenCalled();
322+
});
323+
});
206324
});

packages/@ama-mfe/ng-utils/src/managers/consumer-manager-service.ts

Lines changed: 36 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,18 @@ import {
3131
ProducerManagerService,
3232
} from './producer-manager-service';
3333

34+
/**
35+
* Parses a "major.minor" version string. Returns undefined if the string is malformed.
36+
* @param version
37+
*/
38+
const parseVersion = (version: string): { major: number; minor: number; raw: string } | undefined => {
39+
const match = /^(\d+)\.(\d+)$/.exec(version);
40+
if (!match) {
41+
return undefined;
42+
}
43+
return { major: Number(match[1]), minor: Number(match[2]), raw: version };
44+
};
45+
3446
@Injectable({
3547
providedIn: 'root'
3648
})
@@ -46,7 +58,7 @@ export class ConsumerManagerService {
4658
constructor() {
4759
this.messageService.messages$.pipe(takeUntilDestroyed()).subscribe((message) => this.consumeMessage(message));
4860

49-
// Each time a consumer is registered/unregistered update the list of registered messages
61+
// Each time a consumer is registered/unregistered update the list of registered messages.
5062
effect(() => {
5163
const declareMessages = getAvailableConsumers(this.consumers());
5264

@@ -93,20 +105,36 @@ export class ConsumerManagerService {
93105
return sendError(this.messageService, { reason: 'unknown_type', source: message.payload });
94106
}
95107

96-
const versionMatchingConsumers = typeMatchingConsumers
97-
.filter((consumer) => consumer.supportedVersions[message.payload.version])
98-
.flat();
108+
// Semver-compatible dispatch: for an incoming v{major}.{minor}, pick each consumer's highest
109+
// declared v{major}.{n} where n <= minor.
110+
// Major versions are isolated: no cross-major fallback in either direction. An incoming v2.x
111+
// will never fall back to a v1.x handler (breaking changes), and a consumer that declares
112+
// only v2.x will not match an incoming v1.x (the consumer is ahead of the producer). Both
113+
// result in a version_mismatch error.
114+
const messageVersion = parseVersion(message.payload.version);
115+
const resolvedConsumers = messageVersion
116+
? typeMatchingConsumers
117+
.map((consumer) => {
118+
const fallbackVersion = Object.keys(consumer.supportedVersions)
119+
.map((version) => parseVersion(version))
120+
.filter((v): v is { major: number; minor: number; raw: string } =>
121+
!!v && v.major === messageVersion.major && v.minor <= messageVersion.minor)
122+
.toSorted((a, b) => b.minor - a.minor)[0];
123+
return fallbackVersion ? { consumer, version: fallbackVersion.raw } : undefined;
124+
})
125+
.filter((entry): entry is { consumer: typeof typeMatchingConsumers[number]; version: string } => !!entry)
126+
: [];
99127

100-
if (versionMatchingConsumers.length === 0) {
128+
if (resolvedConsumers.length === 0) {
101129
this.logger.warn(`No consumer found for message version: ${message.payload.version}`);
102130
return sendError(this.messageService, { reason: 'version_mismatch', source: message.payload });
103131
}
104132

105133
await Promise.all(
106-
versionMatchingConsumers
107-
.map(async (consumer) => {
134+
resolvedConsumers
135+
.map(async ({ consumer, version }) => {
108136
try {
109-
await consumer.supportedVersions[message.payload.version](message);
137+
await consumer.supportedVersions[version](message);
110138
} catch (error) {
111139
this.logger.error('Error while consuming message', error);
112140
sendError(this.messageService, { reason: 'internal_error', source: message.payload });

packages/@ama-mfe/ng-utils/src/navigation/README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,3 +54,10 @@ the module route history.
5454
`memorizeRoute` will record the navigation history in the iframe.
5555
`restoreRoute` will forward the host query parameters to the iframe and look for the navigation history associated to the
5656
`memoryChannelId`.
57+
58+
## Message versions
59+
Message shapes and versions are documented in the
60+
[@ama-mfe/messages README](https://github.com/AmadeusITGroup/otter/blob/main/packages/%40ama-mfe/messages/README.md#navigation-messages).
61+
The producer always emits the latest version; consumers benefit from the semver fallback described in the
62+
[package README](../../README.md#message-version-compatibility), so an older consumer still receives the navigation and
63+
just ignores any unknown fields.

packages/@ama-mfe/ng-utils/src/navigation/navigation-consumer-service.spec.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1-
import {
2-
NavigationMessage,
1+
import type {
2+
NavigationV1_0,
33
NavigationV1_1,
44
} from '@ama-mfe/messages';
5-
import {
5+
import type {
66
RoutedMessage,
77
} from '@amadeus-it-group/microfrontends';
88
import {
@@ -65,7 +65,7 @@ describe('Navigation Handler Service', () => {
6565

6666
it('should call navigate when a supported message is received', () => {
6767
jest.spyOn(navHandlerService as any, 'navigate');
68-
const navMessage: RoutedMessage<NavigationMessage> = {
68+
const navMessage: RoutedMessage<NavigationV1_0> = {
6969
from: 'test',
7070
to: [],
7171
payload: {
@@ -81,7 +81,7 @@ describe('Navigation Handler Service', () => {
8181
// eslint-disable-next-line jest/no-done-callback -- use the callback function to finish the test
8282
it('should emit via the requestedUrl observable when a supported message is received', (done) => {
8383
jest.spyOn(navHandlerService as any, 'navigate');
84-
const navMessage: RoutedMessage<NavigationMessage> = {
84+
const navMessage: RoutedMessage<NavigationV1_0> = {
8585
from: 'test',
8686
to: [],
8787
payload: {
@@ -100,7 +100,7 @@ describe('Navigation Handler Service', () => {
100100

101101
it('should call the router navigate when a supported message is received', () => {
102102
jest.spyOn(router, 'navigate');
103-
const navMessage: RoutedMessage<NavigationMessage> = {
103+
const navMessage: RoutedMessage<NavigationV1_0> = {
104104
from: 'test',
105105
to: [],
106106
payload: {

packages/@ama-mfe/ng-utils/src/navigation/navigation-consumer-service.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ export class NavigationConsumerService implements MessageConsumer<NavigationMess
7171
* so the host router reproduces the original history semantics.
7272
* @param message message to consume
7373
*/
74+
// eslint-disable-next-line @stylistic/quote-props -- keep quotes for consistency with '1.0'
7475
'1.1': (message: RoutedMessage<NavigationV1_1>) => {
7576
const channelId = message.from || undefined;
7677
this.requestedUrl.next({ url: message.payload.url, channelId });

0 commit comments

Comments
 (0)