diff --git a/.changeset/safe-observability-plugin.md b/.changeset/safe-observability-plugin.md new file mode 100644 index 00000000000..701b96bb23c --- /dev/null +++ b/.changeset/safe-observability-plugin.md @@ -0,0 +1,8 @@ +--- +"@module-federation/observability-plugin": minor +"@module-federation/error-codes": minor +"@module-federation/runtime-core": minor +"@module-federation/runtime": minor +--- + +Add an opt-in observability plugin, a Node-specific export for file reports, a build-specific export for build summaries and build error reports, remote and shared lifecycle hooks, console trace hints, safe browser/Node report outputs, configurable error stack capture with explicit console raw-stack opt-ins, shared/eager loading evidence, final loading outcome summaries for Module Federation loading reports, deterministic fact reports for runtime and build failures, no-op return handling for observer hooks, detailed remote match/init/expose/factory phase events with phase durations, compact phase summaries, cache/retry/fallback markers, length-limited business component metadata, clipped moduleInfo evidence with preserved deployment locator fields for snapshot-dependent failures, normalized runtime error summaries with error codes, owner hints, retryability, and safe context, dedicated runtime error codes for invalid manifests, missing exposes, and remote container init failures, plus an AI troubleshooting guide for reading and fixing observability reports. diff --git a/apps/runtime-demo/3005-runtime-host/cypress/e2e/app.cy.ts b/apps/runtime-demo/3005-runtime-host/cypress/e2e/app.cy.ts index 4383ea93ec1..c77a8b5f8ba 100644 --- a/apps/runtime-demo/3005-runtime-host/cypress/e2e/app.cy.ts +++ b/apps/runtime-demo/3005-runtime-host/cypress/e2e/app.cy.ts @@ -1,5 +1,45 @@ import { getH1, getH3 } from '../support/app.po'; +const getObservabilityReader = (win: Cypress.AUTWindow) => + (win as any).__FEDERATION__?.__OBSERVABILITY__?.runtime_host; + +type ObservabilityTestReport = { + traceId: string; + status?: string; + errorCode?: string; + requestId?: string; + summary: { + flags: { + cached?: boolean; + }; + phases: { + remoteEntry?: { + cached?: boolean; + }; + }; + }; + shared?: { + name?: string; + provider?: string; + reason?: string; + availableVersions?: string[]; + }; + events: Array<{ + eventName?: string; + metadata?: Record; + }>; +}; + +const ignoreExpectedObservabilityException = (expectedMessages: string[]) => { + cy.on('uncaught:exception', (error) => { + if (expectedMessages.some((message) => error.message.includes(message))) { + return false; + } + + return undefined; + }); +}; + describe('3005-runtime-host/', () => { beforeEach(() => cy.visit('/')); @@ -77,4 +117,598 @@ describe('3005-runtime-host/', () => { }); }); }); + + describe('observability demo fixture', () => { + beforeEach(() => { + cy.visit('/observability'); + }); + + it('should emit build observability for the host config', () => { + cy.readFile('.mf/observability/build-info.json').then((buildInfo) => { + expect(buildInfo.source).to.equal('manifest'); + expect(buildInfo.moduleFederation.name).to.equal('runtime_host'); + expect( + buildInfo.moduleFederation.remotes.some( + (remote: { entry?: string; alias?: string }) => + remote.alias === 'remote1' && + remote.entry === 'http://127.0.0.1:3006/mf-manifest.json', + ), + ).to.equal(true); + expect(buildInfo.moduleFederation.exposes).to.deep.include({ + name: 'Button', + }); + expect( + buildInfo.moduleFederation.shared.some( + (shared: { name: string }) => shared.name === 'react', + ), + ).to.equal(true); + const reactShared = buildInfo.moduleFederation.shared.find( + (shared: { name: string }) => shared.name === 'react', + ); + expect(reactShared).to.include({ + name: 'react', + requiredVersion: '^18.2.0', + singleton: true, + }); + expect(JSON.stringify(buildInfo)).not.to.contain('/Users/bytedance'); + expect(JSON.stringify(buildInfo)).not.to.contain('token='); + }); + }); + + it('should expose a successful remote loading scenario', () => { + cy.get('[data-testid="observability-load-success"]').click(); + cy.get('[data-testid="observability-load-status"]').contains('success'); + cy.get('[data-testid="observability-remote-result"]') + .find('button.test-remote2') + .contains('Button from antd@4.24.15'); + cy.window().then((win) => { + const reader = getObservabilityReader(win); + expect(reader).to.exist; + + const latestReport = reader.getLatestReport(); + expect(latestReport.status).to.equal('success'); + expect(latestReport.summary.runtimeLoaded).to.equal(true); + expect(latestReport.summary.loadCompleted).to.equal(true); + expect(latestReport.summary.componentLoaded).to.equal(false); + expect(latestReport.summary.outcome).to.equal('runtime-loaded'); + expect(latestReport.summary.phases.loadRemote.status).to.equal( + 'complete', + ); + expect(latestReport.diagnosis.status).to.equal('success'); + expect(latestReport.diagnosis.outcome).to.equal('runtime-loaded'); + expect(latestReport.diagnosis.facts.runtimeLoaded).to.equal(true); + expect(latestReport.summary.phases.remoteEntryInit.duration).to.be.a( + 'number', + ); + }); + cy.get('[data-testid="observability-business-loaded"]').click(); + cy.get('[data-testid="observability-report"]').contains( + 'component:business-loaded', + ); + cy.get('[data-testid="observability-report"]') + .should('contain', '"route": "/observability?token=demo-secret#hash"') + .should('contain', 'token=demo-secret') + .should('contain', '#hash'); + cy.window().then((win) => { + const reader = getObservabilityReader(win); + expect(reader).to.exist; + + const latestReport = reader.getLatestReport(); + expect(latestReport.status).to.equal('success'); + expect(latestReport.summary.componentLoaded).to.equal(true); + expect(latestReport.summary.outcome).to.equal('component-loaded'); + expect(latestReport.diagnosis.outcome).to.equal('component-loaded'); + expect(latestReport.diagnosis.facts.componentLoaded).to.equal(true); + expect(reader.getReport(latestReport.traceId).traceId).to.equal( + latestReport.traceId, + ); + }); + }); + + it('should expose a failed remote loading scenario', () => { + cy.window().then((win) => { + cy.spy(win.console, 'error').as('observabilityError'); + }); + cy.get('[data-testid="observability-load-missing-expose"]').click(); + cy.get('[data-testid="observability-load-status"]').contains('error'); + cy.get('[data-testid="observability-report"]').contains( + 'dynamic-remote/__missing_expose__', + ); + cy.get('[data-testid="observability-error-message"]').should( + 'not.contain', + 'token=', + ); + cy.get('@observabilityError').should( + 'have.been.calledWithMatch', + /Observability report generated[\s\S]*traceId: mf-/, + ); + cy.window().then((win) => { + const reader = getObservabilityReader(win); + const latestReport = reader.getLatestReport(); + expect(latestReport.status).to.equal('error'); + expect(latestReport.traceId).to.match(/^mf-/); + expect(latestReport.summary.loadCompleted).to.equal(true); + expect(latestReport.summary.outcome).to.equal('failed'); + expect( + latestReport.diagnosis.actions.some( + (action: { id: string }) => action.id === 'check-expose', + ), + ).to.equal(true); + expect(reader.getReport(latestReport.traceId).failedPhase).to.equal( + 'expose', + ); + }); + }); + + it('should expose a manifest failure scenario', () => { + cy.get('[data-testid="observability-load-broken-manifest"]').click(); + cy.get('[data-testid="observability-load-status"]').contains('error'); + cy.get('[data-testid="observability-report"]').contains( + 'observability-broken-remote/Button', + ); + cy.get('[data-testid="observability-report"]') + .should('contain', 'demo-secret') + .should('contain', 'token=') + .should('contain', '#hash') + .should('contain', 'RUNTIME-003') + .should('contain', '"ownerHint": "host"') + .should('contain', '"check-manifest-url"'); + cy.get('[data-testid="observability-error-message"]') + .contains('/observability-missing/mf-manifest.json') + .should('not.contain', 'demo-secret') + .should('not.contain', 'token=') + .should('not.contain', '#hash'); + cy.window().then((win) => { + const latestReport = getObservabilityReader(win).getLatestReport(); + expect(latestReport.diagnosis.facts.url).to.equal( + 'http://127.0.0.1:3005/observability-missing/mf-manifest.json?token=demo-secret#hash', + ); + }); + }); + + it('should expose a remote URL failure scenario', () => { + cy.get('[data-testid="observability-load-remote-url-error"]').click(); + cy.get('[data-testid="observability-load-status"]').contains('error'); + cy.get('[data-testid="observability-report"]') + .should('contain', 'observability-remote-url/Button') + .should( + 'contain', + 'http://127.0.0.1:3999/observability-remote-url/mf-manifest.json', + ) + .should('contain', 'RUNTIME-003') + .should('contain', '"check-manifest-url"'); + cy.window().then((win) => { + const latestReport = getObservabilityReader(win).getLatestReport(); + expect(latestReport.status).to.equal('error'); + expect(latestReport.failedPhase).to.equal('manifest'); + expect(latestReport.diagnosis.facts.url).to.equal( + 'http://127.0.0.1:3999/observability-remote-url/mf-manifest.json', + ); + }); + }); + + it('should expose a retry recovered remote scenario', () => { + cy.get('[data-testid="observability-load-retry-recovered"]').click(); + cy.get('[data-testid="observability-load-status"]').contains('success'); + cy.get('[data-testid="observability-report"]') + .should('contain', 'observability-retry-recovered/Button') + .should('contain', 'remoteEntry:load-recovered') + .should('contain', '"retried": true') + .should('contain', '"recovered": true'); + cy.window().then((win) => { + const latestReport = getObservabilityReader(win).getLatestReport(); + expect(latestReport.status).to.equal('success'); + expect(latestReport.summary.outcome).to.equal('recovered'); + expect(latestReport.summary.flags.retried).to.equal(true); + expect(latestReport.summary.flags.fallback).to.equal(false); + expect(latestReport.summary.phases.remoteEntry.recovered).to.equal( + true, + ); + expect(latestReport.diagnosis.warnings).to.include( + 'Remote entry loading recovered after retry', + ); + }); + }); + + it('should expose a fallback recovered remote scenario', () => { + cy.get('[data-testid="observability-load-fallback-success"]').click(); + cy.get('[data-testid="observability-load-status"]').contains('success'); + cy.get('[data-testid="observability-report"]') + .should('contain', 'dynamic-remote/__observability_fallback__') + .should('contain', 'remote:load-recovered') + .should('contain', '"fallback": true') + .should('contain', '"outcome": "recovered"'); + cy.window().then((win) => { + const latestReport = getObservabilityReader(win).getLatestReport(); + expect(latestReport.status).to.equal('success'); + expect(latestReport.failedPhase).to.equal('expose'); + expect(latestReport.summary.outcome).to.equal('recovered'); + expect(latestReport.summary.flags.fallback).to.equal(true); + expect(latestReport.summary.flags.recovered).to.equal(true); + expect(latestReport.summary.error.failedPhase).to.equal('expose'); + expect(latestReport.diagnosis.warnings).to.include( + 'Remote loading completed through fallback recovery', + ); + expect( + latestReport.diagnosis.actions.some( + (action: { id: string }) => action.id === 'check-expose', + ), + ).to.equal(true); + }); + }); + + it('should expose a manifest missing fields scenario', () => { + ignoreExpectedObservabilityException(['RUNTIME-013']); + cy.get( + '[data-testid="observability-load-missing-fields-manifest"]', + ).click(); + cy.get('[data-testid="observability-load-status"]').contains('error'); + cy.get('[data-testid="observability-report"]') + .should('contain', 'observability-missing-fields/Button') + .should('contain', 'RUNTIME-013') + .should('contain', 'Missing required fields') + .should('contain', 'metaData') + .should('contain', 'exposes') + .should('contain', 'shared') + .should('contain', '"check-manifest-url"'); + cy.window().then((win) => { + const latestReport = getObservabilityReader(win).getLatestReport(); + expect(latestReport.status).to.equal('error'); + expect(latestReport.failedPhase).to.equal('manifest'); + expect(latestReport.diagnosis.facts.url).to.equal( + 'http://127.0.0.1:3005/observability-fixtures/missing-fields/mf-manifest.json', + ); + }); + }); + + it('should expose a remoteEntry globalName mismatch scenario', () => { + ignoreExpectedObservabilityException(['RUNTIME-001']); + cy.get('[data-testid="observability-load-wrong-global"]').click(); + cy.get('[data-testid="observability-load-status"]').contains('error'); + cy.get('[data-testid="observability-report"]') + .should('contain', 'observability-wrong-global/Button') + .should('contain', 'RUNTIME-001') + .should('contain', 'observability_wrong_global_expected') + .should('contain', '"check-remote-global"'); + cy.window().then((win) => { + const latestReport = getObservabilityReader(win).getLatestReport(); + expect(latestReport.status).to.equal('error'); + expect(latestReport.failedPhase).to.equal('remoteEntry'); + expect(latestReport.errorCode).to.equal('RUNTIME-001'); + expect( + latestReport.diagnosis.actions.some( + (action: { id: string }) => action.id === 'check-remote-global', + ), + ).to.equal(true); + }); + }); + + it('should expose a remoteEntry execution error scenario', () => { + ignoreExpectedObservabilityException([ + 'observability remoteEntry execution failed', + 'ScriptExecutionError', + ]); + cy.get( + '[data-testid="observability-load-remote-entry-execution-error"]', + ).click(); + cy.get('[data-testid="observability-load-status"]').contains('error'); + cy.get('[data-testid="observability-report"]') + .should('contain', 'observability-execution-error/Button') + .should('contain', 'RUNTIME-008') + .should('contain', 'ScriptExecutionError') + .should('contain', '"check-remote-entry"'); + cy.window().then((win) => { + const latestReport = getObservabilityReader(win).getLatestReport(); + expect(latestReport.status).to.equal('error'); + expect(latestReport.failedPhase).to.equal('remoteEntry'); + expect(latestReport.errorCode).to.equal('RUNTIME-008'); + expect(latestReport.diagnosis.facts.resourceErrorType).to.equal( + 'script-execution', + ); + }); + }); + + it('should expose a snapshot match observability scenario', () => { + ignoreExpectedObservabilityException(['RUNTIME-007']); + cy.get('[data-testid="observability-load-snapshot-match-error"]').click(); + cy.get('[data-testid="observability-load-status"]').contains('error'); + cy.get('[data-testid="observability-report"]') + .should('contain', 'observability-snapshot-miss/Button') + .should('contain', 'RUNTIME-007') + .should('contain', 'remote-snapshot') + .should('contain', 'observability-unrelated-snapshot') + .should('contain', '"check-module-info"'); + cy.window().then((win) => { + const latestReport = getObservabilityReader(win).getLatestReport(); + expect(latestReport.status).to.equal('error'); + expect(latestReport.errorCode).to.equal('RUNTIME-007'); + expect(latestReport.ownerHint).to.equal('host'); + expect(latestReport.moduleInfo.reason).to.equal('remote-snapshot'); + expect(latestReport.moduleInfo.matchedCount).to.equal(0); + expect(latestReport.moduleInfo.availableNames).to.include( + 'remote:observability-unrelated-snapshot', + ); + expect( + latestReport.diagnosis.actions.some( + (action: { id: string }) => action.id === 'check-module-info', + ), + ).to.equal(true); + }); + }); + + it('should expose a shared miss observability scenario', () => { + cy.window().then((win) => { + cy.spy(win.console, 'error').as('observabilityError'); + }); + cy.get('[data-testid="observability-shared-miss"]').click(); + cy.get('[data-testid="observability-load-status"]').contains('error'); + cy.get('[data-testid="observability-report"]') + .should('contain', 'observability-missing-shared') + .should('contain', 'missing-provider'); + cy.get('@observabilityError').should( + 'have.been.calledWithMatch', + /Observability report generated[\s\S]*traceId: mf-/, + ); + cy.window().then((win) => { + const latestReport = getObservabilityReader(win).getLatestReport(); + expect(latestReport.status).to.equal('error'); + expect(latestReport.shared.reason).to.equal('missing-provider'); + expect( + latestReport.diagnosis.actions.some( + (action: { id: string }) => action.id === 'check-shared-provider', + ), + ).to.equal(true); + }); + }); + + it('should expose a shared version mismatch observability scenario', () => { + cy.get('[data-testid="observability-shared-version-mismatch"]').click(); + cy.get('[data-testid="observability-load-status"]').contains('error'); + cy.get('[data-testid="observability-report"]') + .should('contain', '"name": "react"') + .should('contain', '^99.0.0') + .should('contain', 'version-mismatch'); + cy.window().then((win) => { + const latestReport = getObservabilityReader(win).getLatestReport(); + expect(latestReport.status).to.equal('error'); + expect(latestReport.shared.reason).to.equal('version-mismatch'); + expect(latestReport.shared.availableVersions).to.include('18.3.1'); + expect( + latestReport.diagnosis.actions.some( + (action: { id: string }) => action.id === 'check-shared-version', + ), + ).to.equal(true); + }); + }); + + it('should expose a shared unexpected provider observability scenario', () => { + cy.get( + '[data-testid="observability-shared-unexpected-provider"]', + ).click(); + cy.get('[data-testid="observability-load-status"]').contains('success'); + cy.get('[data-testid="observability-report"]') + .should('contain', 'observability-provider-choice') + .should('contain', '"provider": "runtime_remote2"') + .should('contain', '"selectedVersion": "2.0.0"') + .should('contain', 'shared:resolved'); + cy.window().then((win) => { + const latestReport = getObservabilityReader(win).getLatestReport(); + expect(latestReport.status).to.equal('success'); + expect(latestReport.shared.name).to.equal( + 'observability-provider-choice', + ); + expect(latestReport.shared.provider).to.equal('runtime_remote2'); + expect(latestReport.shared.selectedVersion).to.equal('2.0.0'); + expect(latestReport.summary.shared.provider).to.equal( + 'runtime_remote2', + ); + expect(latestReport.diagnosis.facts.provider).to.equal( + 'runtime_remote2', + ); + }); + }); + + it('should expose a multi-consumer loading chain scenario', () => { + cy.get('[data-testid="observability-multi-consumer-chain"]').click(); + cy.get('[data-testid="observability-load-status"]').contains('success'); + cy.get('[data-testid="observability-multi-consumer-results"]') + .should('contain', 'checkout-page loaded') + .should('contain', 'analytics-page loaded') + .should('contain', 'checkout-page-repeat loaded') + .should( + 'contain', + 'observability-checkout-theme from observability_consumer_checkout@1.4.0', + ) + .should( + 'contain', + 'observability-analytics-sdk from observability_consumer_analytics@1.2.0', + ) + .should('contain', 'cached=true'); + cy.get('[data-testid="observability-report"]') + .should('contain', 'multi-consumer-loading-chain') + .should('contain', 'dynamic-remote/ProfileCard') + .should('contain', 'dynamic-remote/AnalyticsPanel') + .should('contain', '"consumer": "checkout-page"') + .should('contain', '"consumer": "analytics-page"') + .should( + 'contain', + '"sharedProvider": "observability_consumer_checkout"', + ) + .should( + 'contain', + '"sharedProvider": "observability_consumer_analytics"', + ) + .should('contain', '"cached": true'); + cy.window().then((win) => { + const reader = getObservabilityReader(win); + const reports = reader.getReports(); + const profileReports = reader.findReports({ + remote: 'runtime_remote2', + expose: 'ProfileCard', + }); + const analyticsReports = reader.findReports({ + remote: 'runtime_remote2', + expose: 'AnalyticsPanel', + }); + const checkoutSharedReports = reader.findReports({ + shared: 'observability-checkout-theme', + }); + const analyticsSharedReports = reader.findReports({ + shared: 'observability-analytics-sdk', + }); + const cachedRemoteReport = ( + profileReports as ObservabilityTestReport[] + ).find((report) => report.summary.flags.cached === true); + const checkoutComponentReport = ( + profileReports as ObservabilityTestReport[] + ).find((report) => + report.events.some( + (event) => + event.eventName === 'component:business-loaded' && + event.metadata?.consumer === 'checkout-page', + ), + ); + + expect(reports.length).to.be.greaterThan(4); + expect(profileReports.length).to.be.greaterThan(1); + expect(analyticsReports.length).to.equal(1); + expect( + (checkoutSharedReports[0] as ObservabilityTestReport).shared + ?.provider, + ).to.equal('observability_consumer_checkout'); + expect( + (analyticsSharedReports[0] as ObservabilityTestReport).shared + ?.provider, + ).to.equal('observability_consumer_analytics'); + expect(cachedRemoteReport?.summary.flags.cached).to.equal(true); + expect( + checkoutComponentReport?.events.some( + (event) => + event.metadata?.sharedTraceId === + (checkoutSharedReports[0] as ObservabilityTestReport).traceId, + ), + ).to.equal(true); + }); + }); + + it('should expose an eager config observability scenario', () => { + cy.get('[data-testid="observability-eager-config-error"]').click(); + cy.get('[data-testid="observability-load-status"]').contains('error'); + cy.get('[data-testid="observability-report"]') + .should('contain', 'observability-async-shared') + .should('contain', 'sync-async-boundary') + .should('contain', 'RUNTIME-005') + .should('contain', '"ownerHint": "shared"'); + cy.window().then((win) => { + const latestReport = getObservabilityReader(win).getLatestReport(); + expect(latestReport.status).to.equal('error'); + expect(latestReport.shared.reason).to.equal('sync-async-boundary'); + expect( + latestReport.diagnosis.actions.some( + (action: { id: string }) => action.id === 'check-eager-config', + ), + ).to.equal(true); + }); + }); + + it('should expose a runtime eager config observability scenario', () => { + cy.get( + '[data-testid="observability-runtime-eager-config-error"]', + ).click(); + cy.get('[data-testid="observability-load-status"]').contains('error'); + cy.get('[data-testid="observability-report"]') + .should('contain', 'observability-runtime-async-shared') + .should('contain', 'sync-async-boundary') + .should('contain', 'RUNTIME-006') + .should('contain', '"ownerHint": "shared"'); + cy.window().then((win) => { + const latestReport = getObservabilityReader(win).getLatestReport(); + expect(latestReport.status).to.equal('error'); + expect(latestReport.errorCode).to.equal('RUNTIME-006'); + expect(latestReport.shared.reason).to.equal('sync-async-boundary'); + expect( + latestReport.diagnosis.actions.some( + (action: { id: string }) => action.id === 'check-eager-config', + ), + ).to.equal(true); + }); + }); + }); + + describe('observability showcase fixture', () => { + beforeEach(() => { + cy.visit('/observability-showcase'); + }); + + it('should present a product page route flow for AI observability', () => { + cy.contains('Suggested prompt').should('not.exist'); + cy.contains('AI-ready evidence').should('not.exist'); + cy.contains('Show latest observability report').should('not.exist'); + cy.get('[data-testid="observability-showcase-status"]').contains( + 'success', + ); + cy.get('[data-testid="remote2-profile-card"]').should( + 'contain', + 'ProfileCard', + ); + cy.get('[data-testid="observability-showcase-load"]').click(); + cy.location('pathname').should( + 'equal', + '/observability-showcase/analytics', + ); + cy.get('[data-testid="observability-showcase-status"]').contains( + 'degraded', + ); + cy.get('[data-testid="observability-showcase-fallback"]').should( + 'contain', + 'Limited analytics view', + ); + cy.get('[data-testid="observability-showcase-message"]').should( + 'contain', + 'Some analytics details are temporarily unavailable', + ); + cy.get('[data-testid="observability-showcase-shared"]').should( + 'contain', + 'Detailed analytics are temporarily limited', + ); + + cy.window().then((win) => { + const reports = getObservabilityReader(win).getReports(); + const customerSdkReport = reports.find( + (report: ObservabilityTestReport) => + report.shared?.name === 'observability-customer-sdk', + ); + + expect( + reports.some( + (report: ObservabilityTestReport) => + report.requestId === 'dynamic-remote/ProfileCard', + ), + ).to.equal(true); + expect( + reports.some( + (report: ObservabilityTestReport) => + report.requestId === 'dynamic-remote/AnalyticsPanel', + ), + ).to.equal(true); + expect( + reports.some( + (report: ObservabilityTestReport) => + report.shared?.name === 'react', + ), + ).to.equal(true); + expect( + reports.some( + (report: ObservabilityTestReport) => + report.shared?.name === 'observability-customer-sdk', + ), + ).to.equal(true); + expect(customerSdkReport?.status).to.equal('error'); + expect(customerSdkReport?.shared?.reason).to.equal('version-mismatch'); + expect(customerSdkReport?.shared?.availableVersions).to.include( + '2.1.0', + ); + }); + }); + }); }); diff --git a/apps/runtime-demo/3005-runtime-host/package.json b/apps/runtime-demo/3005-runtime-host/package.json index c4e4f70b641..16b5a8ba8b9 100644 --- a/apps/runtime-demo/3005-runtime-host/package.json +++ b/apps/runtime-demo/3005-runtime-host/package.json @@ -4,6 +4,7 @@ "version": "0.0.0", "devDependencies": { "@module-federation/core": "workspace:*", + "@module-federation/observability-plugin": "workspace:*", "@module-federation/runtime": "workspace:*", "@module-federation/typescript": "workspace:*", "@module-federation/enhanced": "workspace:*", @@ -26,7 +27,7 @@ "serve": "NODE_OPTIONS=--max_old_space_size=4096 pnpm exec webpack-cli serve --config webpack.config.js --mode production --port 3005 --no-hot", "serve:development": "NODE_OPTIONS=--max_old_space_size=4096 pnpm exec webpack-cli serve --config webpack.config.js --mode development --port 3005", "serve:production": "NODE_OPTIONS=--max_old_space_size=4096 pnpm exec webpack-cli serve --config webpack.config.js --mode production --port 3005 --no-hot", - "lint": "ESLINT_USE_FLAT_CONFIG=false pnpm exec eslint --no-error-on-unmatched-pattern --ignore-pattern node_modules **/*.{ts,tsx,js,jsx}", + "lint": "ESLINT_USE_FLAT_CONFIG=false pnpm exec eslint --no-error-on-unmatched-pattern --ignore-pattern node_modules src/**/*.{ts,tsx,js,jsx} cypress/**/*.{ts,tsx,js,jsx} cypress.config.ts webpack.config.js remotes.d.ts @mf-types/**/*.ts", "serve-static": "pnpm exec serve dist -l 3005 --cors", "e2e": "pnpm exec cypress run --project . --e2e --config baseUrl=http://127.0.0.1:3005 --browser chrome", "e2e:development": "pnpm exec cypress open --project . --e2e --config baseUrl=http://127.0.0.1:3005 --browser electron", diff --git a/apps/runtime-demo/3005-runtime-host/src/App.tsx b/apps/runtime-demo/3005-runtime-host/src/App.tsx index 2cf2933f74e..5b3bcd1bc0e 100644 --- a/apps/runtime-demo/3005-runtime-host/src/App.tsx +++ b/apps/runtime-demo/3005-runtime-host/src/App.tsx @@ -1,29 +1,62 @@ -import React, { lazy } from 'react'; -import { loadRemote } from '@module-federation/runtime'; -import { Link, Routes, Route, BrowserRouter } from 'react-router-dom'; +import React from 'react'; +import { + Link, + Routes, + Route, + BrowserRouter, + useLocation, +} from 'react-router-dom'; +import ObservabilityDemo from './ObservabilityDemo'; +import ObservabilityShowcase from './ObservabilityShowcase'; import Root from './Root'; import Remote1 from './Remote1'; import Remote2 from './Remote2'; +const AppRoutes = () => { + const location = useLocation(); + const isShowcase = location.pathname.startsWith('/observability-showcase'); + + return ( + <> + {!isShowcase ? ( + <> +

Runtime Demo

+ + + ) : null} + + } /> + } /> + } /> + } /> + } + /> + + + ); +}; + const App = () => ( -

Runtime Demo

- - - } /> - } /> - } /> - +
); diff --git a/apps/runtime-demo/3005-runtime-host/src/ObservabilityDemo.tsx b/apps/runtime-demo/3005-runtime-host/src/ObservabilityDemo.tsx new file mode 100644 index 00000000000..4e4314221b1 --- /dev/null +++ b/apps/runtime-demo/3005-runtime-host/src/ObservabilityDemo.tsx @@ -0,0 +1,1065 @@ +import React, { useCallback, useState } from 'react'; +import type { ModuleFederationRuntimePlugin } from '@module-federation/runtime'; +import { + createInstance, + getInstance, + loadRemote, + loadShare, + loadShareSync, + registerPlugins, + registerRemotes, +} from '@module-federation/runtime'; +import { observability } from './observability'; + +type LoadStatus = 'idle' | 'loading' | 'success' | 'error'; + +type RemoteComponent = React.ComponentType>; + +const successRequest = 'dynamic-remote/ButtonOldAnt'; +const missingExposeRequest = 'dynamic-remote/__missing_expose__'; +const brokenManifestEntry = + 'http://127.0.0.1:3005/observability-missing/mf-manifest.json?token=demo-secret#hash'; +const brokenManifestRequest = 'observability-broken-remote/Button'; +const remoteUrlErrorEntry = + 'http://127.0.0.1:3999/observability-remote-url/mf-manifest.json'; +const remoteUrlErrorRequest = 'observability-remote-url/Button'; +const retryRecoveryRemoteName = 'observability_retry_recovered_remote'; +const retryRecoveryManifestEntry = + 'http://127.0.0.1:3005/observability-fixtures/retry-recovered/mf-manifest.json'; +const retryRecoveryRequest = 'observability-retry-recovered/Button'; +const fallbackRequest = 'dynamic-remote/__observability_fallback__'; +const missingFieldsManifestEntry = + 'http://127.0.0.1:3005/observability-fixtures/missing-fields/mf-manifest.json'; +const missingFieldsManifestRequest = 'observability-missing-fields/Button'; +const wrongGlobalManifestEntry = + 'http://127.0.0.1:3005/observability-fixtures/wrong-global/mf-manifest.json'; +const wrongGlobalRequest = 'observability-wrong-global/Button'; +const executionErrorManifestEntry = + 'http://127.0.0.1:3005/observability-fixtures/execution-error/mf-manifest.json'; +const executionErrorRequest = 'observability-execution-error/Button'; +const multiProducerRemoteName = 'runtime_remote2'; +const multiProducerAlias = 'dynamic-remote'; +const multiProducerManifestEntry = 'http://127.0.0.1:3007/mf-manifest.json'; +const snapshotMissRequest = 'observability-snapshot-miss/Button'; +const unexpectedProviderSharedName = 'observability-provider-choice'; +const unexpectedProviderSharedScope = 'observability-provider-scope'; + +type RuntimeRemote = Parameters[0][number]; +type RuntimeShareScope = Parameters< + NonNullable>['initShareScopeMap'] +>[1]; +type SharedProviderValue = { + provider: string; + version: string; +}; + +interface MultiConsumerScenario { + consumer: string; + request: string; + expose: string; + componentName: string; + sharedName: string; + sharedScope: string; + requiredVersion: string; + hostVersion: string; + expectedProvider: string; +} + +interface MultiConsumerResult { + consumer: string; + request: string; + expose: string; + sharedName: string; + sharedProvider: string; + sharedVersion: string; + sharedTraceId: string; + remoteTraceId: string; + remoteEntryCached: boolean; + manifestCached: boolean; + summaryCached: boolean; +} + +interface RegisteredRemoteFailureScenario { + remote: RuntimeRemote; + request: string; + errorPrefix?: string; +} + +const multiConsumerScenarios: MultiConsumerScenario[] = [ + { + consumer: 'checkout-page', + request: `${multiProducerAlias}/ProfileCard`, + expose: './ProfileCard', + componentName: 'ProfileCard', + sharedName: 'observability-checkout-theme', + sharedScope: 'observability-checkout-scope', + requiredVersion: '^1.0.0', + hostVersion: '1.4.0', + expectedProvider: 'observability_consumer_checkout', + }, + { + consumer: 'analytics-page', + request: `${multiProducerAlias}/AnalyticsPanel`, + expose: './AnalyticsPanel', + componentName: 'AnalyticsPanel', + sharedName: 'observability-analytics-sdk', + sharedScope: 'observability-analytics-scope', + requiredVersion: '^1.0.0', + hostVersion: '1.2.0', + expectedProvider: 'observability_consumer_analytics', + }, + { + consumer: 'checkout-page-repeat', + request: `${multiProducerAlias}/ProfileCard`, + expose: './ProfileCard', + componentName: 'ProfileCard', + sharedName: 'observability-checkout-theme', + sharedScope: 'observability-checkout-scope', + requiredVersion: '^1.0.0', + hostVersion: '1.4.0', + expectedProvider: 'observability_consumer_checkout', + }, +]; + +function sanitizeErrorMessage(error: unknown): string { + const message = error instanceof Error ? error.message : String(error); + + return message + .replace(/https?:\/\/[^\s'"<>]+/g, (url) => { + try { + const parsedUrl = new URL(url); + return `${parsedUrl.origin}${parsedUrl.pathname}`; + } catch { + return '[redacted-url]'; + } + }) + .replace( + /\b(token|authorization|cookie|secret|password)=([^&\s]+)/gi, + '$1=[redacted]', + ); +} + +function resolveRemoteComponent(remoteModule: unknown): RemoteComponent | null { + if (typeof remoteModule === 'function') { + return remoteModule as RemoteComponent; + } + + if (remoteModule && typeof remoteModule === 'object') { + const candidate = (remoteModule as { default?: unknown }).default; + + if (typeof candidate === 'function') { + return candidate as RemoteComponent; + } + } + + return null; +} + +function createUnexpectedProviderShareScope(): RuntimeShareScope { + return { + [unexpectedProviderSharedName]: { + '1.0.0': { + version: '1.0.0', + get: () => () => ({ + provider: 'runtime_host', + version: '1.0.0', + }), + lib: () => ({ + provider: 'runtime_host', + version: '1.0.0', + }), + shareConfig: { + requiredVersion: false, + singleton: false, + eager: false, + strictVersion: false, + }, + scope: [unexpectedProviderSharedScope], + useIn: ['runtime_host'], + from: 'runtime_host', + deps: [], + strategy: 'version-first', + }, + '2.0.0': { + version: '2.0.0', + get: () => () => ({ + provider: 'runtime_remote2', + version: '2.0.0', + }), + lib: () => ({ + provider: 'runtime_remote2', + version: '2.0.0', + }), + shareConfig: { + requiredVersion: false, + singleton: false, + eager: false, + strictVersion: false, + }, + scope: [unexpectedProviderSharedScope], + useIn: ['runtime_remote2'], + from: 'runtime_remote2', + deps: [], + strategy: 'version-first', + }, + }, + }; +} + +function ObservabilityFallbackRemote() { + return null; +} + +const observabilityRetryRecoveryPlugin: ModuleFederationRuntimePlugin = { + name: 'observability-retry-recovery-plugin', + async loadEntryError({ + getRemoteEntry, + origin, + remoteInfo, + remoteEntryExports, + globalLoading, + uniqueKey, + }) { + if (remoteInfo.name !== retryRecoveryRemoteName) { + return undefined; + } + + delete globalLoading[uniqueKey]; + + return getRemoteEntry({ + origin, + remoteInfo, + remoteEntryExports, + getEntryUrl: (url: string) => { + const separator = url.includes('?') ? '&' : '?'; + return `${url}${separator}retryCount=1`; + }, + }); + }, +}; + +const observabilityFallbackPlugin: ModuleFederationRuntimePlugin = { + name: 'observability-fallback-plugin', + async errorLoadRemote({ id, lifecycle }) { + if (id !== fallbackRequest || lifecycle !== 'onLoad') { + return undefined; + } + + return { + default: ObservabilityFallbackRemote, + }; + }, +}; + +export default function ObservabilityDemo() { + const [status, setStatus] = useState('idle'); + const [errorMessage, setErrorMessage] = useState(''); + const [remoteComponent, setRemoteComponent] = + useState(null); + const [multiConsumerResults, setMultiConsumerResults] = useState< + MultiConsumerResult[] + >([]); + const [reportText, setReportText] = useState('null'); + + const refreshReport = useCallback(() => { + setReportText( + JSON.stringify(observability.getLatestReport() ?? null, null, 2), + ); + }, []); + + const loadSuccessRemote = useCallback(async () => { + setStatus('loading'); + setErrorMessage(''); + setRemoteComponent(null); + + try { + const remoteModule = await loadRemote(successRequest); + const component = resolveRemoteComponent(remoteModule); + + if (!component) { + throw new Error(`Remote module ${successRequest} has no component`); + } + + setRemoteComponent(() => component); + setStatus('success'); + } catch (error) { + const message = sanitizeErrorMessage(error); + setStatus('error'); + setErrorMessage(message); + } finally { + refreshReport(); + } + }, [refreshReport]); + + const loadMissingExpose = useCallback(async () => { + setStatus('loading'); + setErrorMessage(''); + setRemoteComponent(null); + + try { + const remoteModule = await loadRemote(missingExposeRequest); + + if (!remoteModule) { + throw new Error(`Remote module ${missingExposeRequest} returned empty`); + } + + throw new Error( + `Remote module ${missingExposeRequest} unexpectedly loaded`, + ); + } catch (error) { + const message = sanitizeErrorMessage(error); + setStatus('error'); + setErrorMessage(message); + } finally { + refreshReport(); + } + }, [refreshReport]); + + const loadRegisteredRemoteFailure = useCallback( + async (scenario: RegisteredRemoteFailureScenario) => { + setStatus('loading'); + setErrorMessage(''); + setRemoteComponent(null); + + registerRemotes([scenario.remote], { force: true }); + + try { + await loadRemote(scenario.request); + throw new Error( + `Remote module ${scenario.request} unexpectedly loaded`, + ); + } catch (error) { + const message = sanitizeErrorMessage( + `${scenario.errorPrefix || ''} ${error}`, + ); + setStatus('error'); + setErrorMessage(message); + } finally { + refreshReport(); + } + }, + [refreshReport], + ); + + const loadBrokenManifest = useCallback(async () => { + await loadRegisteredRemoteFailure({ + remote: { + name: 'observability_broken_remote', + alias: 'observability-broken-remote', + entry: brokenManifestEntry, + }, + request: brokenManifestRequest, + errorPrefix: brokenManifestEntry, + }); + }, [loadRegisteredRemoteFailure]); + + const loadRemoteUrlError = useCallback(async () => { + await loadRegisteredRemoteFailure({ + remote: { + name: 'observability_remote_url_remote', + alias: 'observability-remote-url', + entry: remoteUrlErrorEntry, + }, + request: remoteUrlErrorRequest, + errorPrefix: remoteUrlErrorEntry, + }); + }, [loadRegisteredRemoteFailure]); + + const loadRetryRecoveredRemote = useCallback(async () => { + setStatus('loading'); + setErrorMessage(''); + setRemoteComponent(null); + + registerPlugins([observabilityRetryRecoveryPlugin]); + registerRemotes( + [ + { + name: retryRecoveryRemoteName, + alias: 'observability-retry-recovered', + entry: retryRecoveryManifestEntry, + }, + ], + { force: true }, + ); + + try { + const remoteModule = await loadRemote(retryRecoveryRequest); + const component = resolveRemoteComponent(remoteModule); + + if (!component) { + throw new Error( + `Remote module ${retryRecoveryRequest} has no component`, + ); + } + + setRemoteComponent(() => component); + setStatus('success'); + } catch (error) { + const message = sanitizeErrorMessage(error); + setStatus('error'); + setErrorMessage(message); + } finally { + refreshReport(); + } + }, [refreshReport]); + + const loadFallbackRemote = useCallback(async () => { + setStatus('loading'); + setErrorMessage(''); + setRemoteComponent(null); + + registerPlugins([observabilityFallbackPlugin]); + + try { + const remoteModule = await loadRemote(fallbackRequest); + const component = resolveRemoteComponent(remoteModule); + + if (!component) { + throw new Error(`Remote module ${fallbackRequest} has no component`); + } + + setRemoteComponent(() => component); + setStatus('success'); + } catch (error) { + const message = sanitizeErrorMessage(error); + setStatus('error'); + setErrorMessage(message); + } finally { + refreshReport(); + } + }, [refreshReport]); + + const loadMissingFieldsManifest = useCallback(async () => { + await loadRegisteredRemoteFailure({ + remote: { + name: 'observability_missing_fields_remote', + alias: 'observability-missing-fields', + entry: missingFieldsManifestEntry, + }, + request: missingFieldsManifestRequest, + errorPrefix: missingFieldsManifestEntry, + }); + }, [loadRegisteredRemoteFailure]); + + const loadWrongGlobalName = useCallback(async () => { + await loadRegisteredRemoteFailure({ + remote: { + name: 'observability_wrong_global_remote', + alias: 'observability-wrong-global', + entry: wrongGlobalManifestEntry, + }, + request: wrongGlobalRequest, + errorPrefix: wrongGlobalManifestEntry, + }); + }, [loadRegisteredRemoteFailure]); + + const loadRemoteEntryExecutionError = useCallback(async () => { + await loadRegisteredRemoteFailure({ + remote: { + name: 'observability_execution_error_remote', + alias: 'observability-execution-error', + entry: executionErrorManifestEntry, + }, + request: executionErrorRequest, + errorPrefix: executionErrorManifestEntry, + }); + }, [loadRegisteredRemoteFailure]); + + const loadSnapshotMatchError = useCallback(async () => { + const federation = ( + globalThis as { + __FEDERATION__?: { + moduleInfo?: Record; + }; + } + ).__FEDERATION__; + + if (federation) { + federation.moduleInfo = { + ...federation.moduleInfo, + 'remote:observability-unrelated-snapshot': { + publicPath: + 'http://127.0.0.1:3005/observability-fixtures/unrelated-snapshot/', + getPublicPath: + 'function getPublicPath(){return "http://127.0.0.1:3005/observability-fixtures/unrelated-snapshot/";}', + remoteEntry: + 'http://127.0.0.1:3005/observability-fixtures/unrelated-snapshot/remoteEntry.js', + globalName: 'observability_unrelated_snapshot', + modules: [{ moduleName: 'Button' }], + shared: [{ sharedName: 'react' }], + }, + }; + } + + await loadRegisteredRemoteFailure({ + remote: { + name: 'observability_snapshot_miss_remote', + alias: 'observability-snapshot-miss', + version: '1.0.0', + }, + request: snapshotMissRequest, + errorPrefix: 'observability-snapshot-miss@1.0.0', + }); + }, [loadRegisteredRemoteFailure]); + + const loadSharedMiss = useCallback(async () => { + setStatus('loading'); + setErrorMessage(''); + setRemoteComponent(null); + + try { + const result = await loadShare('observability-missing-shared', { + customShareInfo: { + version: '1.0.0', + scope: ['observability-missing-scope'], + shareConfig: { + requiredVersion: '^1.0.0', + singleton: false, + }, + }, + }); + + if (result === false) { + throw new Error( + 'Shared miss: observability-missing-shared was not provided by host', + ); + } + + throw new Error('Shared miss scenario unexpectedly loaded'); + } catch (error) { + const message = sanitizeErrorMessage(error); + setStatus('error'); + setErrorMessage(message); + } finally { + refreshReport(); + } + }, [refreshReport]); + + const loadSharedVersionMismatch = useCallback(async () => { + setStatus('loading'); + setErrorMessage(''); + setRemoteComponent(null); + + try { + const result = await loadShare('react', { + customShareInfo: { + shareConfig: { + requiredVersion: '^99.0.0', + singleton: false, + }, + }, + }); + + if (result === false) { + throw new Error('Shared version mismatch: react needs ^99.0.0'); + } + + throw new Error('Shared version mismatch scenario unexpectedly loaded'); + } catch (error) { + const message = sanitizeErrorMessage(error); + setStatus('error'); + setErrorMessage(message); + } finally { + refreshReport(); + } + }, [refreshReport]); + + const loadSharedUnexpectedProvider = useCallback(async () => { + setStatus('loading'); + setErrorMessage(''); + setRemoteComponent(null); + + try { + const instance = getInstance(); + + if (!instance) { + throw new Error('Runtime instance is not initialized'); + } + + instance.initShareScopeMap( + unexpectedProviderSharedScope, + createUnexpectedProviderShareScope(), + ); + + const result = await loadShare( + unexpectedProviderSharedName, + { + customShareInfo: { + scope: [unexpectedProviderSharedScope], + shareConfig: { + requiredVersion: '^2.0.0', + singleton: false, + eager: false, + strictVersion: false, + }, + }, + }, + ); + + if (result === false) { + throw new Error( + 'Shared provider choice: observability-provider-choice was not resolved', + ); + } + + const sharedValue = result(); + + if (!sharedValue) { + throw new Error( + 'Shared provider choice: observability-provider-choice returned empty', + ); + } + + setStatus('success'); + } catch (error) { + const message = sanitizeErrorMessage(error); + setStatus('error'); + setErrorMessage(message); + } finally { + refreshReport(); + } + }, [refreshReport]); + + const runMultiConsumerScenario = useCallback( + async ( + runtimeInstance: ReturnType, + scenario: MultiConsumerScenario, + ): Promise => { + const sharedFactory = + await runtimeInstance.loadShare( + scenario.sharedName, + { + customShareInfo: { + scope: [scenario.sharedScope], + shareConfig: { + requiredVersion: scenario.requiredVersion, + singleton: false, + eager: false, + strictVersion: false, + }, + }, + }, + ); + + if (sharedFactory === false) { + throw new Error( + `${scenario.consumer} could not resolve ${scenario.sharedName}`, + ); + } + + const sharedValue = sharedFactory(); + const sharedReport = observability.getLatestReport(); + const sharedTraceId = sharedReport?.traceId || ''; + + if (!sharedValue) { + throw new Error( + `${scenario.consumer} resolved an empty ${scenario.sharedName}`, + ); + } + + const remoteModule = await runtimeInstance.loadRemote(scenario.request); + const component = resolveRemoteComponent(remoteModule); + + if (!component) { + throw new Error(`Remote module ${scenario.request} has no component`); + } + + const remoteReportBeforeComponent = observability.getLatestReport(); + const remoteTraceId = remoteReportBeforeComponent?.traceId || ''; + + observability.markComponentLoaded({ + traceId: remoteTraceId, + requestId: scenario.request, + componentName: scenario.componentName, + metadata: { + consumer: scenario.consumer, + producer: multiProducerRemoteName, + expose: scenario.expose, + sharedName: scenario.sharedName, + sharedProvider: sharedValue.provider, + sharedVersion: sharedValue.version, + sharedTraceId, + }, + }); + + const remoteReport = observability.getReport(remoteTraceId); + + return { + consumer: scenario.consumer, + request: scenario.request, + expose: scenario.expose, + sharedName: scenario.sharedName, + sharedProvider: sharedValue.provider, + sharedVersion: sharedValue.version, + sharedTraceId, + remoteTraceId, + remoteEntryCached: + remoteReport?.summary.phases.remoteEntry?.cached === true, + manifestCached: remoteReport?.summary.phases.manifest?.cached === true, + summaryCached: remoteReport?.summary.flags.cached === true, + }; + }, + [], + ); + + const loadMultiConsumerChain = useCallback(async () => { + setStatus('loading'); + setErrorMessage(''); + setRemoteComponent(null); + setMultiConsumerResults([]); + observability.clear(); + + try { + const checkoutConsumer = createInstance({ + name: 'observability_consumer_checkout', + version: '1.0.0', + plugins: [observability.plugin], + remotes: [ + { + name: multiProducerRemoteName, + alias: multiProducerAlias, + entry: multiProducerManifestEntry, + }, + ], + shared: { + 'observability-checkout-theme': { + version: '1.4.0', + scope: ['observability-checkout-scope'], + lib: () => ({ + provider: 'observability_consumer_checkout', + version: '1.4.0', + }), + shareConfig: { + requiredVersion: '^1.0.0', + singleton: false, + eager: false, + strictVersion: false, + }, + }, + }, + }); + const analyticsConsumer = createInstance({ + name: 'observability_consumer_analytics', + version: '1.0.0', + plugins: [observability.plugin], + remotes: [ + { + name: multiProducerRemoteName, + alias: multiProducerAlias, + entry: multiProducerManifestEntry, + }, + ], + shared: { + 'observability-analytics-sdk': { + version: '1.2.0', + scope: ['observability-analytics-scope'], + lib: () => ({ + provider: 'observability_consumer_analytics', + version: '1.2.0', + }), + shareConfig: { + requiredVersion: '^1.0.0', + singleton: false, + eager: false, + strictVersion: false, + }, + }, + }, + }); + const consumerInstances: Record< + string, + ReturnType + > = { + 'checkout-page': checkoutConsumer, + 'analytics-page': analyticsConsumer, + 'checkout-page-repeat': checkoutConsumer, + }; + + const results: MultiConsumerResult[] = []; + + for (const scenario of multiConsumerScenarios) { + const result = await runMultiConsumerScenario( + consumerInstances[scenario.consumer], + scenario, + ); + + if (result.sharedProvider !== scenario.expectedProvider) { + throw new Error( + `${scenario.consumer} expected ${scenario.expectedProvider} but used ${result.sharedProvider}`, + ); + } + + results.push(result); + } + + setMultiConsumerResults(results); + setStatus('success'); + setReportText( + JSON.stringify( + { + scenario: 'multi-consumer-loading-chain', + results, + reports: observability.getReports({ limit: 12 }), + }, + null, + 2, + ), + ); + } catch (error) { + const message = sanitizeErrorMessage(error); + setStatus('error'); + setErrorMessage(message); + refreshReport(); + } + }, [refreshReport, runMultiConsumerScenario]); + + const loadEagerConfigError = useCallback(() => { + setStatus('loading'); + setErrorMessage(''); + setRemoteComponent(null); + + try { + loadShareSync('observability-async-shared', { + from: 'build', + customShareInfo: { + version: '1.0.0', + scope: ['default'], + shareConfig: { + requiredVersion: '^1.0.0', + singleton: false, + eager: false, + strictVersion: false, + }, + get: () => + Promise.resolve(() => ({ + value: 'async shared should not be consumed synchronously', + })), + }, + }); + + throw new Error('Eager config scenario unexpectedly loaded'); + } catch (error) { + const message = sanitizeErrorMessage(error); + setStatus('error'); + setErrorMessage(message); + } finally { + refreshReport(); + } + }, [refreshReport]); + + const loadRuntimeEagerConfigError = useCallback(() => { + setStatus('loading'); + setErrorMessage(''); + setRemoteComponent(null); + + try { + loadShareSync('observability-runtime-async-shared', { + from: 'runtime', + customShareInfo: { + version: '1.0.0', + scope: ['default'], + shareConfig: { + requiredVersion: '^1.0.0', + singleton: false, + eager: false, + strictVersion: false, + }, + get: () => + Promise.resolve(() => ({ + value: + 'runtime async shared should not be consumed synchronously', + })), + }, + }); + + throw new Error('Runtime eager config scenario unexpectedly loaded'); + } catch (error) { + const message = sanitizeErrorMessage(error); + setStatus('error'); + setErrorMessage(message); + } finally { + refreshReport(); + } + }, [refreshReport]); + const markBusinessLoaded = useCallback(() => { + observability.markComponentLoaded({ + requestId: successRequest, + componentName: 'ButtonOldAnt', + metadata: { + route: '/observability?token=demo-secret#hash', + rendered: true, + }, + }); + refreshReport(); + }, [refreshReport]); + + const LoadedRemote = remoteComponent; + + return ( +
+

Observability Demo

+ +
+

Load Remote

+ + + + + + + + + + + +
+ +
+

Shared / Eager Scenarios

+ + + + + +
+ +
+

Multi Consumer Loading Chain

+ + {multiConsumerResults.length ? ( +
    + {multiConsumerResults.map((result) => ( +
  • + {result.consumer} loaded {result.request} with{' '} + {result.sharedName} from {result.sharedProvider}@ + {result.sharedVersion}; remoteTrace={result.remoteTraceId}; + sharedTrace={result.sharedTraceId}; cached= + {String(result.summaryCached)} +
  • + ))} +
+ ) : null} +
+ +
+

Status

+

{status}

+ {errorMessage ? ( +
{errorMessage}
+ ) : null} + {LoadedRemote ? ( +
+ +
+ ) : null} +
+ +
+

Report Fixture

+
{reportText}
+
+
+ ); +} diff --git a/apps/runtime-demo/3005-runtime-host/src/ObservabilityShowcase.tsx b/apps/runtime-demo/3005-runtime-host/src/ObservabilityShowcase.tsx new file mode 100644 index 00000000000..034c4cb686b --- /dev/null +++ b/apps/runtime-demo/3005-runtime-host/src/ObservabilityShowcase.tsx @@ -0,0 +1,475 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { createInstance } from '@module-federation/runtime'; +import { useLocation, useNavigate } from 'react-router-dom'; +import { observability } from './observability'; +import './observability-showcase.css'; + +type ShowcaseStatus = 'loading' | 'success' | 'degraded' | 'error'; +type ShowcaseRoute = 'profile' | 'analytics'; +type RemoteComponent = React.ComponentType>; +type CustomerSdk = { + provider: string; + version: string; + feature: string; +}; +type LoadResult = { + Component: RemoteComponent; + traceId: string; + shared: string[]; + status?: ShowcaseStatus; + message?: string; +}; + +const producerName = 'runtime_remote2'; +const producerAlias = 'dynamic-remote'; +const producerManifest = 'http://127.0.0.1:3007/mf-manifest.json'; +const profileRequest = `${producerAlias}/ProfileCard`; +const analyticsRequest = `${producerAlias}/AnalyticsPanel`; +const analyticsConsumerName = 'observability_showcase_analytics_consumer'; + +function getRoute(pathname: string): ShowcaseRoute { + return pathname.endsWith('/analytics') ? 'analytics' : 'profile'; +} + +function resolveRemoteComponent(remoteModule: unknown): RemoteComponent { + if (typeof remoteModule === 'function') { + return remoteModule as RemoteComponent; + } + + if (remoteModule && typeof remoteModule === 'object') { + const candidate = (remoteModule as { default?: unknown }).default; + + if (typeof candidate === 'function') { + return candidate as RemoteComponent; + } + } + + throw new Error('Remote module did not return a React component'); +} + +function createSharedReact() { + return { + version: '18.3.1', + scope: ['default'], + lib: () => React, + shareConfig: { + requiredVersion: '^18.0.0', + singleton: true, + eager: false, + strictVersion: false, + }, + }; +} + +function createConsumer( + name: string, + shared?: Parameters[0]['shared'], +) { + return createInstance({ + name, + version: '1.0.0', + plugins: [observability.plugin], + remotes: [ + { + name: producerName, + alias: producerAlias, + entry: producerManifest, + }, + ], + shared: { + react: createSharedReact(), + ...(shared || {}), + }, + }); +} + +async function loadProfileWidget() { + const consumer = createConsumer('observability_showcase_profile_consumer'); + const remoteModule = await consumer.loadRemote(profileRequest); + const Component = resolveRemoteComponent(remoteModule); + const report = observability.getLatestReport(); + + if (report?.traceId) { + observability.markComponentLoaded({ + traceId: report.traceId, + requestId: profileRequest, + componentName: 'ProfileCard', + metadata: { + route: 'profile', + consumer: 'observability_showcase_profile_consumer', + producer: producerName, + expose: './ProfileCard', + }, + }); + } + + return { + Component, + traceId: report?.traceId || '', + shared: [] as string[], + } satisfies LoadResult; +} + +async function loadAnalyticsWorkspace() { + const consumer = createConsumer(analyticsConsumerName, { + 'observability-customer-sdk': { + version: '2.1.0', + scope: ['default'], + lib: () => ({ + provider: analyticsConsumerName, + version: '2.1.0', + feature: 'customer-insights', + }), + shareConfig: { + requiredVersion: '^2.0.0', + singleton: false, + eager: false, + strictVersion: false, + }, + }, + }); + const remoteModule = await consumer.loadRemote(analyticsRequest); + resolveRemoteComponent(remoteModule); + + const reactFactory = await consumer.loadShare('react', { + customShareInfo: { + version: '18.3.1', + scope: ['default'], + shareConfig: { + requiredVersion: '^18.0.0', + singleton: true, + eager: false, + strictVersion: false, + }, + }, + }); + const sdkFactory = await consumer.loadShare( + 'observability-customer-sdk', + { + customShareInfo: { + version: '2.1.0', + scope: ['default'], + shareConfig: { + requiredVersion: '^3.0.0', + singleton: false, + eager: false, + strictVersion: false, + }, + }, + }, + ); + + if (reactFactory === false) { + throw new Error('React shared dependency was not resolved'); + } + + if (sdkFactory === false) { + throw new Error('Customer SDK shared dependency was not resolved'); + } + + const sdk = sdkFactory(); + const Component = resolveRemoteComponent(remoteModule); + const report = observability.getLatestReport(); + + if (report?.traceId) { + observability.markComponentLoaded({ + traceId: report.traceId, + requestId: analyticsRequest, + componentName: 'AnalyticsPanel', + metadata: { + route: 'analytics', + consumer: analyticsConsumerName, + producer: producerName, + expose: './AnalyticsPanel', + shared: ['react', 'observability-customer-sdk'], + customerSdkProvider: sdk.provider, + customerSdkVersion: sdk.version, + }, + }); + } + + return { + Component, + traceId: report?.traceId || '', + shared: [ + `react from ${analyticsConsumerName}@18.3.1`, + `observability-customer-sdk from ${sdk.provider}@${sdk.version}`, + ], + } satisfies LoadResult; +} + +function AnalyticsFallback() { + return ( +
+

Limited analytics view

+

+ Key account metrics are still available while detailed insights are + temporarily limited. +

+
+ summary available + details limited + support reference ready +
+
+ ); +} + +function getLatestTraceId(): string { + return observability.getLatestReport()?.traceId ?? 'pending'; +} + +export default function ObservabilityShowcase() { + const location = useLocation(); + const navigate = useNavigate(); + const route = useMemo(() => getRoute(location.pathname), [location.pathname]); + const [status, setStatus] = useState('loading'); + const [referenceId, setReferenceId] = useState(''); + const [errorMessage, setErrorMessage] = useState(''); + const [remoteComponent, setRemoteComponent] = + useState(null); + const [sharedEvidence, setSharedEvidence] = useState([]); + + useEffect(() => { + let disposed = false; + + setStatus('loading'); + setReferenceId(''); + setErrorMessage(''); + setRemoteComponent(null); + setSharedEvidence([]); + + const load = + route === 'analytics' ? loadAnalyticsWorkspace : loadProfileWidget; + + load() + .then((result) => { + if (disposed) { + return; + } + + setRemoteComponent(() => result.Component); + setSharedEvidence(result.shared); + setReferenceId(result.traceId || getLatestTraceId()); + setErrorMessage(result.message || ''); + setStatus(result.status || 'success'); + }) + .catch((error) => { + if (disposed) { + return; + } + + const message = error instanceof Error ? error.message : String(error); + + if (route === 'analytics') { + setRemoteComponent(() => AnalyticsFallback); + setSharedEvidence([ + 'Core account page is available', + 'Detailed analytics are temporarily limited', + ]); + setErrorMessage( + 'Some analytics details are temporarily unavailable.', + ); + setReferenceId(getLatestTraceId()); + setStatus('degraded'); + return; + } + + setErrorMessage(message); + setReferenceId(getLatestTraceId()); + setStatus('error'); + }); + + return () => { + disposed = true; + }; + }, [route]); + + const openAnalyticsWorkspace = useCallback(() => { + navigate('/observability-showcase/analytics'); + }, [navigate]); + + const isAnalytics = route === 'analytics'; + const RemoteComponent = remoteComponent; + + return ( +
+ + +
+
+
+

Enterprise account

+

Acme Retail Group

+
+ +
+ +
+
+ Contract value + $1.42M +
+
+ Open requests + 18 +
+
+ Health score + 82 +
+
+ +
+
+
+
+

+ {isAnalytics ? 'Route: insights' : 'Route: overview'} +

+

{isAnalytics ? 'Account analytics' : 'User profile'}

+
+ + {status} + +
+ +
+ {RemoteComponent ? ( + + ) : ( + <> +
AR
+
+ + {status === 'loading' + ? 'Loading remote component' + : 'Remote component unavailable'} + + + {isAnalytics + ? 'Loading the analytics expose from the producer.' + : 'Loading the profile expose from the producer.'} + +
+ + )} +
+ + {status === 'error' ? ( +
+ Remote widget is temporarily unavailable. + + {errorMessage || 'Share this reference with support:'} + + {referenceId} + + +
+ ) : status === 'degraded' ? ( +
+ Limited analytics view is active. + + {errorMessage} + + {referenceId} + + +
+ ) : ( +
+ {isAnalytics + ? 'This view loads a second expose and resolves React plus the customer SDK as shared dependencies.' + : 'This view is loaded by createInstance when the page opens.'} + {referenceId ? ( + + {referenceId} + + ) : null} +
+ )} + + + + {sharedEvidence.length ? ( +
    + {sharedEvidence.map((item) => ( +
  • {item}
  • + ))} +
+ ) : null} +
+ +
+

Recent activity

+
    +
  • + Profile expose loaded on overview route + +
  • +
  • + Analytics expose waits for route navigation + +
  • +
  • + Shared dependency evidence is kept in reports + +
  • +
+
+
+
+
+ ); +} diff --git a/apps/runtime-demo/3005-runtime-host/src/bootstrap.tsx b/apps/runtime-demo/3005-runtime-host/src/bootstrap.tsx index 3f87d871a13..6f34b24294e 100644 --- a/apps/runtime-demo/3005-runtime-host/src/bootstrap.tsx +++ b/apps/runtime-demo/3005-runtime-host/src/bootstrap.tsx @@ -1,13 +1,12 @@ import React, { StrictMode } from 'react'; -import { - init, - registerGlobalPlugins, -} from '@module-federation/enhanced/runtime'; +import { init } from '@module-federation/enhanced/runtime'; import * as ReactDOM from 'react-dom/client'; import App from './App'; +import { observability } from './observability'; init({ name: 'runtime_host', + plugins: [observability.plugin], remotes: [ { name: 'runtime_remote2', diff --git a/apps/runtime-demo/3005-runtime-host/src/components/ButtonOldAnt.tsx b/apps/runtime-demo/3005-runtime-host/src/components/ButtonOldAnt.tsx index c1552882ff5..bad5b1a4610 100644 --- a/apps/runtime-demo/3005-runtime-host/src/components/ButtonOldAnt.tsx +++ b/apps/runtime-demo/3005-runtime-host/src/components/ButtonOldAnt.tsx @@ -1,9 +1,7 @@ import Button from 'antd/lib/button'; -import antdPackage from 'antd/package.json'; +import version from 'antd/lib/version'; import stuff from './stuff.module.css'; -const { version } = antdPackage; - export default function ButtonOldAnt() { return ; } diff --git a/apps/runtime-demo/3005-runtime-host/src/index.ts b/apps/runtime-demo/3005-runtime-host/src/index.ts index 51ffb285cfc..8cae17419e4 100644 --- a/apps/runtime-demo/3005-runtime-host/src/index.ts +++ b/apps/runtime-demo/3005-runtime-host/src/index.ts @@ -6,4 +6,4 @@ import customPlugin from './runtimePlugin'; registerGlobalPlugins([customPlugin()]); -require('./bootstrap'); +void import('./bootstrap'); diff --git a/apps/runtime-demo/3005-runtime-host/src/observability-showcase.css b/apps/runtime-demo/3005-runtime-host/src/observability-showcase.css new file mode 100644 index 00000000000..fb8682db229 --- /dev/null +++ b/apps/runtime-demo/3005-runtime-host/src/observability-showcase.css @@ -0,0 +1,427 @@ +.customer-portal { + display: grid; + grid-template-columns: 248px minmax(0, 1fr); + min-height: 100vh; + color: #1f2933; + background: #f4f6f8; + font-family: + Inter, + ui-sans-serif, + system-ui, + -apple-system, + BlinkMacSystemFont, + 'Segoe UI', + sans-serif; +} + +.customer-portal *, +.customer-portal *::before, +.customer-portal *::after { + box-sizing: border-box; +} + +.customer-portal__sidebar { + padding: 28px 18px; + border-right: 1px solid #dde4eb; + background: #ffffff; +} + +.customer-portal__brand { + margin-bottom: 28px; + color: #111827; + font-size: 18px; + font-weight: 800; +} + +.customer-portal__nav { + display: grid; + gap: 6px; +} + +.customer-portal__nav-item { + padding: 10px 12px; + border-radius: 6px; + color: #536171; + font-size: 14px; + font-weight: 650; + text-decoration: none; +} + +.customer-portal__nav-item--active { + color: #0f4c81; + background: #e8f2fb; +} + +.customer-portal__content { + padding: 32px; +} + +.customer-portal__header { + display: flex; + gap: 18px; + justify-content: space-between; + align-items: flex-start; + margin: 0 0 24px; +} + +.customer-portal__eyebrow { + margin: 0 0 8px; + color: #667789; + font-size: 12px; + font-weight: 800; + letter-spacing: 0; + text-transform: uppercase; +} + +.customer-portal h1, +.customer-portal h2 { + margin: 0; + color: #111827; + letter-spacing: 0; +} + +.customer-portal h1 { + font-size: 34px; + line-height: 1.15; +} + +.customer-portal h2 { + font-size: 22px; + line-height: 1.25; +} + +.customer-portal__metrics { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 16px; + margin-bottom: 20px; +} + +.customer-portal__metrics div, +.customer-portal__card { + border: 1px solid #dde4eb; + border-radius: 8px; + background: #ffffff; + box-shadow: 0 10px 28px rgba(31, 41, 51, 0.06); +} + +.customer-portal__metrics div { + padding: 18px; +} + +.customer-portal__metrics span { + display: block; + margin-bottom: 8px; + color: #667789; + font-size: 13px; + font-weight: 650; +} + +.customer-portal__metrics strong { + color: #111827; + font-size: 26px; +} + +.customer-portal__workspace { + display: grid; + grid-template-columns: minmax(0, 1fr) 360px; + gap: 20px; +} + +.customer-portal__card { + padding: 22px; +} + +.customer-portal__card--profile { + min-height: 430px; +} + +.customer-portal__card-header { + display: flex; + gap: 16px; + justify-content: space-between; + align-items: flex-start; +} + +.customer-portal__status { + min-width: 82px; + padding: 7px 11px; + border-radius: 999px; + font-size: 12px; + font-weight: 800; + text-align: center; + text-transform: uppercase; +} + +.customer-portal__status--ready { + color: #245275; + background: #e6f2fb; +} + +.customer-portal__status--loading { + color: #7a4f00; + background: #fff3cf; +} + +.customer-portal__status--success { + color: #0f6b45; + background: #ddf8eb; +} + +.customer-portal__status--degraded { + color: #7a4f00; + background: #fff3cf; +} + +.customer-portal__status--error { + color: #9f1d20; + background: #ffe3e3; +} + +.customer-portal__profile-shell { + display: flex; + gap: 16px; + align-items: center; + min-height: 170px; + margin: 28px 0 18px; + padding: 24px; + border: 1px dashed #bac7d3; + border-radius: 8px; + background: #f8fafc; +} + +.customer-portal__avatar { + display: grid; + width: 58px; + height: 58px; + border-radius: 50%; + color: #ffffff; + background: #0f4c81; + font-size: 18px; + font-weight: 800; + place-items: center; + flex: 0 0 auto; +} + +.customer-portal__profile-shell strong { + display: block; + color: #111827; + font-size: 18px; + line-height: 1.35; +} + +.customer-portal__profile-shell span { + display: block; + margin-top: 6px; + color: #667789; + font-size: 14px; + line-height: 1.55; +} + +.customer-portal__remote-widget { + width: 100%; +} + +.customer-portal__remote-widget h3 { + margin: 0 0 8px; + color: #111827; + font-size: 20px; + line-height: 1.25; +} + +.customer-portal__remote-widget p { + margin: 0; + color: #667789; + font-size: 14px; + line-height: 1.55; +} + +.customer-portal__remote-meta { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 16px; +} + +.customer-portal__remote-meta span { + margin: 0; + padding: 6px 9px; + border-radius: 6px; + color: #245275; + background: #e6f2fb; + font-size: 12px; + font-weight: 750; +} + +.customer-portal__hint, +.customer-portal__fallback, +.customer-portal__error { + margin-bottom: 18px; + padding: 14px 16px; + border-radius: 8px; + font-size: 14px; + line-height: 1.6; +} + +.customer-portal__hint { + color: #425466; + background: #f1f5f9; +} + +.customer-portal__error { + color: #7a2222; + background: #fff0f0; +} + +.customer-portal__fallback { + color: #6f4b00; + background: #fff6d9; +} + +.customer-portal__error strong, +.customer-portal__fallback strong, +.customer-portal__fallback span, +.customer-portal__error span { + display: block; +} + +.customer-portal__hint code, +.customer-portal__fallback code, +.customer-portal__error code { + display: inline-block; + margin-left: 6px; + padding: 2px 6px; + border-radius: 4px; + color: #5d1f1f; + background: #ffe1e1; + font-family: + ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', + monospace; + font-size: 13px; + overflow-wrap: anywhere; +} + +.customer-portal__hint code { + color: #245275; + background: #dceefb; +} + +.customer-portal__fallback code { + color: #6f4b00; + background: #ffedaf; +} + +.customer-portal__shared-list { + display: grid; + gap: 8px; + margin: 16px 0 0; + padding: 0; + list-style: none; +} + +.customer-portal__shared-list li { + padding: 10px 12px; + border: 1px solid #d7e5ef; + border-radius: 6px; + color: #245275; + background: #f4f9fd; + font-size: 13px; + font-weight: 700; +} + +.customer-portal__primary-button, +.customer-portal__secondary-button { + min-height: 42px; + border-radius: 6px; + font-size: 14px; + font-weight: 800; + cursor: pointer; +} + +.customer-portal__primary-button { + padding: 0 18px; + border: 0; + color: #ffffff; + background: #0f4c81; +} + +.customer-portal__primary-button:hover { + background: #0b3e69; +} + +.customer-portal__primary-button:disabled { + cursor: wait; + background: #8aa8c1; +} + +.customer-portal__secondary-button { + padding: 0 16px; + border: 1px solid #c8d3de; + color: #334155; + background: #ffffff; +} + +.customer-portal__activity { + display: grid; + gap: 14px; + margin: 8px 0 0; + padding: 0; + list-style: none; +} + +.customer-portal__activity li { + display: flex; + gap: 12px; + justify-content: space-between; + padding-bottom: 14px; + border-bottom: 1px solid #eef2f6; + color: #273547; + font-size: 14px; + line-height: 1.5; +} + +.customer-portal__activity li:last-child { + padding-bottom: 0; + border-bottom: 0; +} + +.customer-portal__activity time { + color: #76879a; + white-space: nowrap; +} + +@media (max-width: 900px) { + .customer-portal { + grid-template-columns: 1fr; + } + + .customer-portal__sidebar { + border-right: 0; + border-bottom: 1px solid #dde4eb; + } + + .customer-portal__nav { + grid-template-columns: repeat(4, max-content); + overflow-x: auto; + } + + .customer-portal__metrics, + .customer-portal__workspace { + grid-template-columns: 1fr; + } +} + +@media (max-width: 560px) { + .customer-portal__content { + padding: 20px; + } + + .customer-portal__header, + .customer-portal__card-header { + display: grid; + } + + .customer-portal h1 { + font-size: 28px; + } +} diff --git a/apps/runtime-demo/3005-runtime-host/src/observability.ts b/apps/runtime-demo/3005-runtime-host/src/observability.ts new file mode 100644 index 00000000000..cff01684308 --- /dev/null +++ b/apps/runtime-demo/3005-runtime-host/src/observability.ts @@ -0,0 +1,10 @@ +import { ObservabilityPlugin } from '@module-federation/observability-plugin'; + +export const observability = ObservabilityPlugin({ + level: 'verbose', + maxEvents: 100, + browser: { + enabled: true, + scope: 'runtime_host', + }, +}); diff --git a/apps/runtime-demo/3005-runtime-host/tsconfig.app.json b/apps/runtime-demo/3005-runtime-host/tsconfig.app.json index 8864b3d724f..af024f1690b 100644 --- a/apps/runtime-demo/3005-runtime-host/tsconfig.app.json +++ b/apps/runtime-demo/3005-runtime-host/tsconfig.app.json @@ -2,16 +2,12 @@ "extends": "./tsconfig.json", "compilerOptions": { "outDir": "../../../dist/out-tsc", - "types": [ - "node", - "../../../typings/cssmodule.d.ts", - "../../../typings/image.d.ts" - ], + "types": ["node"], "paths": { "*": ["./@mf-types/*"] } }, - "files": ["../../../typings/cssmodule.d.ts", "../../../typings/image.d.ts"], + "files": ["../typings/cssmodule.d.ts", "../typings/image.d.ts"], "exclude": [ "jest.config.ts", "**/*.spec.ts", diff --git a/apps/runtime-demo/3005-runtime-host/webpack.config.js b/apps/runtime-demo/3005-runtime-host/webpack.config.js index dce5c186906..5f17e4b512f 100644 --- a/apps/runtime-demo/3005-runtime-host/webpack.config.js +++ b/apps/runtime-demo/3005-runtime-host/webpack.config.js @@ -1,3 +1,5 @@ +/* eslint-env node */ + const path = require('path'); const reactPath = path.dirname(require.resolve('react/package.json')); const reactDomPath = path.dirname(require.resolve('react-dom/package.json')); @@ -7,11 +9,55 @@ const cssLoader = require.resolve('css-loader'); const { ModuleFederationPlugin, } = require('@module-federation/enhanced/webpack'); +const { + ObservabilityBuildPlugin, +} = require('@module-federation/observability-plugin/build'); const HtmlWebpackPlugin = require('html-webpack-plugin'); module.exports = (_env, argv = {}) => { const isProduction = argv.mode === 'production'; const sourcePath = path.resolve(__dirname, 'src'); + const moduleFederationOptions = { + name: 'runtime_host', + experiments: { asyncStartup: true }, + remotes: { + remote1: 'runtime_remote1@http://127.0.0.1:3006/mf-manifest.json', + }, + filename: 'remoteEntry.js', + exposes: { + './Button': './src/Button.tsx', + }, + dts: { + tsConfigPath: path.resolve(__dirname, 'tsconfig.app.json'), + }, + shareStrategy: 'loaded-first', + shared: { + lodash: { + singleton: true, + requiredVersion: '^4.0.0', + }, + antd: { + singleton: true, + requiredVersion: '^4.0.0', + }, + react: { + singleton: true, + requiredVersion: '^18.2.0', + }, + 'react/': { + singleton: true, + requiredVersion: '^18.2.0', + }, + 'react-dom': { + singleton: true, + requiredVersion: '^18.2.0', + }, + 'react-dom/': { + singleton: true, + requiredVersion: '^18.2.0', + }, + }, + }; return { mode: isProduction ? 'production' : 'development', @@ -87,46 +133,9 @@ module.exports = (_env, argv = {}) => { ], }, plugins: [ - new ModuleFederationPlugin({ - name: 'runtime_host', - experiments: { asyncStartup: true }, - remotes: { - remote1: 'runtime_remote1@http://127.0.0.1:3006/mf-manifest.json', - }, - filename: 'remoteEntry.js', - exposes: { - './Button': './src/Button.tsx', - }, - dts: { - tsConfigPath: path.resolve(__dirname, 'tsconfig.app.json'), - }, - shareStrategy: 'loaded-first', - shared: { - lodash: { - singleton: true, - requiredVersion: '^4.0.0', - }, - antd: { - singleton: true, - requiredVersion: '^4.0.0', - }, - react: { - singleton: true, - requiredVersion: '^18.2.0', - }, - 'react/': { - singleton: true, - requiredVersion: '^18.2.0', - }, - 'react-dom': { - singleton: true, - requiredVersion: '^18.2.0', - }, - 'react-dom/': { - singleton: true, - requiredVersion: '^18.2.0', - }, - }, + new ModuleFederationPlugin(moduleFederationOptions), + new ObservabilityBuildPlugin({ + moduleFederation: moduleFederationOptions, }), new HtmlWebpackPlugin({ template: path.resolve(__dirname, 'src/index.html'), @@ -144,6 +153,169 @@ module.exports = (_env, argv = {}) => { devMiddleware: { writeToDisk: true, }, + setupMiddlewares: (middlewares, devServer) => { + if (!devServer.app) { + return middlewares; + } + + const sendJson = (response, body) => { + response.setHeader('Content-Type', 'application/json'); + response.end(JSON.stringify(body)); + }; + const sendJs = (response, body) => { + response.setHeader('Content-Type', 'application/javascript'); + response.end(body); + }; + const createManifest = ({ name, globalName, publicPath }) => ({ + id: name, + name, + metaData: { + name, + type: 'app', + buildInfo: { + buildVersion: 'observability-fixture', + buildName: name, + }, + remoteEntry: { + name: 'remoteEntry.js', + path: '', + type: 'global', + }, + types: { + path: '', + name: '', + zip: '', + api: '', + }, + globalName, + pluginVersion: 'observability-fixture', + publicPath, + }, + shared: [], + remotes: [], + exposes: [ + { + id: `${name}:Button`, + name: 'Button', + assets: { + js: { + sync: [], + async: [], + }, + css: { + sync: [], + async: [], + }, + }, + path: './Button', + }, + ], + }); + + devServer.app.get( + '/observability-fixtures/missing-fields/mf-manifest.json', + (_request, response) => { + sendJson(response, { + id: 'observability_missing_fields_remote', + name: 'observability_missing_fields_remote', + }); + }, + ); + devServer.app.get( + '/observability-fixtures/retry-recovered/mf-manifest.json', + (_request, response) => { + sendJson( + response, + createManifest({ + name: 'observability_retry_recovered_remote', + globalName: 'observability_retry_recovered_remote', + publicPath: + 'http://127.0.0.1:3005/observability-fixtures/retry-recovered/', + }), + ); + }, + ); + devServer.app.get( + '/observability-fixtures/retry-recovered/remoteEntry.js', + (request, response) => { + if (request.query.retryCount !== '1') { + response.statusCode = 503; + response.end('observability retry fixture failed before retry'); + return; + } + + sendJs( + response, + [ + 'window.observability_retry_recovered_remote = {', + ' init: function() {},', + ' get: function() {', + ' return Promise.resolve(function() {', + ' return { default: function ObservabilityRetryRecovered() { return null; } };', + ' });', + ' }', + '};', + ].join('\n'), + ); + }, + ); + devServer.app.get( + '/observability-fixtures/wrong-global/mf-manifest.json', + (_request, response) => { + sendJson( + response, + createManifest({ + name: 'observability_wrong_global_remote', + globalName: 'observability_wrong_global_expected', + publicPath: + 'http://127.0.0.1:3005/observability-fixtures/wrong-global/', + }), + ); + }, + ); + devServer.app.get( + '/observability-fixtures/wrong-global/remoteEntry.js', + (_request, response) => { + sendJs( + response, + [ + 'window.observability_wrong_global_actual = {', + ' init: function() {},', + ' get: function() {', + ' return Promise.resolve(function() {', + ' return { default: function ObservabilityWrongGlobal() { return null; } };', + ' });', + ' }', + '};', + ].join('\n'), + ); + }, + ); + devServer.app.get( + '/observability-fixtures/execution-error/mf-manifest.json', + (_request, response) => { + sendJson( + response, + createManifest({ + name: 'observability_execution_error_remote', + globalName: 'observability_execution_error_remote', + publicPath: + 'http://127.0.0.1:3005/observability-fixtures/execution-error/', + }), + ); + }, + ); + devServer.app.get( + '/observability-fixtures/execution-error/remoteEntry.js', + (_request, response) => { + sendJs( + response, + "throw new Error('observability remoteEntry execution failed');", + ); + }, + ); + return middlewares; + }, }, optimization: { runtimeChunk: false, diff --git a/apps/runtime-demo/3006-runtime-remote/tsconfig.app.json b/apps/runtime-demo/3006-runtime-remote/tsconfig.app.json index 9f647569f50..196facc7dcf 100644 --- a/apps/runtime-demo/3006-runtime-remote/tsconfig.app.json +++ b/apps/runtime-demo/3006-runtime-remote/tsconfig.app.json @@ -3,12 +3,8 @@ "compilerOptions": { "composite": true, "declaration": true, - "types": [ - "node", - "../../../typings/cssmodule.d.ts", - "../../../typings/image.d.ts" - ] + "types": ["node"] }, - "files": ["../../../typings/cssmodule.d.ts", "../../../typings/image.d.ts"], + "files": ["../typings/cssmodule.d.ts", "../typings/image.d.ts"], "include": ["src/**/*"] } diff --git a/apps/runtime-demo/3007-runtime-remote/src/components/AnalyticsPanel.tsx b/apps/runtime-demo/3007-runtime-remote/src/components/AnalyticsPanel.tsx new file mode 100644 index 00000000000..55355f372d4 --- /dev/null +++ b/apps/runtime-demo/3007-runtime-remote/src/components/AnalyticsPanel.tsx @@ -0,0 +1,20 @@ +export default function AnalyticsPanel() { + return ( +
+

Expansion analytics

+

+ Analytics panel loaded from runtime_remote2/AnalyticsPanel after route + navigation. +

+
+ producer: runtime_remote2 + expose: AnalyticsPanel + shared: react + shared: observability-customer-sdk +
+
+ ); +} diff --git a/apps/runtime-demo/3007-runtime-remote/src/components/ProfileCard.tsx b/apps/runtime-demo/3007-runtime-remote/src/components/ProfileCard.tsx new file mode 100644 index 00000000000..786d89ba900 --- /dev/null +++ b/apps/runtime-demo/3007-runtime-remote/src/components/ProfileCard.tsx @@ -0,0 +1,18 @@ +export default function ProfileCard() { + return ( +
+

Jordan Lee

+

+ Account owner profile loaded from runtime_remote2/ProfileCard when the + page opened. +

+
+ producer: runtime_remote2 + expose: ProfileCard +
+
+ ); +} diff --git a/apps/runtime-demo/3007-runtime-remote/tsconfig.app.json b/apps/runtime-demo/3007-runtime-remote/tsconfig.app.json index e02bdff676a..6d7c66b0018 100644 --- a/apps/runtime-demo/3007-runtime-remote/tsconfig.app.json +++ b/apps/runtime-demo/3007-runtime-remote/tsconfig.app.json @@ -2,13 +2,9 @@ "extends": "./tsconfig.json", "compilerOptions": { "outDir": "../../../dist/out-tsc", - "types": [ - "node", - "../../../typings/cssmodule.d.ts", - "../../../typings/image.d.ts" - ] + "types": ["node"] }, - "files": ["../../../typings/cssmodule.d.ts", "../../../typings/image.d.ts"], + "files": ["../typings/cssmodule.d.ts", "../typings/image.d.ts"], "exclude": [ "jest.config.ts", "**/*.spec.ts", diff --git a/apps/runtime-demo/3007-runtime-remote/webpack.config.js b/apps/runtime-demo/3007-runtime-remote/webpack.config.js index c7aa170ed70..feff0c14c5f 100644 --- a/apps/runtime-demo/3007-runtime-remote/webpack.config.js +++ b/apps/runtime-demo/3007-runtime-remote/webpack.config.js @@ -1,3 +1,5 @@ +/* eslint-env node */ + const path = require('path'); const reactPath = path.dirname(require.resolve('react/package.json')); const reactDomPath = path.dirname(require.resolve('react-dom/package.json')); @@ -92,6 +94,8 @@ module.exports = (_env, argv = {}) => { filename: 'remoteEntry.js', exposes: { './ButtonOldAnt': './src/components/ButtonOldAnt', + './ProfileCard': './src/components/ProfileCard', + './AnalyticsPanel': './src/components/AnalyticsPanel', }, shared: { lodash: { diff --git a/apps/runtime-demo/AGENTS.md b/apps/runtime-demo/AGENTS.md new file mode 100644 index 00000000000..8ce63645c5e --- /dev/null +++ b/apps/runtime-demo/AGENTS.md @@ -0,0 +1,80 @@ +# Runtime Demo Agent Guide + +This directory has a local copy of the MF skill at `./skills/mf/SKILL.md`. +When the user asks about Module Federation loading, shared dependencies, +observability reports, trace IDs, or the runtime demo, read that skill first. +For the showcase flow, use `./skills/mf/reference/observability.md`. + +## Showcase Task + +Use this flow when the user asks you to inspect the shared dependency loading +state in the runtime demo: + +1. Start the demo from the repository root: + +```bash +pnpm run app:runtime:dev +``` + +2. Open: + +```text +http://localhost:3005/observability-showcase +``` + +3. Confirm the page renders the profile widget. This first view is loaded with + `createInstance` from `runtime_remote2/ProfileCard`. + +4. Click `Open Analytics Workspace`. + +5. Confirm the route changes to: + +```text +http://localhost:3005/observability-showcase/analytics +``` + +6. Confirm the page shows a limited analytics view instead of crashing. Do not + treat the page copy as the root cause. This second view first tries to load + `runtime_remote2/AnalyticsPanel` with `createInstance`, then the + observability report shows `observability-customer-sdk` failed because the + route asks for `^3.0.0` but only `2.1.0` is available. + +7. Read the browser observability reports: + +```js +window.__FEDERATION__.__OBSERVABILITY__.runtime_host.getReports({ limit: 20 }); +window.__FEDERATION__.__OBSERVABILITY__.runtime_host.findReports({ + remote: 'runtime_remote2', +}); +window.__FEDERATION__.__OBSERVABILITY__.runtime_host.findReports({ + shared: 'react', +}); +window.__FEDERATION__.__OBSERVABILITY__.runtime_host.findReports({ + shared: 'observability-customer-sdk', +}); +``` + +8. Answer with: + +- which consumer loaded `ProfileCard` +- which consumer loaded `AnalyticsPanel` +- whether `react` was resolved +- whether `observability-customer-sdk` failed +- which version was required and which version was available +- why the page did not crash and what limited view was rendered + +## Suggested User Prompt + +```text +启动项目,访问 http://localhost:3005/observability-showcase, +确认首屏远程组件是否加载成功。 +然后点击 Open Analytics Workspace,看下共享依赖加载情况。 + +请说明: +1. 首屏加载了哪个生产者的哪个 expose +2. 点击后路由加载了哪个生产者的哪个 expose +3. 第二个 expose 使用了哪些 shared +4. 哪个 shared 加载失败了,原因是什么 +5. 页面为什么没有崩溃,显示了什么降级视图 +6. 观测报告里有哪些关键证据 +``` diff --git a/apps/runtime-demo/README.md b/apps/runtime-demo/README.md index 81cca8c5617..aa7335c84c7 100644 --- a/apps/runtime-demo/README.md +++ b/apps/runtime-demo/README.md @@ -10,8 +10,135 @@ host declare remote2 in webpack.config.js, and use `@module-federation/runtime` # Running Demo -Run `npm run app:runtime:dev` to start host, remote1, remote2 +Run `pnpm run app:runtime:dev` to start host, remote1, remote2. - host: [localhost:3005](http://localhost:3005/) - remote1: [localhost:3006](http://localhost:3006/) - remote2: [localhost:3007](http://localhost:3007/) + +## Observability Demo + +The observability fixture lives in the runtime host. The host explicitly enables +`@module-federation/observability-plugin` for this demo, so the report is +generated by the runtime plugin instead of local UI state. +The host also installs `ObservabilityBuildPlugin`, so each build writes a +build summary to `.mf/observability/build-info.json`. If a host build fails +after the plugin runs, the build-side report is written to +`.mf/observability/build-report.json`. + +- observability fixture page: + [localhost:3005/observability](http://localhost:3005/observability) +- observability showcase: + [localhost:3005/observability-showcase](http://localhost:3005/observability-showcase) + +Start the demo first: + +```bash +pnpm run app:runtime:dev +``` + +Then open the observability fixture page and use these controls: + +- `observability-showcase`: a clean recording page that looks like a normal + product page. Opening the page loads `runtime_remote2/ProfileCard` with + `createInstance`. Click `Open Analytics Workspace` to route to the analytics + view, which loads `runtime_remote2/AnalyticsPanel` with another + `createInstance`, resolves `react`, and then fails to resolve + `observability-customer-sdk` because the route asks for `^3.0.0` while only + `2.1.0` is available. The page renders a limited analytics view instead of + crashing, without exposing the low-level shared error in the UI. Use this page + to record an AI agent discovering which expose was attempted, which shared + dependency failed, why the limited view appeared, and what evidence exists in + the observability report. Use `/observability` for the full fixture matrix. + +- Build observability: after the host compiles, check + `apps/runtime-demo/3005-runtime-host/.mf/observability/build-info.json`. It + should include `runtime_host`, the remote manifest URL, `Button`, and shared + dependencies such as `react`, without local source paths. + A clean build should not leave a stale + `apps/runtime-demo/3005-runtime-host/.mf/observability/build-report.json`. + +- `Load success remote`: loads `dynamic-remote/ButtonOldAnt` from remote2. The + status should become `success`, the remote button should render, and the + report should include a completed `loadRemote` timeline with + `summary.outcome: "runtime-loaded"` and per-phase success facts under + `summary.phases`. The report should also include + `diagnosis.outcome: "runtime-loaded"`. + The same report is available at + `window.__FEDERATION__.__OBSERVABILITY__.runtime_host.getLatestReport()`. +- `Load missing expose`: loads a missing expose from remote2. The status should + become `error`, and the report should include + `dynamic-remote/__missing_expose__` with `failedPhase: "expose"`. The + `diagnosis.actions` list should include a concrete expose check. The browser + console should print an observability hint with a `traceId`. +- `Load broken manifest`: loads a remote with a broken manifest URL. The status + should become `error`. The report should include the original manifest URL, + `RUNTIME-003`, `ownerHint: "host"`, `diagnosis.facts.url`, and a manifest + check in `diagnosis.actions`. +- `Load remote URL error`: registers a remote whose manifest URL points to an + unavailable server. The report should fail at `manifest` and include + `RUNTIME-003`, the original URL, and a manifest check. +- `Load retry recovered`: serves a remoteEntry that fails on the first script + load and succeeds on retry. The report should be successful with + `summary.outcome: "recovered"`, `summary.flags.retried: true`, and a retry + recovery warning. +- `Load fallback success`: loads a missing expose and lets the demo fallback + plugin return a fallback module. The report should be successful with + `summary.outcome: "recovered"`, keep the original expose failure under + `summary.error`, and set `summary.flags.fallback: true`. +- `Load manifest missing fields`: serves a JSON manifest that misses required + fields. The report should fail at `manifest`, show the missing fields, and + include a manifest check. +- `Load wrong globalName`: serves a manifest and remoteEntry where the + remoteEntry registers a different global name from the manifest. The report + should fail at `remoteEntry`, include `RUNTIME-001`, and include a remote + global check. +- `Load remoteEntry execution error`: serves a remoteEntry that throws while it + is being executed. The report should fail at `remoteEntry`, include + `RUNTIME-008`, classify the resource error as script execution, and include a + remoteEntry check. +- `Load snapshot match error`: registers a version-only remote that cannot be + matched from deployment `moduleInfo`. The report should include + `RUNTIME-007`, `moduleInfo.reason: "remote-snapshot"`, the clipped + `moduleInfo.availableNames`, and a moduleInfo check. +- `Mark business loaded`: calls the observability plugin's component success API, + and the report should include `component:business-loaded` with + `summary.outcome: "component-loaded"`. The demo also sends sample metadata + to verify that business-provided metadata stays visible in the report. +- `Shared miss`: triggers a missing shared provider report. The report should + include `observability-missing-shared`, `missing-provider`, and shared + provider/version checks in `diagnosis.actions`. +- `Shared version mismatch`: asks for an unsupported React version. The report + should include `react`, `^99.0.0`, the available version, and + `version-mismatch`. +- `Shared unexpected provider`: resolves a shared dependency from a remote-like + provider even though the host provider is present. The report should be + successful and include `observability-provider-choice`, + `provider: "runtime_remote2"`, and `selectedVersion: "2.0.0"`. +- `Load multi-consumer chain`: creates two runtime consumers with + `createInstance`, loads different exposes from the real `runtime_remote2` + remote on port 3007, and gives each consumer its own shared dependency. It + records the remote load report, shared dependency report, business component + success event, and repeated load cache evidence. Use this scenario when + validating whether an agent can answer who loaded which expose, which shared + provider was selected, and whether the producer load reused cached runtime + state. +- `Eager config error`: synchronously consumes an async shared dependency. The + report should include `observability-async-shared`, `RUNTIME-005`, and + `sync-async-boundary`, with `ownerHint: "shared"` and an eager config check + in `diagnosis.actions`. +- `Runtime eager config error`: synchronously consumes an async shared + dependency from the pure runtime path. The report should include + `observability-runtime-async-shared`, `RUNTIME-006`, and + `sync-async-boundary`, with the same eager config check. + +Run the automated verification: + +```bash +pnpm run ci:local --only=e2e-runtime +``` + +This command installs the e2e dependencies if needed, builds the packages, +starts the host and remotes, and runs the Cypress checks. The observability +fixture is covered by the `observability demo fixture` tests in the runtime host +e2e suite. diff --git a/apps/runtime-demo/skills/mf/SKILL.md b/apps/runtime-demo/skills/mf/SKILL.md new file mode 100644 index 00000000000..0c4b3415464 --- /dev/null +++ b/apps/runtime-demo/skills/mf/SKILL.md @@ -0,0 +1,55 @@ +--- +name: mf +description: "All-in-one Module Federation skill. Use when the user asks anything about MF — concepts, configuration, runtime API, shared dependencies, type errors, runtime error code troubleshooting, observability, slow builds, Bridge integration, or adding MF to an existing project." +argument-hint: [args...] +allowed-tools: Read Glob Bash(node *) Bash(npx tsc*) Bash(npx mf dts*) Bash(curl *) WebFetch Write Edit AskUserQuestion +--- + +# MF — Module Federation All-in-One Skill + +## Step 1: Identify the sub-skill + +Parse `$ARGUMENTS` and map to a reference file in the `reference/` directory (same directory as this file): + +| Sub-command (case-insensitive) | Aliases | Reference file | +|---|---|---| +| `docs` | `doc`, `help`, `?` | `reference/docs.md` | +| `context` | `ctx`, `info`, `status` | `reference/context.md` | +| `module-info` | `module`, `remote`, `manifest` | `reference/module-info.md` | +| `integrate` | `init`, `setup`, `add` | `reference/integrate.md` | +| `type-check` | `types`, `ts`, `dts` | `reference/type-check.md` | +| `shared-deps` | `shared`, `deps`, `singleton` | `reference/shared-deps.md` | +| `perf` | `performance`, `hmr`, `speed` | `reference/perf.md` | +| `config-check` | `config`, `plugin`, `exposes` | `reference/config-check.md` | +| `bridge-check` | `bridge`, `sub-app` | `reference/bridge-check.md` | +| `runtime-error` | `runtime-code`, `runtime-008`, `runtime-001`, `remote-entry` | `reference/runtime-error.md` | +| `observability` | `observe`, `trace`, `traceId`, `report`, `observability`, `debug-loading`, `telemetry`, `runtime-007`, `moduleInfo`, `snapshot` | `reference/observability.md` | + +**If no explicit sub-command is found**, detect intent from the full input: + +If the input contains an observability report, `traceId`, console `read:` command, +`.mf/observability` file path, or asks how to observe, debug, trace, inspect, or +upload Module Federation loading data, choose `reference/observability.md` even +when the same input also contains a `RUNTIME-xxx` code. + +| Signal in input | Reference file | +|---|---| +| Question about MF concepts, API, configuration options | `reference/docs.md` | +| "integrate", "add MF", "setup", "scaffold", "new project" | `reference/integrate.md` | +| "type error", "TS error", "@mf-types", "dts", "typescript" | `reference/type-check.md` | +| "shared", "singleton", "duplicate", "antd", "transformImport" | `reference/shared-deps.md` | +| "slow", "HMR", "performance", "build speed", "ts-go" | `reference/perf.md` | +| "plugin", "asyncStartup", "exposes key", "config" | `reference/config-check.md` | +| "bridge", "sub-app", "export-app", "createRemoteAppComponent" | `reference/bridge-check.md` | +| "RUNTIME-001", "RUNTIME-008", "runtime error code", "remote entry load failed", "ScriptNetworkError", "ScriptExecutionError", "container missing", "window[remoteEntryKey]" | `reference/runtime-error.md` | +| "Observability report generated", "console.error", "traceId", "read:", "diagnosis", "ownerHint", "summary.phases", ".mf/observability", "build-report.json", "latest.json", "RUNTIME-007", "moduleInfo", "remote snapshot", "global snapshot", "snapshot match", "observability", "observe MF", "debug MF loading", "trace loading", "loading report", "telemetry", "onReport", "onEvent", "production report", "upload observability" | `reference/observability.md` | +| "manifest", "remoteEntry URL", "module info", "publicPath" | `reference/module-info.md` | +| "context", "what is configured", "MF role", "bundler" | `reference/context.md` | + +If still ambiguous, show the user the sub-command table above and ask them to pick. + +## Step 2: Load and execute the reference + +Read the matched file from the `reference/` directory (same directory as this SKILL.md). + +Execute all instructions in that file, passing the remaining arguments (everything after the sub-command token, or the full `$ARGUMENTS` if intent-detected) as `ARGS`. diff --git a/apps/runtime-demo/skills/mf/reference/bridge-check.md b/apps/runtime-demo/skills/mf/reference/bridge-check.md new file mode 100644 index 00000000000..915e683f86f --- /dev/null +++ b/apps/runtime-demo/skills/mf/reference/bridge-check.md @@ -0,0 +1,29 @@ +# Sub-skill: bridge-check + +Check Module Federation Bridge usage: verify that producers correctly export `export-app`, and that consumers use the recommended Bridge API. + +## Step 1: Collect MFContext + +Read and follow the instructions in `./context.md`, passing ARGS as the project root. + +## Step 2: Run bridge check script + +Serialize MFContext to JSON and pass it to the check script: + +```bash +node scripts/bridge-check.js --context '' +``` + +Process each item in the output `results` and `context.mfConfig`: + +**BRIDGE-USAGE · info — No export-app export found** +- No key matching the `export-app` pattern found in `exposes` +- If this project is a sub-app that should follow the Bridge spec, guide the user to: + 1. Add `"./export-app": "./src/export-app.tsx"` to `exposes` + 2. The exported module must return an object conforming to the Bridge spec (containing `render` and `destroy` methods) + +**BRIDGE-USAGE · info — Consumer API recommendation** +- Advise consumers to use official Bridge APIs such as `createRemoteAppComponent` +- Avoid directly concatenating remote URLs or manually calling `loadRemote` + +If `context.mfRole` is `host` (no exposes), skip the producer-side check and only provide consumer-side recommendations. diff --git a/apps/runtime-demo/skills/mf/reference/browser-debug/long-chain.md b/apps/runtime-demo/skills/mf/reference/browser-debug/long-chain.md new file mode 100644 index 00000000000..c6a78cae92e --- /dev/null +++ b/apps/runtime-demo/skills/mf/reference/browser-debug/long-chain.md @@ -0,0 +1,72 @@ +# Long-Chain Capture + +Keep a tab alive across multiple steps — navigate, click through interactions, then capture. + +## Usage + +```bash +# Step 1 — open tab, keep it alive +TAB=$(node ../scripts/browser-capture.mjs "https://example.com" --keep-tab | jq -r .tabId) + +# Step 2 — click through the interaction chain (faster: domcontentloaded/none) +node ../scripts/browser-capture.mjs --tab-id "$TAB" --click "Profile" --action-wait domcontentloaded +node ../scripts/browser-capture.mjs --tab-id "$TAB" --click "Favorites" --action-wait none + +# Step 3 — final action, capture variables, close tab +node ../scripts/browser-capture.mjs --tab-id "$TAB" --click "Add" --vars __FEDERATION__ --action-wait networkidle --close +``` + +## Flags + +| Flag | Description | +|---|---| +| `--keep-tab` | Don't close tab after capture; outputs `tabId` in result | +| `--tab-id ` | Attach to existing tab instead of navigating | +| `--click ""` | Click an element; matching prefers CSS/interactive elements first | +| `--fill "placeholder::text"` | Type into an input/textarea located by placeholder | +| `--select "placeholder::value"` | Choose an option in a select located by placeholder | +| `--action-wait ` | Wait strategy after click/fill/select (`auto` is default; use `networkidle` on the final step when strict consistency is needed) | +| `--no-entries` | Exclude entries logs to speed up capture and reduce output size | +| `--dump-dom` | Output page DOM structure (for identifying selectors) | +| `--close` | Close the tab after this step | + +## Click matching + +Applied in order: +1. If query starts with `#`, `.`, `[`, or contains `>` → CSS selector +2. Strong interactive elements (`button`, `a`, role/button/tab/menuitem/option, submit/button inputs) +3. Weak interactive elements (`div`/`span`/`li`) only when they look clickable (`cursor:pointer`, `onclick`, or focusable tabindex) +4. Text match priority inside each layer: **exact** → **prefix** → **contains** + +## Fill (input/textarea) + +Locates the field by `placeholder` attribute, injects text using native value setter — compatible with React and Vue controlled inputs. + +```bash +node ../scripts/browser-capture.mjs --tab-id "$TAB" --fill "Enter keyword::Module Federation" +``` + +## Select (dropdown) + +Locates by `placeholder` attribute or default option text, then: +- **Native `, otherwise click the custom dropdown trigger + const r1 = await session.send('Runtime.evaluate', { + expression: `(function(ph, val) { + // native