@edynamix/mf-isolation

中文文档
0.0.43发布于 2026/11/04UNLICENSEDTypeScriptES Module近 30 天下载 2958

CSS and overlay isolation helpers for eDynamix microfrontends.

原始使用文档
文档目录14 节
  1. Install
  2. Runtime boundary
  3. Build-time CSS isolation
  4. Root font size and rem
  5. Hosted module styles at runtime
  6. Document queries from hosted modules
  7. Angular with Vite
  8. Host contract
  9. Navigation manifest
  10. Angular base path in any letter case
  11. Angular CDK overlays
  12. Sharing Angular between modules
  13. Host-owned Zone.js
  14. Exports

@edynamix/mf-isolation

CSS isolation and Angular overlay helpers for microfrontends.

Install

bun add @edynamix/mf-isolation@latest

Runtime boundary

import { prepareMfBoundary } from '@edynamix/mf-isolation';

const disposeBoundary = prepareMfBoundary(element, {
  scopeId: 'portal',
});

// When the microfrontend unmounts:
disposeBoundary();

Build-time CSS isolation

For global and vendor CSS:

import { scopeRemoteCss } from '@edynamix/mf-isolation/postcss';

export default {
  css: {
    postcss: {
      plugins: [scopeRemoteCss({ scopeId: 'portal' })],
    },
  },
};

The host and remote must use the same scopeId. Both PostCSS plugins keep ordinary global and vendor selectors under the zero-specificity :where([data-edyn-mf="<scope-id>"]) boundary. Document-root descendants such as html body#app button or html body button keep the weight of the root they start from: it becomes :where([data-edyn-mf="<scope-id>"]):not(<carrier>), where :where() weighs nothing and the carrier (for example edyn-mf-root edyn-mf-root#edyn-mf-root for html body#app) has the same id, class and type counts as the replaced root and never matches the scope element. Module overrides therefore win or lose against Angular Material exactly as on the standalone page.

A module page had one <html> and one <body>, so a selector that needs a second one never matched there: html body html body .x (what a global partial compiles to when it is loaded inside html body {}), body .a body .b, or a document root after a combinator (.x html, .x :root). Scoping would turn those roots into the boundary and make the rule match hosted, so both PostCSS plugins drop such a selector, and the rule when no selector is left (0.0.36 or later). The runtime rescope below leaves it as written, where it matches nothing either.

Root font size and rem

rem always resolves against the page's <html>, even inside a shadow root. A module page that sized its own root (html, body { font-size: 12px }) drew every rem length from that size; hosted, the same lengths would use the host page's root. Both PostCSS plugins therefore record a resolvable font-size or font size on html or :root (px, %, em, rem, absolute units and size keywords, against the initial 16px) as --edyn-mf-rem on the module boundary, in a rule right after the one that sets it, so @media conditions and !important carry over. Sizing only body (or html body) leaves rem alone, as it did on the module's own page. A size that cannot be resolved at build time (var(), calc() with var(), viewport units, system fonts) is reported as a PostCSS warning; rem then keeps the host root size. CSS that never sizes the root is emitted exactly as before.

