@granite-js/micro-frontend 2.0.1 → 2.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.
Files changed (57) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +124 -55
  3. package/dist/module/host/pendingHostComponentStore.js +33 -14
  4. package/dist/module/host/pendingHostComponentStore.js.map +1 -1
  5. package/dist/module/index.js.map +1 -1
  6. package/dist/module/plugin/disposeBabelPlugin.js +38 -0
  7. package/dist/module/plugin/disposeBabelPlugin.js.map +1 -0
  8. package/dist/module/plugin/disposeTransform.js +37 -0
  9. package/dist/module/plugin/disposeTransform.js.map +1 -0
  10. package/dist/module/plugin/globalContextScript.js +121 -0
  11. package/dist/module/plugin/globalContextScript.js.map +1 -0
  12. package/dist/module/plugin/microFrontendPlugin.js +12 -1
  13. package/dist/module/plugin/microFrontendPlugin.js.map +1 -1
  14. package/dist/module/plugin/prelude.js +4 -2
  15. package/dist/module/plugin/prelude.js.map +1 -1
  16. package/dist/module/plugin/resolver.js +1 -1
  17. package/dist/module/plugin/resolver.js.map +1 -1
  18. package/dist/module/runtime/containerModuleExposure.js +79 -0
  19. package/dist/module/runtime/containerModuleExposure.js.map +1 -0
  20. package/dist/module/runtime/containerPairing.js +86 -0
  21. package/dist/module/runtime/containerPairing.js.map +1 -0
  22. package/dist/module/runtime/containerRegistry.js +190 -0
  23. package/dist/module/runtime/containerRegistry.js.map +1 -0
  24. package/dist/module/runtime/createMicroFrontendRuntime.js +9 -6
  25. package/dist/module/runtime/createMicroFrontendRuntime.js.map +1 -1
  26. package/dist/module/runtime/globalContext.js +89 -0
  27. package/dist/module/runtime/globalContext.js.map +1 -0
  28. package/dist/module/runtime/registry.js +103 -45
  29. package/dist/module/runtime/registry.js.map +1 -1
  30. package/dist/module/session/useMicroFrontendSessions.js +23 -1
  31. package/dist/module/session/useMicroFrontendSessions.js.map +1 -1
  32. package/dist/typescript/index.d.ts +1 -0
  33. package/dist/typescript/plugin/disposeBabelPlugin.d.ts +8 -0
  34. package/dist/typescript/plugin/disposeTransform.d.ts +3 -0
  35. package/dist/typescript/plugin/globalContextScript.d.ts +1 -0
  36. package/dist/typescript/runtime/containerModuleExposure.d.ts +2 -0
  37. package/dist/typescript/runtime/containerPairing.d.ts +18 -0
  38. package/dist/typescript/runtime/containerRegistry.d.ts +6 -0
  39. package/dist/typescript/runtime/globalContext.d.ts +17 -0
  40. package/dist/typescript/runtime/registry.d.ts +10 -8
  41. package/ios/GraniteMicroFrontendRuntimeModule.mm +6 -1
  42. package/package.json +7 -4
  43. package/src/host/pendingHostComponentStore.ts +58 -29
  44. package/src/index.ts +1 -0
  45. package/src/plugin/disposeBabelPlugin.ts +51 -0
  46. package/src/plugin/disposeTransform.ts +43 -0
  47. package/src/plugin/globalContextScript.ts +118 -0
  48. package/src/plugin/microFrontendPlugin.ts +13 -1
  49. package/src/plugin/prelude.ts +160 -20
  50. package/src/plugin/resolver.ts +1 -1
  51. package/src/runtime/containerModuleExposure.ts +92 -0
  52. package/src/runtime/containerPairing.ts +120 -0
  53. package/src/runtime/containerRegistry.ts +239 -0
  54. package/src/runtime/createMicroFrontendRuntime.ts +8 -2
  55. package/src/runtime/globalContext.ts +164 -0
  56. package/src/runtime/registry.ts +151 -58
  57. package/src/session/useMicroFrontendSessions.ts +26 -16
@@ -1,12 +1,10 @@
1
1
  import type { AppRequest } from '../types';
2
+ import { createContainer, exposeModule, getContainer, getExposedModule, removeContainer } from './containerRegistry';
2
3
  import type { MicroFrontendModuleRegistry } from './createMicroFrontendRuntime';
3
- import {
4
- AppContainerAlreadyRegisteredError,
5
- ExposedModuleAlreadyRegisteredError,
6
- ExposedModuleNotFoundError,
7
- SharedModuleAlreadyRegisteredError,
8
- } from './errors';
4
+ import { ExposedModuleNotFoundError, SharedModuleAlreadyRegisteredError } from './errors';
5
+ import { getMicroFrontendGlobalContext, type MicroFrontendGlobalContext } from './globalContext';
9
6
  import { parseAppRequest } from './parseAppRequest';
