Skip to content

Commit d997eb4

Browse files
committed
feat: ama-mfe consumer accept semver messages
1 parent a699535 commit d997eb4

13 files changed

Lines changed: 260 additions & 171 deletions

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.
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import type {
2+
VersionedMessage,
3+
} from '@amadeus-it-group/microfrontends';
4+
5+
/** Base structure for all versioned messages in the communication protocol. */
6+
export interface StrictTypedVersionedMessage<V extends string, T extends string> extends VersionedMessage {
7+
/** @inheritdoc */
8+
version: V;
9+
/** @inheritdoc */
10+
type: T;
11+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,9 @@
1+
import type {
2+
StrictTypedVersionedMessage,
3+
} from '../core';
4+
15
/** The navigation message type used at communication with iframes */
26
export const NAVIGATION_MESSAGE_TYPE = 'navigation';
7+
8+
/** Versioned navigation message */
9+
export interface VersionedNavigationMessage<V extends string> extends StrictTypedVersionedMessage<V, typeof NAVIGATION_MESSAGE_TYPE> {}
Lines changed: 16 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,39 +1,32 @@
11
import type {
2-
VersionedMessage,
3-
} from '@amadeus-it-group/microfrontends';
4-
import type {
5-
NAVIGATION_MESSAGE_TYPE,
2+
VersionedNavigationMessage,
63
} from './base';
74

8-
/**
9-
* The navigation message object sent via the communication protocol.
10-
* Used in microfrontend architecture based on iframes where an iframe change its url and notify its parent with the url change
11-
* The parent should update its own url with the information received in order to allow the refresh and the deep link share
12-
*/
13-
export interface NavigationV1_0 extends VersionedMessage {
14-
/** @inheritdoc */
15-
type: typeof NAVIGATION_MESSAGE_TYPE;
16-
/** @inheritdoc */
17-
version: '1.0';
5+
/** Navigation content for version 1.0 */
6+
export interface NavigationContentV1_0 {
187
/** The url updated */
198
url: string;
209
}
2110

2211
/**
2312
* The navigation message object sent via the communication protocol.
24-
* Carries a subset of NavigationExtras alongside the updated url so the receiving router
25-
* can reproduce the original history/location semantics (e.g. skipping a route from history).
13+
* Used in microfrontend architecture based on iframes where an iframe change its url and notify its parent with the url change
14+
* The parent should update its own url with the information received in order to allow the refresh and the deep link share
2615
*/
27-
export interface NavigationV1_1 extends VersionedMessage {
28-
/** @inheritdoc */
29-
type: typeof NAVIGATION_MESSAGE_TYPE;
30-
/** @inheritdoc */
31-
version: '1.1';
32-
/** The url updated */
33-
url: string;
16+
export interface NavigationV1_0 extends NavigationContentV1_0, VersionedNavigationMessage<'1.0'> {}
17+
18+
/** Navigation content for version 1.1 */
19+
export interface NavigationContentV1_1 extends NavigationContentV1_0 {
3420
/** Subset of NavigationExtras forwarded across the iframe boundary. */
3521
extras?: {
3622
/** Navigate while replacing the current history entry instead of pushing a new one. */
3723
replaceUrl?: boolean;
3824
};
3925
}
26+
27+
/**
28+
* The navigation message object sent via the communication protocol.
29+
* Carries a subset of NavigationExtras alongside the updated url so the receiving router
30+
* can reproduce the original history/location semantics (e.g. skipping a route from history).
31+
*/
32+
export interface NavigationV1_1 extends NavigationContentV1_1, VersionedNavigationMessage<'1.1'> {}

packages/@ama-mfe/messages/src/public_api.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
export * from './messages/core';
12
export * from './messages/history/index';
23
export * from './messages/navigation/index';
34
export * from './messages/resize/index';

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: 46 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,27 @@ import {
3131
ProducerManagerService,
3232
} from './producer-manager-service';
3333

34+
const VERSION_MATCH = /^(\d+)\.(\d+)$/;
35+
36+
interface ParsedVersion {
37+
major: number;
38+
minor: number;
39+
raw: string;
40+
}
41+
42+
/**
43+
* Parses a "major.minor" version string. Returns undefined if the string is malformed.
44+
* @param version
45+
*/
46+
const parseVersion = (version: string): ParsedVersion | undefined => {
47+
const match = VERSION_MATCH.exec(version);
48+
if (!match) {
49+
return undefined;
50+
}
51+
const [major, minor] = match.slice(1).map(Number);
52+
return { major, minor, raw: version };
53+
};
54+
3455
@Injectable({
3556
providedIn: 'root'
3657
})
@@ -46,7 +67,7 @@ export class ConsumerManagerService {
4667
constructor() {
4768
this.messageService.messages$.pipe(takeUntilDestroyed()).subscribe((message) => this.consumeMessage(message));
4869

49-
// Each time a consumer is registered/unregistered update the list of registered messages
70+
// Each time a consumer is registered/unregistered update the list of registered messages.
5071
effect(() => {
5172
const declareMessages = getAvailableConsumers(this.consumers());
5273

@@ -93,20 +114,37 @@ export class ConsumerManagerService {
93114
return sendError(this.messageService, { reason: 'unknown_type', source: message.payload });
94115
}
95116

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

100-
if (versionMatchingConsumers.length === 0) {
138+
if (resolvedConsumers.length === 0) {
101139
this.logger.warn(`No consumer found for message version: ${message.payload.version}`);
102140
return sendError(this.messageService, { reason: 'version_mismatch', source: message.payload });
103141
}
104142

105143
await Promise.all(
106-
versionMatchingConsumers
107-
.map(async (consumer) => {
144+
resolvedConsumers
145+
.map(async ({ consumer, version }) => {
108146
try {
109-
await consumer.supportedVersions[message.payload.version](message);
147+
await consumer.supportedVersions[version](message);
110148
} catch (error) {
111149
this.logger.error('Error while consuming message', error);
112150
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: {

0 commit comments

Comments
 (0)