diff --git a/packages/plugin-micro-frontend/package.json b/packages/plugin-micro-frontend/package.json index 198538a38..73b1d856c 100644 --- a/packages/plugin-micro-frontend/package.json +++ b/packages/plugin-micro-frontend/package.json @@ -45,12 +45,14 @@ "*.d.ts" ], "devDependencies": { + "@types/babel__core": "^7", "@vitest/coverage-v8": "^4.0.12", "tsdown": "catalog:tools", "typescript": "catalog:tools", "vitest": "^4.0.12" }, "dependencies": { + "@babel/core": "7.28.5", "@granite-js/plugin-core": "workspace:*", "@granite-js/utils": "workspace:*", "es-toolkit": "^1.39.8", diff --git a/packages/plugin-micro-frontend/src/microFrontendPlugin.ts b/packages/plugin-micro-frontend/src/microFrontendPlugin.ts index 8d75b7a3c..9b573739d 100644 --- a/packages/plugin-micro-frontend/src/microFrontendPlugin.ts +++ b/packages/plugin-micro-frontend/src/microFrontendPlugin.ts @@ -5,6 +5,7 @@ import { prepareLocalDirectory } from '@granite-js/utils'; import { getPreludeConfig } from './prelude'; import { fetchRemoteBundle } from './remote'; import { virtualSharedConfig } from './resolver'; +import { createScopedTimersTransformer } from './scopedTimers'; import type { MicroFrontendPluginOptions } from './types'; import { intoShared } from './utils/intoShared'; @@ -35,10 +36,16 @@ export const microFrontendPlugin = async (options: MicroFrontendPluginOptions): const isReactNativeShared = Boolean(nonEagerEntries.find(([libName]) => libName === 'react-native')); const virtualShared = virtualSharedConfig(nonEagerEntries); + const isRemoteContainer = options.remote == null; return { name: 'micro-frontend-plugin', config: { + transformer: isRemoteContainer + ? { + transformSync: createScopedTimersTransformer({ containerName: options.name }), + } + : undefined, extra: isReactNativeShared ? { skipReactNativePolyfills: true, skipReactNativeInitializeCore: true } : undefined, resolver: { alias: virtualShared.alias, diff --git a/packages/plugin-micro-frontend/src/runtime/createContainer.ts b/packages/plugin-micro-frontend/src/runtime/createContainer.ts index 4c718bacf..1744ac0bf 100644 --- a/packages/plugin-micro-frontend/src/runtime/createContainer.ts +++ b/packages/plugin-micro-frontend/src/runtime/createContainer.ts @@ -1,21 +1,33 @@ import type { RemoteConfig, SharedConfig, ExposeConfig } from '../types'; +import { createScopeRef, getContainerScope, getRemoteContext } from './remoteScope'; import type { Container } from './types'; export function createContainer( name: string, config: { remote?: RemoteConfig; shared?: SharedConfig; exposes?: ExposeConfig } ) { - if (typeof global.__MICRO_FRONTEND__.__INSTANCES__[name] === 'number') { - throw new Error(`'${name}' container already registered`); - } - - const containerIndex = global.__MICRO_FRONTEND__.__INSTANCES__.length; + const scope = getContainerScope(name, config); const container: Container = { name, config, exposeMap: {}, }; + if (scope != null) { + container.scope = scope; + scope.name = name; + scope.container = container; + getRemoteContext().scopes[name] = createScopeRef(scope); + + return container; + } + + if (typeof global.__MICRO_FRONTEND__.__INSTANCES__[name] === 'number') { + throw new Error(`'${name}' container already registered`); + } + + const containerIndex = global.__MICRO_FRONTEND__.__INSTANCES__.length; + Object.defineProperty(global.__MICRO_FRONTEND__.__INSTANCES__, name, { value: containerIndex, enumerable: false, diff --git a/packages/plugin-micro-frontend/src/runtime/index.ts b/packages/plugin-micro-frontend/src/runtime/index.ts index 1312a278d..5eb73abc3 100644 --- a/packages/plugin-micro-frontend/src/runtime/index.ts +++ b/packages/plugin-micro-frontend/src/runtime/index.ts @@ -2,5 +2,21 @@ export { createContainer } from './createContainer'; export { registerShared } from './registerShared'; export { exposeModule } from './exposeModule'; export { getContainer, parseRemotePath, importRemoteModule } from './utils'; +export { + cancelScopedAnimationFrame, + clearCurrentRemoteScope, + clearScopedInterval, + clearScopedTimeout, + createRemoteScope, + getCurrentRemoteScope, + getRemoteContext, + getRemoteScope, + getScopedResourceScope, + releaseRemoteScope, + requestScopedAnimationFrame, + setScopedInterval, + setScopedTimeout, +} from './remoteScope'; export type { RuntimeContext, Container, Module, SharedModuleRegistry } from './types'; +export type { RemoteContext, RemoteScope, RemoteScopeRef } from './remoteScope'; diff --git a/packages/plugin-micro-frontend/src/runtime/remoteScope.ts b/packages/plugin-micro-frontend/src/runtime/remoteScope.ts new file mode 100644 index 000000000..8d566fa6f --- /dev/null +++ b/packages/plugin-micro-frontend/src/runtime/remoteScope.ts @@ -0,0 +1,440 @@ +import type { Container } from './types'; + +export const MICRO_FRONTEND_REMOTE_CONTEXT_KEY = '__GRANITE_MICRO_FRONTEND_REMOTE__'; + +type ResourceName = 'timeouts' | 'intervals' | 'animationFrames'; + +export interface RemoteScope { + name: string; + appName: string | null; + container: Container | null; + finalized: boolean; + hostSkeletonRoutes: unknown[]; + releaseFinalizerScheduled: boolean; + released: boolean; + resources: Record; +} + +export interface RemoteScopeRef { + deref(): RemoteScope | null | undefined; +} + +export interface RemoteContext { + currentScope: RemoteScope | null; + releasedScopeNames: Record; + scopes: Record; + releaseScope?: typeof releaseRemoteScope; + getScopedResourceScope?: typeof getScopedResourceScope; + setScopedTimeout?: typeof setScopedTimeout; + clearScopedTimeout?: typeof clearScopedTimeout; + setScopedInterval?: typeof setScopedInterval; + clearScopedInterval?: typeof clearScopedInterval; + requestScopedAnimationFrame?: typeof requestScopedAnimationFrame; + cancelScopedAnimationFrame?: typeof cancelScopedAnimationFrame; +} + +interface RemoteGlobal { + [MICRO_FRONTEND_REMOTE_CONTEXT_KEY]?: RemoteContext; + setTimeout?: (...args: any[]) => unknown; + clearTimeout?: (timeoutId: unknown) => unknown; + setInterval?: (...args: any[]) => unknown; + clearInterval?: (intervalId: unknown) => unknown; + requestAnimationFrame?: (handler: (...args: any[]) => unknown) => unknown; + cancelAnimationFrame?: (animationFrameId: unknown) => unknown; +} + +export function getGlobalObject() { + if (typeof globalThis !== 'undefined') { + return globalThis as typeof globalThis & RemoteGlobal; + } + + return Function('return this')() as RemoteGlobal; +} + +export function getRemoteContext() { + const globalObject = getGlobalObject(); + + let remoteContext = globalObject[MICRO_FRONTEND_REMOTE_CONTEXT_KEY]; + + if (remoteContext == null) { + remoteContext = { + currentScope: null, + releasedScopeNames: {}, + scopes: {}, + }; + } + + remoteContext.currentScope ??= null; + remoteContext.releasedScopeNames ??= {}; + remoteContext.scopes ??= {}; + + remoteContext.releaseScope = releaseRemoteScope; + remoteContext.getScopedResourceScope = getScopedResourceScope; + remoteContext.setScopedTimeout = setScopedTimeout; + remoteContext.clearScopedTimeout = clearScopedTimeout; + remoteContext.setScopedInterval = setScopedInterval; + remoteContext.clearScopedInterval = clearScopedInterval; + remoteContext.requestScopedAnimationFrame = requestScopedAnimationFrame; + remoteContext.cancelScopedAnimationFrame = cancelScopedAnimationFrame; + + globalObject[MICRO_FRONTEND_REMOTE_CONTEXT_KEY] = remoteContext; + + return remoteContext; +} + +export function createScopeRef(scope: RemoteScope): RemoteScopeRef { + if (typeof WeakRef === 'function') { + return new WeakRef(scope); + } + + return { + deref() { + return scope; + }, + }; +} + +export function createRemoteScope(name: string): RemoteScope { + const scope: RemoteScope = { + name, + appName: null, + container: null, + finalized: false, + hostSkeletonRoutes: [], + releaseFinalizerScheduled: false, + released: false, + resources: { + timeouts: [], + intervals: [], + animationFrames: [], + }, + }; + const remoteContext = getRemoteContext(); + + remoteContext.currentScope = scope; + remoteContext.scopes[name] = createScopeRef(scope); + + return scope; +} + +export function getCurrentRemoteScope() { + const remoteContext = getRemoteContext(); + const scope = remoteContext.currentScope; + + if (scope?.released === true) { + remoteContext.currentScope = null; + return null; + } + + return scope; +} + +export function clearCurrentRemoteScope(scope: RemoteScope) { + const remoteContext = getRemoteContext(); + + if (remoteContext.currentScope === scope) { + remoteContext.currentScope = null; + } +} + +export function getRemoteScope(name: string) { + const remoteContext = getRemoteContext(); + const scopeRef = remoteContext.scopes[name]; + const scope = scopeRef == null ? null : scopeRef.deref(); + + if (scope == null) { + delete remoteContext.scopes[name]; + return null; + } + + return scope.released === true ? null : scope; +} + +function getScope(scopeOrName: RemoteScope | string | null | undefined) { + return typeof scopeOrName === 'string' ? getRemoteScope(scopeOrName) : scopeOrName; +} + +function getResourceScope(scopeOrName: RemoteScope | string | null | undefined) { + if (typeof scopeOrName !== 'string') { + return scopeOrName; + } + + const remoteContext = getRemoteContext(); + const scopeRef = remoteContext.scopes[scopeOrName]; + const scope = scopeRef == null ? null : scopeRef.deref(); + + if (scope == null) { + delete remoteContext.scopes[scopeOrName]; + return null; + } + + return scope; +} + +export function getScopedResourceScope(scopeOrName: RemoteScope | string | null | undefined) { + return getResourceScope(scopeOrName); +} + +function shouldDropScopedResource(scopeOrName: RemoteScope | string | null | undefined, scope: RemoteScope | null | undefined) { + if (scope == null) { + return typeof scopeOrName !== 'string' || getRemoteContext().releasedScopeNames[scopeOrName] === true; + } + + return scope.released === true || scope.finalized === true; +} + +function getScopeResources(scope: RemoteScope) { + scope.resources ??= { + timeouts: [], + intervals: [], + animationFrames: [], + }; + + return scope.resources; +} + +function addResource(scope: RemoteScope | null | undefined, resourceName: ResourceName, resourceId: unknown) { + if (scope == null || scope.finalized === true) { + return; + } + + const resourceIds = getScopeResources(scope)[resourceName]; + + if (!resourceIds.includes(resourceId)) { + resourceIds.push(resourceId); + } +} + +function removeResource(scope: RemoteScope | null | undefined, resourceName: ResourceName, resourceId: unknown) { + if (scope == null || scope.resources == null) { + return; + } + + const resourceIds = scope.resources[resourceName]; + const index = resourceIds.indexOf(resourceId); + + if (index >= 0) { + resourceIds.splice(index, 1); + } +} + +function clearResources(scope: RemoteScope, resourceName: ResourceName, clearResource: (resourceId: unknown) => void) { + const resourceIds = scope.resources?.[resourceName]; + + if (resourceIds == null) { + return; + } + + while (resourceIds.length > 0) { + clearResource(resourceIds.pop()); + } +} + +export function setScopedTimeout( + scopeOrName: RemoteScope | string | null | undefined, + handler: unknown, + timeout?: unknown, + ...args: unknown[] +) { + const scope = getResourceScope(scopeOrName); + const timeoutRef: { id: unknown } = { id: undefined }; + + if (shouldDropScopedResource(scopeOrName, scope)) { + return undefined; + } + + const wrappedHandler = + typeof handler === 'function' + ? function (this: unknown, ...handlerArgs: unknown[]) { + removeResource(scope, 'timeouts', timeoutRef.id); + if (shouldDropScopedResource(scopeOrName, scope)) { + return undefined; + } + + return handler.apply(this, handlerArgs); + } + : handler; + + const timeoutId = getGlobalObject().setTimeout?.(...[wrappedHandler, timeout].concat(args)); + timeoutRef.id = timeoutId; + addResource(scope, 'timeouts', timeoutId); + + return timeoutId; +} + +export function clearScopedTimeout(scopeOrName: RemoteScope | string | null | undefined, timeoutId: unknown) { + removeResource(getResourceScope(scopeOrName), 'timeouts', timeoutId); + return getGlobalObject().clearTimeout?.(timeoutId); +} + +export function setScopedInterval( + scopeOrName: RemoteScope | string | null | undefined, + handler: unknown, + timeout?: unknown, + ...args: unknown[] +) { + const scope = getResourceScope(scopeOrName); + + if (shouldDropScopedResource(scopeOrName, scope)) { + return undefined; + } + + const wrappedHandler = + typeof handler === 'function' + ? function (this: unknown, ...handlerArgs: unknown[]) { + if (shouldDropScopedResource(scopeOrName, scope)) { + return undefined; + } + + return handler.apply(this, handlerArgs); + } + : handler; + + const intervalId = getGlobalObject().setInterval?.(...[wrappedHandler, timeout].concat(args)); + + addResource(scope, 'intervals', intervalId); + + return intervalId; +} + +export function clearScopedInterval(scopeOrName: RemoteScope | string | null | undefined, intervalId: unknown) { + removeResource(getResourceScope(scopeOrName), 'intervals', intervalId); + return getGlobalObject().clearInterval?.(intervalId); +} + +export function requestScopedAnimationFrame(scopeOrName: RemoteScope | string | null | undefined, handler: unknown) { + const scope = getResourceScope(scopeOrName); + const animationFrameRef: { id: unknown } = { id: undefined }; + + if (shouldDropScopedResource(scopeOrName, scope)) { + return undefined; + } + + const wrappedHandler = + typeof handler === 'function' + ? function (this: unknown, ...handlerArgs: unknown[]) { + removeResource(scope, 'animationFrames', animationFrameRef.id); + if (shouldDropScopedResource(scopeOrName, scope)) { + return undefined; + } + + return handler.apply(this, handlerArgs); + } + : handler; + + const animationFrameId = getGlobalObject().requestAnimationFrame?.(wrappedHandler as (...args: any[]) => unknown); + animationFrameRef.id = animationFrameId; + addResource(scope, 'animationFrames', animationFrameId); + + return animationFrameId; +} + +export function cancelScopedAnimationFrame(scopeOrName: RemoteScope | string | null | undefined, animationFrameId: unknown) { + removeResource(getResourceScope(scopeOrName), 'animationFrames', animationFrameId); + return getGlobalObject().cancelAnimationFrame?.(animationFrameId); +} + +function finalizeRemoteScope(scope: RemoteScope) { + const remoteContext = getRemoteContext(); + const scopeName = typeof scope.name === 'string' ? scope.name : null; + + clearResources(scope, 'timeouts', timeoutId => { + getGlobalObject().clearTimeout?.(timeoutId); + }); + clearResources(scope, 'intervals', intervalId => { + getGlobalObject().clearInterval?.(intervalId); + }); + clearResources(scope, 'animationFrames', animationFrameId => { + getGlobalObject().cancelAnimationFrame?.(animationFrameId); + }); + + scope.finalized = true; + + if (scopeName != null) { + const scopeRef = remoteContext.scopes[scopeName]; + + if (scopeRef == null || scopeRef.deref() === scope) { + delete remoteContext.scopes[scopeName]; + } + } +} + +function scheduleRemoteScopeFinalization(scope: RemoteScope) { + if (scope.releaseFinalizerScheduled === true) { + return; + } + + scope.releaseFinalizerScheduled = true; + + if (typeof getGlobalObject().setTimeout === 'function') { + getGlobalObject().setTimeout?.(() => { + finalizeRemoteScope(scope); + }, 0); + return; + } + + finalizeRemoteScope(scope); +} + +export function releaseRemoteScope(scopeOrName: RemoteScope | string | null | undefined) { + const remoteContext = getRemoteContext(); + const scope = getScope(scopeOrName); + + if (scope == null) { + return false; + } + + scope.released = true; + const scopeName = typeof scope.name === 'string' ? scope.name : null; + + if (scopeName != null) { + remoteContext.releasedScopeNames[scopeName] = true; + } + + clearResources(scope, 'timeouts', timeoutId => { + getGlobalObject().clearTimeout?.(timeoutId); + }); + clearResources(scope, 'intervals', intervalId => { + getGlobalObject().clearInterval?.(intervalId); + }); + clearResources(scope, 'animationFrames', animationFrameId => { + getGlobalObject().cancelAnimationFrame?.(animationFrameId); + }); + + if (remoteContext.currentScope === scope) { + remoteContext.currentScope = null; + } + + if (scope.container != null) { + scope.container.scope = null; + scope.container.exposeMap = {}; + } + + scope.container = null; + scope.hostSkeletonRoutes = []; + scheduleRemoteScopeFinalization(scope); + + return true; +} + +export function isRemoteContainerConfig(config: unknown) { + return config == null || (typeof config === 'object' && (config as { remote?: unknown }).remote == null); +} + +export function getContainerScope(name: string, config: unknown) { + const remoteContext = getRemoteContext(); + + if (remoteContext.currentScope?.released === true) { + remoteContext.currentScope = null; + } + + if (remoteContext.currentScope != null) { + return remoteContext.currentScope; + } + + if (isRemoteContainerConfig(config)) { + return createRemoteScope(name); + } + + return null; +} diff --git a/packages/plugin-micro-frontend/src/runtime/runtime.spec.ts b/packages/plugin-micro-frontend/src/runtime/runtime.spec.ts index b9e836d45..18ac61d29 100644 --- a/packages/plugin-micro-frontend/src/runtime/runtime.spec.ts +++ b/packages/plugin-micro-frontend/src/runtime/runtime.spec.ts @@ -1,21 +1,30 @@ -import { describe, it, expect, beforeAll } from 'vitest'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; import { createContainer } from './createContainer'; import { exposeModule } from './exposeModule'; import { registerShared } from './registerShared'; +import { getRemoteContext, getRemoteScope, releaseRemoteScope, setScopedTimeout } from './remoteScope'; describe('runtime', () => { - beforeAll(() => { + beforeEach(() => { global.__MICRO_FRONTEND__ = { __INSTANCES__: [] as any, __SHARED__: {}, }; + delete (globalThis as any).__GRANITE_MICRO_FRONTEND_REMOTE__; + vi.useRealTimers(); }); describe('container', () => { const CONTAINER_NAME = 'test-app'; - it('should register a new container', () => { - const container = createContainer(CONTAINER_NAME, { shared: { lib: { eager: true } } }); + it('should register a host container', () => { + const container = createContainer(CONTAINER_NAME, { + remote: { + host: 'localhost', + port: 8082, + }, + shared: { lib: { eager: true } }, + }); expect(container.name).toBe(CONTAINER_NAME); expect(global.__MICRO_FRONTEND__.__INSTANCES__.length).toBe(1); @@ -23,6 +32,10 @@ describe('runtime', () => { [ { "config": { + "remote": { + "host": "localhost", + "port": 8082, + }, "shared": { "lib": { "eager": true, @@ -37,7 +50,49 @@ describe('runtime', () => { }); it('should throw an error if a container is already registered', () => { - expect(() => createContainer(CONTAINER_NAME, { shared: { react: { eager: true } } })).toThrow(); + const config = { + remote: { + host: 'localhost', + port: 8082, + }, + shared: { react: { eager: true } }, + }; + + createContainer(CONTAINER_NAME, config); + + expect(() => createContainer(CONTAINER_NAME, config)).toThrow(); + }); + + it('should keep remote containers in a weak runtime scope instead of the global instance registry', () => { + const container = createContainer(CONTAINER_NAME, { shared: { lib: { eager: true } } }); + const remoteContext = getRemoteContext(); + const scope = getRemoteScope(CONTAINER_NAME); + + expect(global.__MICRO_FRONTEND__.__INSTANCES__.length).toBe(0); + expect(container.scope).toBe(scope); + expect(scope?.container).toBe(container); + expect(remoteContext.currentScope).toBe(scope); + expect((globalThis as any).__GRANITE_MICRO_FRONTEND_REMOTE__).toBe(remoteContext); + }); + + it('should release remote containers and scoped resources', () => { + vi.useFakeTimers(); + const container = createContainer(CONTAINER_NAME, {}); + const scope = getRemoteScope(CONTAINER_NAME); + const callback = vi.fn(); + const timeoutId = setScopedTimeout(scope, callback, 1000); + + expect(scope?.resources.timeouts).toContain(timeoutId); + + expect(releaseRemoteScope(scope)).toBe(true); + + vi.advanceTimersByTime(1000); + + expect(callback).not.toHaveBeenCalled(); + expect(container.exposeMap).toEqual({}); + expect(container.scope).toBe(null); + expect(scope?.container).toBe(null); + expect(getRemoteScope(CONTAINER_NAME)).toBe(null); }); }); @@ -51,6 +106,8 @@ describe('runtime', () => { }); it('should throw an error if a shared module is already registered', () => { + registerShared('lib-name', {}); + expect(() => registerShared('lib-name', {})).toThrow(); }); }); @@ -58,7 +115,12 @@ describe('runtime', () => { describe('exposeModule', () => { it('should expose a module', () => { const mod = {}; - const container = createContainer('exposed-app-1', {}); + const container = createContainer('exposed-app-1', { + remote: { + host: 'localhost', + port: 8082, + }, + }); exposeModule(container, './my-module', mod); exposeModule(container, 'my-module/foo', mod); exposeModule(container, '../../../my-module', mod); @@ -70,7 +132,12 @@ describe('runtime', () => { it('should throw an error if a module is already exposed', () => { const mod = {}; - const container = createContainer('exposed-app-2', {}); + const container = createContainer('exposed-app-2', { + remote: { + host: 'localhost', + port: 8082, + }, + }); exposeModule(container, './my-module', mod); exposeModule(container, 'my-module/foo', mod); exposeModule(container, '../../../my-module', mod); diff --git a/packages/plugin-micro-frontend/src/runtime/types.ts b/packages/plugin-micro-frontend/src/runtime/types.ts index 84c2f4aa5..344e4d095 100644 --- a/packages/plugin-micro-frontend/src/runtime/types.ts +++ b/packages/plugin-micro-frontend/src/runtime/types.ts @@ -13,6 +13,7 @@ export interface RuntimeContext { export interface Container { name: string; exposeMap: Record; + scope?: import('./remoteScope').RemoteScope | null; config: { remote?: RemoteConfig; shared?: SharedConfig; diff --git a/packages/plugin-micro-frontend/src/runtime/utils.spec.ts b/packages/plugin-micro-frontend/src/runtime/utils.spec.ts index 555d1e8e2..274a8738b 100644 --- a/packages/plugin-micro-frontend/src/runtime/utils.spec.ts +++ b/packages/plugin-micro-frontend/src/runtime/utils.spec.ts @@ -1,22 +1,35 @@ -import { describe, it, expect, beforeAll } from 'vitest'; +import { describe, it, expect, beforeEach } from 'vitest'; import { createContainer } from './createContainer'; import { exposeModule } from './exposeModule'; import { normalizePath, parseRemotePath, importRemoteModule, getContainer, toESM } from './utils'; describe('utils', () => { + beforeEach(() => { + global.__MICRO_FRONTEND__ = { + __INSTANCES__: [] as any, + __SHARED__: {}, + }; + delete (globalThis as any).__GRANITE_MICRO_FRONTEND_REMOTE__; + }); + describe('getContainer', () => { - beforeAll(() => { - global.__MICRO_FRONTEND__ = { - __INSTANCES__: [] as any, - __SHARED__: {}, - }; + it('should return the container', () => { + const container = createContainer('remoteApp', { + remote: { + host: 'localhost', + port: 8082, + }, + }); + + expect(getContainer('remoteApp')).toBe(container); + expect(getContainer('unknown')).toBe(null); }); - it('should return the container', () => { + it('should return a scoped remote container', () => { const container = createContainer('remoteApp', {}); expect(getContainer('remoteApp')).toBe(container); - expect(getContainer('unknown')).toBe(null); + expect(global.__MICRO_FRONTEND__.__INSTANCES__.length).toBe(0); }); }); @@ -49,16 +62,14 @@ describe('utils', () => { }); describe('importRemoteModule', () => { - beforeAll(() => { - global.__MICRO_FRONTEND__ = { - __INSTANCES__: [] as any, - __SHARED__: {}, - }; - }); - it('should return the exposed module', () => { const Button = {}; - const container = createContainer('remoteApp', {}); + const container = createContainer('remoteApp', { + remote: { + host: 'localhost', + port: 8082, + }, + }); exposeModule(container, './components/Button', { default: Button }); exposeModule(container, '../../../components/Button', { default: Button }); diff --git a/packages/plugin-micro-frontend/src/runtime/utils.ts b/packages/plugin-micro-frontend/src/runtime/utils.ts index 100906ef7..ad6cef92d 100644 --- a/packages/plugin-micro-frontend/src/runtime/utils.ts +++ b/packages/plugin-micro-frontend/src/runtime/utils.ts @@ -1,6 +1,13 @@ +import { getRemoteScope } from './remoteScope'; import type { Module } from './types'; export function getContainer(instanceName: string) { + const remoteScope = getRemoteScope(instanceName); + + if (remoteScope?.container != null) { + return remoteScope.container; + } + const containerIndex = __MICRO_FRONTEND__.__INSTANCES__[instanceName]; return typeof containerIndex === 'number' ? __MICRO_FRONTEND__.__INSTANCES__[containerIndex]! : null; diff --git a/packages/plugin-micro-frontend/src/scopedTimers.spec.ts b/packages/plugin-micro-frontend/src/scopedTimers.spec.ts new file mode 100644 index 000000000..ef70a94fc --- /dev/null +++ b/packages/plugin-micro-frontend/src/scopedTimers.spec.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from 'vitest'; +import { transformScopedTimers } from './scopedTimers'; + +const options = { + containerName: 'remote-app', +}; + +describe('transformScopedTimers', () => { + it('should scope global timer calls to the remote container', () => { + const input = ` + const timeoutId = setTimeout(() => run(), 300000); + clearTimeout(timeoutId); + const intervalId = setInterval(tick, 1000); + clearInterval(intervalId); + const animationFrameId = requestAnimationFrame(render); + cancelAnimationFrame(animationFrameId); + `; + + expect(transformScopedTimers('/app/src/query.ts', input, options)).toMatchInlineSnapshot(` + "var __graniteScopedRemoteScope = globalThis.__GRANITE_MICRO_FRONTEND_REMOTE__.getScopedResourceScope("remote-app"); + + const timeoutId = globalThis.__GRANITE_MICRO_FRONTEND_REMOTE__.setScopedTimeout(__graniteScopedRemoteScope,() => run(), 300000); + globalThis.__GRANITE_MICRO_FRONTEND_REMOTE__.clearScopedTimeout(__graniteScopedRemoteScope,timeoutId); + const intervalId = globalThis.__GRANITE_MICRO_FRONTEND_REMOTE__.setScopedInterval(__graniteScopedRemoteScope,tick, 1000); + globalThis.__GRANITE_MICRO_FRONTEND_REMOTE__.clearScopedInterval(__graniteScopedRemoteScope,intervalId); + const animationFrameId = globalThis.__GRANITE_MICRO_FRONTEND_REMOTE__.requestScopedAnimationFrame(__graniteScopedRemoteScope,render); + globalThis.__GRANITE_MICRO_FRONTEND_REMOTE__.cancelScopedAnimationFrame(__graniteScopedRemoteScope,animationFrameId); + " + `); + }); + + it('should keep import declarations before scope capture', () => { + const input = ` + import { foo } from './foo'; + + setTimeout(foo, 1); + `; + + expect(transformScopedTimers('/app/src/query.ts', input, options)).toMatchInlineSnapshot(` + " + import { foo } from './foo'; + var __graniteScopedRemoteScope = globalThis.__GRANITE_MICRO_FRONTEND_REMOTE__.getScopedResourceScope("remote-app"); + + + globalThis.__GRANITE_MICRO_FRONTEND_REMOTE__.setScopedTimeout(__graniteScopedRemoteScope,foo, 1); + " + `); + }); + + it('should ignore local timer bindings', () => { + const input = ` + const setTimeout = createTimer(); + setTimeout(callback, delay); + `; + + expect(transformScopedTimers('/app/src/query.ts', input, options)).toBe(input); + }); + + it('should skip micro frontend runtime files', () => { + const input = `setTimeout(callback, delay);`; + + expect(transformScopedTimers('/app/.granite/micro-frontend-runtime.js', input, options)).toBe(input); + expect(transformScopedTimers('/repo/packages/plugin-micro-frontend/src/runtime/remoteScope.ts', input, options)).toBe( + input + ); + }); +}); diff --git a/packages/plugin-micro-frontend/src/scopedTimers.ts b/packages/plugin-micro-frontend/src/scopedTimers.ts new file mode 100644 index 000000000..09413abcc --- /dev/null +++ b/packages/plugin-micro-frontend/src/scopedTimers.ts @@ -0,0 +1,287 @@ +import { transformSync } from '@babel/core'; + +const SCOPED_TIMER_CALLEES = { + clearInterval: 'clearScopedInterval', + clearTimeout: 'clearScopedTimeout', + cancelAnimationFrame: 'cancelScopedAnimationFrame', + requestAnimationFrame: 'requestScopedAnimationFrame', + setInterval: 'setScopedInterval', + setTimeout: 'setScopedTimeout', +} as const; + +const REMOTE_CONTEXT_IDENTIFIER = 'globalThis.__GRANITE_MICRO_FRONTEND_REMOTE__'; +const SCOPED_REMOTE_SCOPE_IDENTIFIER = '__graniteScopedRemoteScope'; + +const BASE_PARSER_PLUGINS = [ + 'classProperties', + 'classPrivateProperties', + 'classPrivateMethods', + 'decorators-legacy', + 'dynamicImport', + 'exportDefaultFrom', + 'exportNamespaceFrom', + 'importMeta', + 'jsx', + 'nullishCoalescingOperator', + 'objectRestSpread', + 'optionalChaining', + 'topLevelAwait', +] as const; + +type ScopedTimersOptions = { + containerName: string; +}; + +type Replacement = { + end: number; + start: number; + text: string; +}; + +type BabelPath = { + node: { + arguments?: unknown[]; + callee?: unknown; + }; + scope: { + getBinding(name: string): unknown; + }; +}; + +type BabelProgramPath = { + node: { + body?: Array<{ + directive?: string; + end?: number | null; + type?: string; + }>; + directives?: Array<{ + end?: number | null; + value?: { + end?: number | null; + }; + }>; + interpreter?: { + end?: number | null; + } | null; + }; +}; + +type BabelIdentifier = { + end?: number | null; + name: string; + start?: number | null; + type: 'Identifier'; +}; + +export function createScopedTimersTransformer(options: ScopedTimersOptions) { + return (id: string, code: string) => transformScopedTimers(id, code, options); +} + +export function transformScopedTimers(id: string, code: string, options: ScopedTimersOptions) { + if (!shouldTransform(id, code)) { + return code; + } + + const replacements = collectScopedTimerReplacements(id, code, options); + + if (replacements.length === 0) { + return code; + } + + return applyReplacements(code, replacements); +} + +function shouldTransform(id: string, code: string) { + const normalizedId = id.replace(/\\/g, '/').replace(/\?.*$/, ''); + + if ( + normalizedId.includes('/.granite/') || + normalizedId.includes('/packages/plugin-micro-frontend/src/runtime/') || + normalizedId.includes('/@granite-js/plugin-micro-frontend/') || + normalizedId.endsWith('/micro-frontend-runtime.js') + ) { + return false; + } + + return Object.keys(SCOPED_TIMER_CALLEES).some(callee => code.includes(callee)); +} + +function collectScopedTimerReplacements(id: string, code: string, options: ScopedTimersOptions) { + const parserPluginSets = getParserPluginSets(id); + + for (const parserPlugins of parserPluginSets) { + try { + return collectScopedTimerReplacementsWithParserPlugins(id, code, parserPlugins, options); + } catch { + continue; + } + } + + return []; +} + +function collectScopedTimerReplacementsWithParserPlugins( + id: string, + code: string, + parserPlugins: string[], + options: ScopedTimersOptions +) { + const replacements: Replacement[] = []; + let scopeCaptureInsertionIndex = 0; + + transformSync(code, { + ast: false, + babelrc: false, + code: false, + configFile: false, + filename: id, + parserOpts: { + plugins: parserPlugins as any, + sourceType: 'unambiguous', + }, + plugins: [ + () => ({ + visitor: { + Program(path: BabelProgramPath) { + scopeCaptureInsertionIndex = getScopeCaptureInsertionIndex(path.node); + }, + CallExpression(path: BabelPath) { + const callee = path.node.callee; + + if (!isIdentifier(callee)) { + return; + } + + const runtimeCallee = getRuntimeCallee(callee.name); + + if (runtimeCallee == null || isLocalTimerBinding(path.scope.getBinding(callee.name))) { + return; + } + + const replacement = createScopedTimerReplacement(code, callee, runtimeCallee); + + if (replacement == null) { + return; + } + + replacements.push(...replacement); + }, + }, + }), + ], + sourceType: 'unambiguous', + }); + + if (replacements.length > 0) { + replacements.push(createScopeCaptureReplacement(scopeCaptureInsertionIndex, options)); + } + + return replacements; +} + +function getParserPluginSets(id: string) { + const normalizedId = id.replace(/\\/g, '/').replace(/\?.*$/, ''); + const isTypeScript = /\.[cm]?tsx?$/.test(normalizedId); + + if (isTypeScript) { + return [[...BASE_PARSER_PLUGINS, 'typescript']]; + } + + return [[...BASE_PARSER_PLUGINS], [...BASE_PARSER_PLUGINS, 'flow']]; +} + +function createScopedTimerReplacement(code: string, callee: BabelIdentifier, runtimeCallee: string) { + if (callee.start == null || callee.end == null) { + return null; + } + + const openParenIndex = findNextNonWhitespaceIndex(code, callee.end); + + if (code[openParenIndex] !== '(') { + return null; + } + + return [ + { + start: callee.start, + end: callee.end, + text: `${REMOTE_CONTEXT_IDENTIFIER}.${runtimeCallee}`, + }, + { + start: openParenIndex + 1, + end: openParenIndex + 1, + text: `${SCOPED_REMOTE_SCOPE_IDENTIFIER},`, + }, + ]; +} + +function createScopeCaptureReplacement(insertionIndex: number, options: ScopedTimersOptions): Replacement { + return { + start: insertionIndex, + end: insertionIndex, + text: `${insertionIndex > 0 ? '\n' : ''}var ${SCOPED_REMOTE_SCOPE_IDENTIFIER} = ${REMOTE_CONTEXT_IDENTIFIER}.getScopedResourceScope("${options.containerName}");\n`, + }; +} + +function getScopeCaptureInsertionIndex(programNode: BabelProgramPath['node']) { + let insertionIndex = programNode.interpreter?.end ?? 0; + + for (const directive of programNode.directives ?? []) { + insertionIndex = Math.max(insertionIndex, directive.end ?? directive.value?.end ?? insertionIndex); + } + + for (const statement of programNode.body ?? []) { + if (statement.type === 'ImportDeclaration' || statement.directive != null) { + insertionIndex = Math.max(insertionIndex, statement.end ?? insertionIndex); + continue; + } + + break; + } + + return insertionIndex; +} + +function getRuntimeCallee(callee: string) { + return Object.prototype.hasOwnProperty.call(SCOPED_TIMER_CALLEES, callee) + ? SCOPED_TIMER_CALLEES[callee as keyof typeof SCOPED_TIMER_CALLEES] + : null; +} + +function isLocalTimerBinding(binding: unknown) { + if (binding == null || typeof binding !== 'object') { + return false; + } + + const bindingNodeType = (binding as { path?: { node?: { type?: unknown } } }).path?.node?.type; + + return bindingNodeType !== 'ClassMethod' && bindingNodeType !== 'ObjectMethod'; +} + +function applyReplacements(code: string, replacements: Replacement[]) { + let transformed = code; + const sortedReplacements = [...replacements].sort((a, b) => b.start - a.start || b.end - a.end); + + sortedReplacements.forEach(replacement => { + transformed = `${transformed.slice(0, replacement.start)}${replacement.text}${transformed.slice(replacement.end)}`; + }); + + return transformed; +} + +function findNextNonWhitespaceIndex(code: string, index: number) { + while (index < code.length && isWhitespace(code[index])) { + index += 1; + } + + return index; +} + +function isIdentifier(node: unknown): node is BabelIdentifier { + return node != null && typeof node === 'object' && (node as { type?: unknown }).type === 'Identifier'; +} + +function isWhitespace(char: string | undefined) { + return char != null && /\s/.test(char); +} diff --git a/packages/react-native/src/app/Granite.tsx b/packages/react-native/src/app/Granite.tsx index 3e3fff7d5..69505cf05 100644 --- a/packages/react-native/src/app/Granite.tsx +++ b/packages/react-native/src/app/Granite.tsx @@ -48,12 +48,26 @@ export interface GraniteProps { getInitialUrl?: (initialScheme: string) => string | undefined | Promise; } +type RemoteRuntimeScope = { + released?: boolean; +}; + +type GlobalWithRemoteRuntime = typeof globalThis & { + __GRANITE_MICRO_FRONTEND_REMOTE__?: { + currentScope?: RemoteRuntimeScope | null; + }; +}; + const createApp = () => { let _appName: string | null = null; setupPolyfills(); function registerComponent(appKey: string, component: React.ComponentType): string { + if (isRemoteScopeActive()) { + return appKey; + } + if (AppRegistry.getAppKeys().includes(appKey)) { // `AppRegistry.registerComponent` returns the app key. return appKey; @@ -121,6 +135,22 @@ const createApp = () => { }; }; +function isRemoteScopeActive() { + const globalObject = globalThis as GlobalWithRemoteRuntime; + const remoteContext = globalObject.__GRANITE_MICRO_FRONTEND_REMOTE__; + const scope = remoteContext?.currentScope ?? null; + + if (scope?.released === true) { + if (remoteContext != null) { + remoteContext.currentScope = null; + } + + return false; + } + + return scope != null; +} + /** * @public * @category Core diff --git a/yarn.lock b/yarn.lock index 23a754eea..45f64d2b7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5887,8 +5887,10 @@ __metadata: version: 0.0.0-use.local resolution: "@granite-js/plugin-micro-frontend@workspace:packages/plugin-micro-frontend" dependencies: + "@babel/core": "npm:7.28.5" "@granite-js/plugin-core": "workspace:*" "@granite-js/utils": "workspace:*" + "@types/babel__core": "npm:^7" "@vitest/coverage-v8": "npm:^4.0.12" es-toolkit: "npm:^1.39.8" picocolors: "npm:^1.1.1"