7
+ export { createContainer, exposeModule, getContainer, removeContainer };
10
8
 
11
9
  export interface SharedModuleConfig {
12
10
  readonly eager?: boolean;
@@ -24,6 +22,13 @@ export interface AppContainer {
24
22
  readonly exposedModules: Record<string, unknown>;
25
23
  }
26
24
 
25
+ export type AppDisposeCallback = () => void | Promise<void>;
26
+
27
+ export interface RegisterAppDispose {
28
+ (callback: AppDisposeCallback): () => void;
29
+ (appName: string, callback: AppDisposeCallback): () => void;
30
+ }
31
+
27
32
  interface SharedModule {
28
33
  readonly get: () => unknown;
29
34
  readonly loaded: boolean;
@@ -31,55 +36,22 @@ interface SharedModule {
31
36
 
32
37
  export interface MicroFrontendRuntimeContext {
33
38
  readonly containers: Record<string, AppContainer>;
39
+ readonly dispose: RegisterAppDispose;
40
+ readonly disposeCallbacksByApp: Record<string, Set<AppDisposeCallback>>;
34
41
  readonly sharedModules: Record<string, SharedModule>;
35
42
  }
36
43
 
37
- declare global {
38
- var _graniteMicroFrontend: MicroFrontendRuntimeContext | undefined;
39
- }
40
-
41
44
  export function getMicroFrontendRuntimeContext(): MicroFrontendRuntimeContext {
42
- const existingContext = globalThis._graniteMicroFrontend;
43
- if (existingContext != null) {
44
- return existingContext;
45
- }
46
-
47
- const context: MicroFrontendRuntimeContext = {
48
- containers: {},
49
- sharedModules: {},
50
- };
51
- globalThis._graniteMicroFrontend = context;
45
+ const context = getMicroFrontendGlobalContext();
46
+ ensureRuntimeContext(context);
52
47
  return context;
53
48
  }
54
49
 
55
- export function createContainer(appName: string, config: AppContainerConfig = {}): AppContainer {
56
- const containers = getMicroFrontendRuntimeContext().containers;
57
- if (containers[appName] != null) {
58
- throw new AppContainerAlreadyRegisteredError(appName);
59
- }
60
-
61
- const container: AppContainer = {
62
- appName,
63
- config,
64
- exposedModules: {},
65
- };
66
- containers[appName] = container;
67
- return container;
68
- }
69
-
70
- export function exposeModule(container: AppContainer, exposedModule: string, module: unknown): void {
71
- const normalizedModule = normalizeExposedModule(exposedModule);
72
- if (container.exposedModules[normalizedModule] != null) {
73
- throw new ExposedModuleAlreadyRegisteredError(container.appName, normalizedModule);
74
- }
75
- container.exposedModules[normalizedModule] = module;
76
- }
77
-
78
50
  export function registerShared(moduleName: string, module: unknown): void {
79
- const sharedModules = getMicroFrontendRuntimeContext().sharedModules;
51
+ const sharedModules = getMicroFrontendGlobalContext().__SHARED__;
80
52
  const existingModule = sharedModules[moduleName];
81
53
  if (existingModule != null) {
82
- if (Object.is(existingModule.get(), module)) {
54
+ if (isSharedModule(existingModule) && Object.is(existingModule.get(), module)) {
83
55
  return;
84
56
  }
85
57
  throw new SharedModuleAlreadyRegisteredError(moduleName);
@@ -90,21 +62,22 @@ export function registerShared(moduleName: string, module: unknown): void {
90
62
  };
91
63
  }
92
64
 
93
- export function getContainer(appName: string): AppContainer | null {
94
- return getMicroFrontendRuntimeContext().containers[appName] ?? null;
65
+ function isSharedModule(value: unknown): value is SharedModule {
66
+ return (
67
+ typeof value === 'object' &&
68
+ value != null &&
69
+ typeof Reflect.get(value, 'get') === 'function' &&
70
+ typeof Reflect.get(value, 'loaded') === 'boolean'
71
+ );
95
72
  }
96
73
 
97
- export function removeContainer(appName: string): void {
98
- const containers = getMicroFrontendRuntimeContext().containers;
99
- const container = containers[appName];
100
- if (container == null) {
74
+ export async function disposeAppResources(appName: string): Promise<void> {
75
+ const disposeCallbacks = getMicroFrontendRuntimeContext().disposeCallbacksByApp[appName];
76
+ if (disposeCallbacks == null) {
101
77
  return;
102
78
  }
103
79
 
104
- for (const exposedModule of Object.keys(container.exposedModules)) {
105
- Reflect.deleteProperty(container.exposedModules, exposedModule);
106
- }
107
- Reflect.deleteProperty(containers, appName);
80
+ await runAppDisposeCallbacks(disposeCallbacks);
108
81
  }
109
82
 
110
83
  export function hasContainer(appName: string): boolean {
@@ -113,15 +86,135 @@ export function hasContainer(appName: string): boolean {
113
86
 
114
87
  export function importModule<TModule>(request: AppRequest): TModule {
115
88
  const { appName, exposedModule } = parseAppRequest(request);
116
- const module = getContainer(appName)?.exposedModules[exposedModule];
89
+ const container = getContainer(appName);
90
+ const module = container == null ? null : getExposedModule(container, exposedModule);
117
91
  if (module == null) {
118
92
  throw new ExposedModuleNotFoundError(appName, exposedModule);
119
93
  }
120
94
  return module as TModule;
121
95
  }
122
96
 
123
- function normalizeExposedModule(exposedModule: string): string {
124
- return exposedModule.startsWith('./') ? exposedModule : `./${exposedModule}`;
97
+ function ensureRuntimeContext(
98
+ context: MicroFrontendGlobalContext
99
+ ): asserts context is MicroFrontendGlobalContext & MicroFrontendRuntimeContext {
100
+ const containers = context.__CONTAINERS__;
101
+ const sharedModules = context.__SHARED__;
102
+ const propertyDescriptors = new Map<PropertyKey, PropertyDescriptor | undefined>();
103
+ const runtimeProperties = {
104
+ containers,
105
+ dispose: isRegisterAppDispose(Reflect.get(context, 'dispose'))
106
+ ? Reflect.get(context, 'dispose')
107
+ : createRegisterAppDispose(),
108
+ disposeCallbacksByApp: isDisposeCallbackRegistry(Reflect.get(context, 'disposeCallbacksByApp'))
109
+ ? Reflect.get(context, 'disposeCallbacksByApp')
110
+ : {},
111
+ sharedModules,
112
+ } satisfies MicroFrontendRuntimeContext;
113
+
114
+ try {
115
+ for (const property of Reflect.ownKeys(runtimeProperties)) {
116
+ propertyDescriptors.set(property, Reflect.getOwnPropertyDescriptor(context, property));
117
+ const value = Reflect.get(runtimeProperties, property);
118
+ if (
119
+ !Reflect.defineProperty(context, property, {
120
+ configurable: true,
121
+ enumerable: false,
122
+ value,
123
+ writable: true,
124
+ })
125
+ ) {
126
+ throw new Error(`Cannot install micro-frontend runtime property '${String(property)}'`);
127
+ }
128
+ }
129
+ } catch (error) {
130
+ for (const [property, descriptor] of Array.from(propertyDescriptors).reverse()) {
131
+ if (descriptor == null) {
132
+ Reflect.deleteProperty(context, property);
133
+ } else {
134
+ Reflect.defineProperty(context, property, descriptor);
135
+ }
136
+ }
137
+ throw error;
138
+ }
139
+ }
140
+
141
+ function isObjectRecord(value: unknown): value is Record<string, unknown> {
142
+ return typeof value === 'object' && value != null && !Array.isArray(value);
143
+ }
144
+
145
+ function isRegisterAppDispose(value: unknown): value is RegisterAppDispose {
146
+ return typeof value === 'function';
147
+ }
148
+
149
+ function isDisposeCallbackRegistry(value: unknown): value is Record<string, Set<AppDisposeCallback>> {
150
+ return isObjectRecord(value) && Object.values(value).every(isDisposeCallbackSet);
151
+ }
152
+
153
+ function isDisposeCallbackSet(value: unknown): value is Set<AppDisposeCallback> {
154
+ return (
155
+ typeof value === 'object' &&
156
+ value != null &&
157
+ typeof Reflect.get(value, 'add') === 'function' &&
158
+ typeof Reflect.get(value, 'delete') === 'function' &&
159
+ typeof Reflect.get(value, 'has') === 'function' &&
160
+ typeof Reflect.get(value, Symbol.iterator) === 'function'
161
+ );
162
+ }
163
+
164
+ function createRegisterAppDispose(): RegisterAppDispose {
165
+ function dispose(callback: AppDisposeCallback): () => void;
166
+ function dispose(appName: string, callback: AppDisposeCallback): () => void;
167
+ function dispose(appNameOrCallback: string | AppDisposeCallback, callback?: AppDisposeCallback): () => void {
168
+ if (typeof appNameOrCallback !== 'string' || callback == null) {
169
+ throw new Error('dispose() must be compiled with the microFrontend plugin');
170
+ }
171
+ return registerAppDisposeCallback(getMicroFrontendRuntimeContext(), appNameOrCallback, callback);
172
+ }
173
+
174
+ return dispose;
175
+ }
176
+
177
+ function registerAppDisposeCallback(
178
+ context: MicroFrontendRuntimeContext,
179
+ appName: string,
180
+ callback: AppDisposeCallback
181
+ ): () => void {
182
+ if (typeof appName !== 'string' || typeof callback !== 'function') {
183
+ throw new Error('dispose() must be compiled with the microFrontend plugin');
184
+ }
185
+
186
+ const disposeCallbacks = (context.disposeCallbacksByApp[appName] ??= new Set());
187
+ disposeCallbacks.add(callback);
188
+ let isRegistered = true;
189
+
190
+ return () => {
191
+ if (!isRegistered) {
192
+ return;
193
+ }
194
+ isRegistered = false;
195
+ disposeCallbacks.delete(callback);
196
+ };
197
+ }
198
+
199
+ async function runAppDisposeCallbacks(disposeCallbacks: Set<AppDisposeCallback>): Promise<void> {
200
+ const callbacks = Array.from(disposeCallbacks).reverse();
201
+ let firstDisposeError: unknown;
202
+ let didDisposeFail = false;
203
+
204
+ for (const dispose of callbacks) {
205
+ try {
206
+ await dispose();
207
+ } catch (error) {
208
+ if (!didDisposeFail) {
209
+ firstDisposeError = error;
210
+ didDisposeFail = true;
211
+ }
212
+ }
213
+ }
214
+
215
+ if (didDisposeFail) {
216
+ throw firstDisposeError;
217
+ }
125
218
  }
126
219
 
127
220
  export const microFrontendModuleRegistry: MicroFrontendModuleRegistry = {
@@ -1,12 +1,7 @@
1
- import { useLayoutEffect, useReducer } from 'react';
2
- import {
3
- installPendingHostComponentBridge,
4
- resetPendingHostComponent,
5
- } from '../host/pendingHostComponentStore';
6
- import type {
7
- MicroFrontendRuntimeApi,
8
- MicroFrontendSessionEvent,
9
- } from '../types';
1
+ import { useEffect, useLayoutEffect, useReducer, useRef } from 'react';
2
+ import { installPendingHostComponentBridge, resetPendingHostComponent } from '../host/pendingHostComponentStore';
3
+ import { disposeAppResources } from '../runtime/registry';
4
+ import type { MicroFrontendRuntimeApi, MicroFrontendSessionEvent } from '../types';
10
5
 
11
6
  export interface MicroFrontendSessionState {
12
7
  readonly appName: string;
@@ -38,9 +33,7 @@ function reduceMicroFrontendSessions(
38
33
  return sessions.filter(({ sessionId }) => sessionId !== event.params.sessionId);
39
34
  case 'sessionVisibilityChanged':
40
35
  return sessions.map((session) =>
41
- session.sessionId === event.params.sessionId
42
- ? { ...session, isVisible: event.params.isVisible }
43
- : session
36
+ session.sessionId === event.params.sessionId ? { ...session, isVisible: event.params.isVisible } : session
44
37
  );
45
38
  default: {
46
39
  const exhaustiveEvent: never = event;
@@ -52,10 +45,27 @@ function reduceMicroFrontendSessions(
52
45
  export function useMicroFrontendSessions(
53
46
  runtime: Pick<MicroFrontendRuntimeApi, 'onEvent'>
54
47
  ): readonly MicroFrontendSessionState[] {
55
- const [sessions, dispatch] = useReducer(
56
- reduceMicroFrontendSessions,
57
- INITIAL_SESSIONS
58
- );
48
+ const [sessions, dispatch] = useReducer(reduceMicroFrontendSessions, INITIAL_SESSIONS);
49
+ const previousSessionsRef = useRef(sessions);
50
+
51
+ useEffect(() => {
52
+ const previousSessions = previousSessionsRef.current;
53
+ previousSessionsRef.current = sessions;
54
+ const activeSessionIds = new Set(sessions.map(({ sessionId }) => sessionId));
55
+ const activeAppNames = new Set(sessions.map(({ appName }) => appName));
56
+ const inactiveAppNames = new Set(
57
+ previousSessions
58
+ .filter(({ sessionId }) => !activeSessionIds.has(sessionId))
59
+ .map(({ appName }) => appName)
60
+ .filter((appName) => !activeAppNames.has(appName))
61
+ );
62
+
63
+ inactiveAppNames.forEach((appName) => {
64
+ void disposeAppResources(appName).catch((error) => {
65
+ console.error(`Failed to dispose micro-frontend app resources for '${appName}'`, error);
66
+ });
67
+ });
68
+ }, [sessions]);
59
69
 
60
70
  useLayoutEffect(() => {
61
71
  installPendingHostComponentBridge();