-
Notifications
You must be signed in to change notification settings - Fork 377
(feat) Federate most of the Module Federation runtime #1853
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
887192f
ffc5a44
d597148
c87551b
a20fca3
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,33 @@ | ||
| --- | ||
| '@openmrs/rspack-config': major | ||
| '@openmrs/webpack-config': major | ||
| '@openmrs/esm-app-shell': major | ||
| 'openmrs': major | ||
| --- | ||
|
|
||
| (feat) O3-5873: Ship the Module Federation runtime once, from the app shell | ||
|
|
||
| Remote entries no longer embed their own copy of `@module-federation/runtime-core`; the app shell | ||
| publishes it on a global and remotes read it from there, so a page downloads it once rather than once per | ||
| app. Measured on `@openmrs/esm-login-app`, a production remote entry drops from ~85 kB to ~32 kB raw | ||
| (~26 kB to ~9.7 kB gzipped); the remainder is per-build federation glue plus the ~14 kB of runtime that | ||
| has to stay per remote. | ||
|
|
||
| Both shared configs now build with `@module-federation/enhanced` rather than the bundlers' built-in | ||
| Module Federation plugins, which also brings the webpack path onto the Module Federation 2.0 runtime. | ||
| Webpack-built remote entries grow as a result — ~8.7 kB to ~23 kB raw on the same app — because they | ||
| previously shipped a classic container with no runtime at all. Neither config emits an `mf-manifest.json` | ||
| or federated type declarations, as before. | ||
|
|
||
| **This is a breaking change for apps and app shells upgraded separately.** An app built with this version | ||
| of `@openmrs/rspack-config`, `@openmrs/webpack-config` or the `openmrs` CLI requires an app shell of this | ||
| version or newer, and refuses to start under an older one with an error in the browser console naming | ||
| itself. Apps built with older tooling keep working in the new app shell, so a distribution can mix old | ||
| apps with a new app shell but not the reverse. An app and its app shell should also be built against the | ||
| same Module Federation minor; a mismatch logs a warning rather than failing the load, because minor skew | ||
| usually works and a changed runtime helper only sometimes breaks shared-dependency de-duplication. There | ||
| is deliberately no machine-readable compatibility field yet — the app shell's own version is the contract. | ||
|
|
||
| One consequence worth knowing: because every remote now shares the app shell's `runtime-core`, that | ||
| build's identifier and its `experiments.optimization` settings apply to every app's federation instance. | ||
| The app shell therefore sets only `target: 'web'`, which is true of every app in a distribution. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| --- | ||
| "@openmrs/esm-app-shell": major | ||
| "@openmrs/rspack-config": major | ||
| "@openmrs/webpack-config": major | ||
| --- | ||
|
|
||
| (feat) Federate most of the Module Federation runtime | ||
|
ibacher marked this conversation as resolved.
|
||
|
|
||
| Apps built with these versions of `@openmrs/rspack-config` or `@openmrs/webpack-config` require an app shell that provides the Module Federation runtime. Deploy the app shell first. Rolling the shell back to a pre-change version while those apps remain deployed will prevent them from starting. The new shell remains compatible with apps built using older tooling. | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,54 @@ | ||
| // The names published here are read by remote entries built separately from the app shell, so nothing | ||
| // else in this build would notice a rename or the call in `index.ts` being dropped. | ||
| import { readFileSync } from 'node:fs'; | ||
| import { resolve } from 'node:path'; | ||
| import { describe, expect, it } from 'vitest'; | ||
|
|
||
| const shellRoot = resolve(__dirname, '..'); | ||
|
|
||
| describe('the Module Federation runtime the app shell publishes for remotes', () => { | ||
| it('publishes the runtime helpers under the names remote entries read', async () => { | ||
| expect(globalThis._OPENMRS_FEDERATION_SDK).toBeUndefined(); | ||
| expect(globalThis._OPENMRS_FEDERATION_ERROR_CODES).toBeUndefined(); | ||
|
|
||
| const { publishFederationRuntime } = await import('./federation-runtime'); | ||
| publishFederationRuntime(); | ||
|
|
||
| // Indexed by string so a rename in the module can't rename the assertion along with it. | ||
| expect(Object.keys(globalThis['_OPENMRS_FEDERATION_SDK'] ?? {}).length).toBeGreaterThan(0); | ||
| expect(Object.keys(globalThis['_OPENMRS_FEDERATION_ERROR_CODES'] ?? {}).length).toBeGreaterThan(0); | ||
| }); | ||
|
|
||
| it('is configured to publish the runtime that remote entries expect', () => { | ||
| // Dropping `provideExternalRuntime` — or giving the app shell `exposes`, which makes Module Federation | ||
| // reject it — breaks every app in the distribution. | ||
| const originalCwd = process.cwd(); | ||
| process.chdir(shellRoot); | ||
|
|
||
| try { | ||
| // eslint-disable-next-line @typescript-eslint/no-require-imports | ||
| const config = require(resolve(shellRoot, 'rspack.config.js'))({}, { mode: 'production' }); | ||
| const federationOptions = config.plugins.find( | ||
| (plugin: { constructor?: { name?: string } }) => plugin?.constructor?.name === 'ModuleFederationPlugin', | ||
| )?._options; | ||
|
|
||
| expect(federationOptions?.experiments?.provideExternalRuntime).toBe(true); | ||
| expect(federationOptions?.exposes).toBeUndefined(); | ||
| } finally { | ||
| process.chdir(originalCwd); | ||
| } | ||
| }, 60_000); | ||
|
|
||
| it('is called by the app shell entry, before anything that can load a remote', () => { | ||
| const statements = readFileSync(resolve(__dirname, 'index.ts'), 'utf8') | ||
| .split('\n') | ||
| .map((line) => line.trim()) | ||
| .filter((line) => line.length > 0 && !line.startsWith('//') && !line.startsWith('*') && !line.startsWith('/*')); | ||
|
|
||
| // Remotes are only loaded from code reached via `initializeSpa`, so anywhere ahead of the first | ||
| // function declaration is early enough. | ||
| const callIndex = statements.indexOf('publishFederationRuntime();'); | ||
| expect(callIndex).toBeGreaterThan(-1); | ||
| expect(callIndex).toBeLessThan(statements.findIndex((line) => line.startsWith('function '))); | ||
| }); | ||
| }); |
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is now the "federation runtime". Note, though, that we're basically just creating global variables to hold it's state. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,42 @@ | ||
| /** | ||
| * Remote entries built by `@openmrs/rspack-config` or `@openmrs/webpack-config` read the Module | ||
| * Federation runtime from globals instead of embedding a copy each, so the app shell has to publish it. | ||
| * | ||
| * `@module-federation/runtime-core` is published by Module Federation itself, via the runtime plugin | ||
| * that `experiments.provideExternalRuntime` injects. Because it is this build's copy, its compiled-in | ||
| * build identifier and `experiments.optimization` settings are the ones every app's federation instance | ||
| * ends up with. The two helpers below are published here because that doesn't cover them; they hold no | ||
| * cross-build state. `@module-federation/runtime` and `webpack-bundler-runtime` are deliberately not | ||
| * published — they cache a federation instance at module scope, so a remote using the app shell's copy | ||
| * dies with RUNTIME-010. | ||
| */ | ||
| import * as errorCodes from '@module-federation/error-codes'; | ||
| import * as sdk from '@module-federation/sdk'; | ||
|
|
||
| declare global { | ||
| // `var` is what declares a property on `globalThis`; `let`/`const` do not. | ||
|
|
||
| var _OPENMRS_FEDERATION_ERROR_CODES: typeof errorCodes; | ||
|
|
||
| var _OPENMRS_FEDERATION_SDK: typeof sdk; | ||
|
|
||
| var _FEDERATION_RUNTIME_CORE: unknown; | ||
| } | ||
|
|
||
| /** | ||
| * A function rather than a module side effect so the call can't be tree-shaken away if the app shell | ||
| * ever declares `sideEffects: false`. | ||
| */ | ||
| export function publishFederationRuntime() { | ||
| globalThis._OPENMRS_FEDERATION_ERROR_CODES = errorCodes; | ||
| globalThis._OPENMRS_FEDERATION_SDK = sdk; | ||
|
|
||
| // Reporting this here means a mis-built app shell says so itself, rather than every app in the | ||
| // distribution claiming the app shell is too old. | ||
| if (typeof globalThis._FEDERATION_RUNTIME_CORE === 'undefined') { | ||
| console.error( | ||
| 'The app shell did not publish the Module Federation runtime, so no frontend module will be able ' + | ||
| 'to start. This is a bug in the app shell build (see `experiments.provideExternalRuntime`).', | ||
| ); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,33 @@ | ||
| { | ||
| "extends": ["//"], | ||
| "tasks": { | ||
| "test": { | ||
| "inputs": [ | ||
| "src/**", | ||
| "__mocks__/**", | ||
| "mock.ts", | ||
| "mock-jest.ts", | ||
| "mock-jest.tsx", | ||
| "vitest.config.ts", | ||
| "setup-tests.ts", | ||
| "rspack.config.js", | ||
| "dependencies.json", | ||
| "tools/**" | ||
| ] | ||
| }, | ||
| "coverage": { | ||
| "inputs": [ | ||
| "src/**", | ||
| "__mocks__/**", | ||
| "mock.ts", | ||
| "mock-jest.ts", | ||
| "mock-jest.tsx", | ||
| "vitest.config.ts", | ||
| "setup-tests.ts", | ||
| "rspack.config.js", | ||
| "dependencies.json", | ||
| "tools/**" | ||
| ] | ||
| } | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,13 @@ | ||
| { | ||
| "name": "@openmrs/esm-fixture-app", | ||
| "version": "1.0.0", | ||
| "private": true, | ||
| "browser": "dist/openmrs-esm-fixture-app.js", | ||
| "main": "src/index.ts", | ||
| "types": "src/index.ts", | ||
| "peerDependencies": { | ||
| "react": "18.x", | ||
| "react-dom": "18.x", | ||
| "swr": "2.x" | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| // The smallest thing that still produces a Module Federation remote entry: the app shell only requires | ||
| // that a remote expose `./start`. | ||
| export function startupApp() { | ||
| return 'started'; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| { | ||
| "$schema": "https://json.openmrs.org/routes.schema.json", | ||
| "pages": [], | ||
| "extensions": [] | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,10 @@ | ||
| { | ||
| "compilerOptions": { | ||
| "module": "esnext", | ||
| "moduleResolution": "bundler", | ||
| "target": "es2022", | ||
| "skipLibCheck": true, | ||
| "noEmit": true | ||
| }, | ||
| "include": ["src/**/*"] | ||
| } |
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Integration tests added (but not published) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,38 @@ | ||
| { | ||
| "name": "@openmrs/integration-tests", | ||
| "version": "10.0.0", | ||
| "private": true, | ||
| "license": "MPL-2.0", | ||
| "description": "Tests that span more than one OpenMRS package and so belong to none of them. Not published; nothing depends on this package, which keeps its fixtures out of every other package's Turborepo cache key.", | ||
| "engines": { | ||
| "node": ">= 20.0.0" | ||
| }, | ||
| "scripts": { | ||
| "test": "cross-env TZ=UTC vitest run", | ||
| "test:watch": "cross-env TZ=UTC vitest watch", | ||
| "coverage": "cross-env TZ=UTC vitest run --coverage", | ||
| "lint": "eslint src --ext ts,tsx", | ||
| "typescript": "tsc --noEmit" | ||
| }, | ||
| "repository": { | ||
| "type": "git", | ||
| "url": "git+https://github.com/openmrs/openmrs-esm-core.git" | ||
| }, | ||
| "bugs": { | ||
| "url": "https://github.com/openmrs/openmrs-esm-core/issues" | ||
| }, | ||
| "devDependencies": { | ||
| "@module-federation/error-codes": "2.8.1", | ||
| "@module-federation/runtime-core": "2.8.1", | ||
| "@module-federation/sdk": "2.8.1", | ||
| "@openmrs/rspack-config": "workspace:*", | ||
| "@openmrs/webpack-config": "workspace:*", | ||
| "@rspack/core": "1.7.9", | ||
| "@types/semver": "^7.7.3", | ||
| "cross-env": "^10.1.0", | ||
| "semver": "^7.7.3", | ||
| "typescript": "^5.8.3", | ||
| "vitest": "^4.1.2", | ||
| "webpack": "5.105.3" | ||
| } | ||
| } |
Uh oh!
There was an error while loading. Please reload this page.