At runtime, provideMfOverlayRoot() and provideMfOverlayRootFromToken() start followModuleRootFontSize(shadowRoot) for the shadow root the overlay root sits in, from an environment initializer (before the first component renders) until the injector is destroyed. Once a stylesheet in that root declares --edyn-mf-rem, every rem length becomes calc(<n> * var(--edyn-mf-rem, 1rem)): in its stylesheets, including the component styles Angular adds later, and in inline style attributes, including ones inserted or changed later by templates, bindings or code. Both go through the CSSOM (rule.style and element.style), so @media, @container and @supports conditions, strings and url() keep their text, !important stays, and the rewrite is idempotent. Inline styles without an unfollowed rem length (Material's ripples and overlay positions) are skipped on a single attribute test, and a module whose CSS never sizes its root pays nothing: no inline observer is started. Rules added with insertRule() to an already rewritten sheet, and styles inside nested shadow roots, are not rewritten.

Hosted module styles at runtime

A host calls adaptHostedModuleStyles(shadowRoot) from @edynamix/mf-isolation on each module shadow root it creates, and the function it returns on unmount. It adapts what a module brings into that root at runtime, with no module rebuild:

  • rem follows the module's own root font size, as followModuleRootFontSize() does above;
  • rules a library injects at runtime for the document root never match inside a shadow root (Angular Material's html { --mat-focus-indicator-display: block; } left keyboard focus rings off), so they move onto the shadow host, as the PostCSS plugins do at build time: html, body, :root and html body become :host, and html .x, body > .x or :root .x become :host .x and :host > .x. A root with conditions of its own (html.dark, body#app, :root[dir=rtl]) is left alone, as is any rule that already names :host or data-edyn-mf. rescopeDocumentRootSelectors(selectorText) is the pure selector step and also reports how many qualified roots it left;
  • sticky headers written for the legacy navigation's top bar stick to the top of the host's content area: old pages scrolled under that 49px bar, so a sticky top of 49px or more (top: 49px, top: 50px) moves up by 49px, in stylesheet rules and in inline styles a module sets from code (the position-sticky directives' position: sticky; top: 50px), with its priority kept and once per value the module writes (0.0.34 or later). Smaller tops (0, 1px, for pages and panels that already scrolled below the bar), var(), % and calc() values, and non-sticky rules are left alone.

It is safe beside a module's own followModuleRootFontSize() on the same root (0.0.31 or later): stylesheet changes go through the CSSOM, which no mutation observer sees, every step is idempotent, and an inline style already followed never looks like work again, so neither side acts on the other's writes.

Document queries from hosted modules

A module that found its own elements through the global document (document.querySelector('.week-wrapper'), this.document.getElementById('notes-tab-3') through Angular's DOCUMENT) found them on its own page; hosted, they live in its shadow root, which document queries never enter, so the same calls return null or nothing. A host calls scopeDocumentQueries(shadowRoot) from @edynamix/mf-isolation next to adaptHostedModuleStyles() on each module shadow root it creates, and the function it returns on unmount.

While a root is registered, querySelector, querySelectorAll, getElementById and getElementsByClassName on that root's document first run as normal and keep their result whenever it finds anything. Only an empty result falls back to the registered roots, newest first (during a module switch the incoming module answers before the outgoing one), and the first root with a match answers. Calls on any other document, such as an iframe's or one from createHTMLDocument(), are untouched. The document prototype is patched once for any number of roots, shared with other copies of this package on the page, and restored when the last root stops; a method someone else wrapped after us keeps their wrapper, and ours inside it passes calls through.

The trade-offs: fallback results of querySelectorAll and getElementsByClassName are static NodeLists, not live collections, so a module that holds one and expects it to update must query again. A document match always wins, so a module asking for a class or id the host page also uses gets the host's element, as it would have before. Code anywhere on the page that relies on null for a selector one of the modules matches now gets the module's element. getElementsByTagName is not patched: tag queries nearly always match the host's own elements, so a fallback would rarely help and could hand a module's <link> or <style> to library code checking the page.

Angular with Vite

Angular applications must use the Angular-aware plugin so component CSS remains compatible with Emulated view encapsulation:

import { scopeAngularRemoteCss } from '@edynamix/mf-isolation/postcss';
import { defineConfig } from 'vite';

export default defineConfig({
  css: {
    postcss: {
      plugins: [scopeAngularRemoteCss({ scopeId: 'portal' })],
    },
  },
});

The Angular-aware plugin leaves Emulated .component.css, .component.scss, .component.sass and .component.less files as written (only @keyframes names get the module prefix), so Angular compiles exactly what it compiled on the module's own page: every component rule keeps its weight (mat-form-field[_ngcontent-x] stays (0,1,1) and still loses to a global (0,2,0) rule), template content rendered in an overlay outside the component's host keeps its component styles, and a top-level ::ng-deep rule stays global to the module. Angular's _ngcontent and _nghost attributes keep component rules to their component, and a hosted module's component styles live in its own shadow root, so no boundary is needed. Components declaring ViewEncapsulation.None use the global selector behavior described above, since Angular leaves their CSS global.

Run this plugin through Vite/PostCSS before Angular AOT compilation. Do not combine it with scopeRemoteCss in the same Vite configuration.

Host contract

The host passes an IMfShellContext to a module's mount function and expects an IMfModuleLifecycle export. Check the host version with isSupportedMfHostContract: it accepts MF_MINIMUM_HOST_CONTRACT_VERSION and every later version, so additive contract changes do not need a lockstep release of the host and all modules.

import { type IMfShellContext, isSupportedMfHostContract, MF_MINIMUM_HOST_CONTRACT_VERSION } from '@edynamix/mf-isolation/host-contract';

export const mount = (element: HTMLElement, shellContext: IMfShellContext): void => {
  if (!isSupportedMfHostContract(shellContext)) {
    throw new Error(`This module requires host contract version ${MF_MINIMUM_HOST_CONTRACT_VERSION} or later.`);
  }
  // ...
};

Navigation manifest

Declare module-owned navigation as validated data and emit navigation.json from the same Vite build:

import {
  defineMfNavigation,
  MF_NAVIGATION_CONTRACT_VERSION,
} from '@edynamix/mf-isolation/navigation';
import { mfNavigationPlugin } from '@edynamix/mf-isolation/vite';
import { defineConfig } from 'vite';

const navigation = defineMfNavigation({
  applicationId: 22,
  contractVersion: MF_NAVIGATION_CONTRACT_VERSION,
  items: [
    {
      id: 'stockmaster.for-sale',
      label: 'For Sale',
      order: 1,
      path: 'usedstock/for-sale',
    },
  ],
  label: 'Stock Master',
  landingPath: 'usedstock',
  moduleId: 'stockmaster',
});

export default defineConfig({
  plugins: [mfNavigationPlugin({ navigation })],
});

Routes are module-relative and contain no leading slash. The plugin validates the complete tree, emits deterministic JSON with a SHA-256 revision during builds, and serves the same manifest with Cache-Control: no-cache in development.

For standalone development, combine the manifest with the host-owned navigation browser entry and development BFF:

import {
  MF_DEVELOPMENT_HOST_NAVIGATION_PATH,
  mfDevelopmentBffPlugin,
} from '@edynamix/mf-isolation/vite-development';

mfDevelopmentBffPlugin({
  hostNavigationUrl: 'https://host.example.test/host-navigation/index.js',
  moduleApiOrigin: 'https://module.example.test',
  moduleApiPath: '/stockmaster/api/',
  // The sign-in service and client the module's own server accepts (its Web.config).
  moduleSignIn: { issuer: 'https://login.example.test', clientId: 'module-local-client' },
});

const localHostNavigationUrl = new URL(
  MF_DEVELOPMENT_HOST_NAVIGATION_PATH,
  'https://localhost:4200',
).href;

hostNavigationUrl selects the host environment. Load the browser entry from localHostNavigationUrl; the BFF serves the selected bundle and its host-owned logo assets through the secure local origin, avoiding mixed-content failures when the selected host is an HTTP localhost instance. Host API calls go to the selected host origin, while module API calls go to the explicit moduleApiOrigin. The BFF performs OIDC authorization code with PKCE, retains tokens and module API cookies in the development process, and forwards authenticated API requests without exposing either to module JavaScript. The host's sign-in (the issuer, client and scope the host's /api/auth/oidc-configuration names) authorizes everything sent to the host: its APIs, the navigation and the portal API it serves. moduleSignIn names the sign-in service and client the module's own server accepts (issuer, clientId, and scope, which defaults to openid profile email offline_access); it is required, and the plugin refuses to start without a valid one. When it names the host's issuer and client, one sign-in serves both, as before. Otherwise /api/dev-auth/login signs the developer in to the host first and then to the module's sign-in service, each with its own PKCE flow and a state that names its step, and module API requests carry the module's token while every other request carries the host's. Each sign-in refreshes on its own, and signing out revokes both and ends the session at each sign-in service in turn. The host's refresh token is also kept in an HttpOnly __Host-edyn_dev_refresh cookie for the local origin, and a separate module sign-in's in __Host-edyn_dev_module_refresh, so signing in survives dev server restarts and switching between modules until a token expires; a restart needs both cookies when the module signs in separately. The sign-in service accepts each refresh token once, so requests the browser sent before a restart's rotated cookie reached it share that restore instead of spending the token again and clearing the new cookie. Host API requests keep the browser's navigation selection headers, which the host validates. Module API requests instead carry the selection the host last confirmed, read from /api/navigation responses and navigation stream events, through X-Edyn-Navigation-Group-Id, X-Edyn-Navigation-Dealer-Ids and, for a single dealer, X-Edyn-Navigation-Dealer-Id; module backends must still authorize those values before constructing module-specific session state. The standalone navigation's portal menus (/api/navigation/portal/...) go to the selected host's own portal API (/portal/api/...) the same way, so they also load from a deployed host, which relays nothing under /api/navigation/portal. Request bodies are forwarded with a fixed length (up to 100 MB), because deployed module front ends reject chunked bodies. Before a module's first API call the BFF asks the host for the current selection, because the local shell starts the module beside the navigation rather than after it. A module's redirect to its legacy /login page leads to the development sign-in while signed out; signed in, it shows that the module API refused the session instead of reloading the module in a loop. When nothing answers at moduleApiOrigin, module API calls answer 502 module_api_unreachable with that origin and the reason, and the terminal says so once until the module server answers again.

Angular base path in any letter case

IIS serves a module at any letter case of its path, and old bookmarks keep the old case (/AutoPoint/...), but Angular's Location strips the base path only on an exact match. Install the module's LocationStrategy from this package so /AutoPoint/... and /autopoint/... both open the module, and the address keeps the case it was opened with (no redirect). Earlier names of the module's path can be passed as aliases:

import { NgModule } from '@angular/core';
import { provideCaseInsensitiveBasePath } from '@edynamix/mf-isolation/angular';

@NgModule({
  providers: [...provideCaseInsensitiveBasePath(['motcleanse'])],
})
export class AppModule {}

The base is the module's APP_BASE_HREF (or the document's <base>); a root base (/) is left untouched. A host that redirects case variants of a module's path to lowercase can stop doing so once the module installs this strategy.

The strategy also passes the router only the address changes within the module's base and aliases. In a host, Back or Forward to another module changes the address before the module is unmounted; the module's router would otherwise take that address for one of its own and either log NG04002 (no match) or write it back under the module's base (a catch-all or redirect), leaving the page in the wrong module.

Angular CDK overlays

The Angular integration targets Angular/CDK 19.x. In a hosted application, install the complete provider array so global and connected overlays share the module boundary's viewport and coordinate system:

import { provideMfOverlayRoot } from '@edynamix/mf-isolation/angular';

const providers = provideMfOverlayRoot(overlayRoot);

Pass all of providers through the application bootstrap. Calling provideMfOverlayRoot() without an element preserves CDK's normal document-viewport behavior.

Legacy NgModule applications that receive the root through an injection token must install both providers through the token-aware helper:

import { InjectionToken, NgModule } from '@angular/core';
import { provideMfOverlayRootFromToken } from '@edynamix/mf-isolation/angular';

export const MF_OVERLAY_ROOT = new InjectionToken<HTMLElement>('MF_OVERLAY_ROOT');

@NgModule({
  providers: [...provideMfOverlayRootFromToken(MF_OVERLAY_ROOT)],
})
export class AppModule {}

Provide the runtime MF_OVERLAY_ROOT value through the existing platform/bootstrap injector. Do not extract only the first provider from provideMfOverlayRoot(); that installs the container but omits boundary-aware connected positioning.

The Angular entry point requires @angular/core, @angular/common, and @angular/cdk 19.x.

Sharing Angular between modules

Without sharing, every hosted module ships and starts its own copy of Angular. With createMfAngularShared() from @edynamix/mf-isolation/vite, modules on the same versions load Angular, the CDK, Material and RxJS once per page:

import { federation } from '@module-federation/vite';
import { createMfAngularShared } from '@edynamix/mf-isolation/vite';
import packageJson from './package.json' with { type: 'json' };

federation({
  name: 'autopoint',
  filename: 'remoteEntry.js',
  exposes: { './lifecycle': './src/lifecycle.ts' },
  shared: createMfAngularShared(packageJson.dependencies),
});

It shares every script entry point of the module's @angular/* dependencies (read from each installed package's exports), plus rxjs and rxjs/operators, at the module's own version. Entry points that stylesheets also resolve are left out: the @angular/material and @angular/cdk roots answer @use '@angular/material', and Module Federation would answer the stylesheet with a script. @angular/compiler and @angular/platform-browser-dynamic are not shared: only just-in-time compilation needs them, and sharing them would put the compiler into every module's build. The versions must be exact (19.2.25, not ^19.2.25). A module on a different version keeps its own copy instead of mixing two Angular versions on one page. Pass { root } when the Vite config does not run from the module's package directory.

A module that starts first on a page loads its own copy of every shared entry point at start-up, in the batches the federation plugin orders them in, and an entry point reads another one through a stub that is filled only once that one has loaded. An entry point that reads another while it loads (a class extending it, a static field, a call at the top of the module) therefore needs that one to load in an earlier batch, and the plugin orders entry points only by the shared package roots their package depends on. createMfAngularShared() reads each entry point's source and leaves out those that would start with an undefined binding (@angular/material-moment-adapter extends @angular/material/core's DateAdapter, but depends on the unshared @angular/material root; @angular/cdk/listbox provides @angular/forms' NG_VALUE_ACCESSOR, which the CDK does not depend on), together with every shared entry point that imports them, so a module never holds two copies of one entry point. The module bundles its own copy of those, as before sharing. mfAssetManifestPlugin() checks the built output the same way and fails the build if a shared entry point still reads another one before it is sure to have loaded.

A shared Angular means one Angular platform per page. A module bootstrapped with createApplication() reuses that platform already. A module that bootstraps an NgModule replaces platformBrowserDynamic(providers) with createHostedAngularPlatform(providers):

import { createHostedAngularPlatform } from '@edynamix/mf-isolation/angular-platform';

const platform = createHostedAngularPlatform([{ provide: APP_BASE_HREF, useValue: baseHref }]);
const moduleRef = await platform.bootstrapModule(AppModule);
// on unmount
platform.destroy();

Its providers reach only the module's own NgModules, and destroy() destroys only them; the shared platform and the other modules keep running. Calling platformBrowserDynamic(providers) instead would hand the module another module's platform without its providers, and its destroy() would stop every module on the page. The entry point requires @angular/platform-browser-dynamic 19.x.

Host-owned Zone.js

Standalone Angular applications load their own Zone.js runtime as usual:

import 'zone.js';

When the application runs as a hosted lifecycle, the host owns the single Zone.js runtime. Check that runtime before dynamically importing any Angular code:

import { assertHostZoneRuntime } from '@edynamix/mf-isolation/angular-runtime';
import type { IShellContext } from './shell-context';

export const mount = async (host: HTMLElement, context: IShellContext): Promise<void> => {
  assertHostZoneRuntime();

  const lifecycle = await import('./lifecycle-impl');
  return lifecycle.mount(host, context);
};

angular-runtime is framework-free. It verifies that globalThis.Zone is a function; it does not load or modify Zone.js.

Exports

  • @edynamix/mf-isolation
  • @edynamix/mf-isolation/angular
  • @edynamix/mf-isolation/angular-runtime
  • @edynamix/mf-isolation/angular-platform
  • @edynamix/mf-isolation/host-contract
  • @edynamix/mf-isolation/postcss

版本与发布记录

@edynamix/mf-isolation 共发布 43 个版本,首次发布于 2026/08/03;过去 12 个月发布了 43 个版本。

版本发布日期距今
0.0.43latest2026/11/049 天前
0.0.422026/10/3014 天前
0.0.412026/10/3014 天前
0.0.402026/10/3014 天前
0.0.392026/10/3014 天前
0.0.382026/10/3014 天前

关于 @edynamix/mf-isolation 的常见问题

@edynamix/mf-isolation 是什么?

CSS and overlay isolation helpers for eDynamix microfrontends.

如何安装 @edynamix/mf-isolation?

在项目目录运行 npm install @edynamix/mf-isolation,或使用 pnpm add / yarn add / bun add。

@edynamix/mf-isolation 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

@edynamix/mf-isolation 支持 ESM 还是 CommonJS?

以 ES Module 形式发布,推荐使用 import 引入。在 CommonJS 项目中,Node.js 20.19+ / 22.12+ 可以直接 require() 不含顶层 await 的 ES Module,更早的版本需要改用动态 import()。

@edynamix/mf-isolation 需要什么版本的 Node.js?

package.json 的 engines 字段要求 Node.js >=22。版本不符时 npm 默认只给出警告,开启 engine-strict 后会拒绝安装。

@edynamix/mf-isolation 可以商用吗?

@edynamix/mf-isolation 使用 UNLICENSED 许可。作者声明为 UNLICENSED,即不授予公开使用许可(与公共领域的 Unlicense 不同)。使用前需获得作者授权。以上为概括,不构成法律意见,请以许可原文为准。

@edynamix/mf-isolation 的下载量有多少?

根据 npm 官方下载统计,@edynamix/mf-isolation 在 2026/10/13 – 2026/11/11 这 30 天内被下载 2,958 次,日均约 99 次。下载量会把 CI 构建、镜像同步和作为其他包依赖的安装一并计入,适合横向比较热度,不能直接等同于使用人数。

@edynamix/mf-isolation 还在维护吗?

最新版本 0.0.43 发布于 2026/11/04(9 天前);至今共发布 43 个版本,其中过去 12 个月发布了 43 个;首次发布于 2026/08/03。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。