Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions .changeset/mf-runtime-shipped-once.md
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.
9 changes: 9 additions & 0 deletions .changeset/wacky-pens-refuse.md
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
Comment thread
ibacher marked this conversation as resolved.
---

(feat) Federate most of the Module Federation runtime
Comment thread
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.
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@
"scripts": {
"run:omrs": "openmrs",
"run:shell": "yarn workspace @openmrs/esm-app-shell watch",
"ci:publish-next": "yarn workspaces foreach --all --topological --exclude @openmrs/esm-core --exclude @openmrs/storybook npm publish --tolerate-republish --access public --tag next",
"ci:publish": "yarn workspaces foreach --all --topological --exclude @openmrs/esm-core --exclude @openmrs/storybook npm publish --tolerate-republish --access public --tag latest",
"ci:publish-next": "yarn workspaces foreach --all --topological --exclude @openmrs/esm-core --exclude @openmrs/storybook --exclude @openmrs/integration-tests npm publish --tolerate-republish --access public --tag next",
"ci:publish": "yarn workspaces foreach --all --topological --exclude @openmrs/esm-core --exclude @openmrs/storybook --exclude @openmrs/integration-tests npm publish --tolerate-republish --access public --tag latest",
"release": "yarn workspaces foreach --all --topological version",
"build": "turbo run build",
"build:apps": "turbo run build --filter='@openmrs/*-app'",
Expand Down
3 changes: 3 additions & 0 deletions packages/shell/esm-app-shell/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@
"dependencies": {
"@carbon/react": "^1.92.1",
"@internationalized/date": "^3.8.0",
"@module-federation/enhanced": "2.8.1",
"@module-federation/error-codes": "2.8.1",
"@module-federation/sdk": "2.8.1",
"@openmrs/esm-framework": "workspace:*",
"@openmrs/esm-styleguide": "workspace:*",
"@rspack/cli": "1.7.9",
Expand Down
17 changes: 15 additions & 2 deletions packages/shell/esm-app-shell/rspack.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@ const {
CssExtractRspackPlugin,
CopyRspackPlugin,
DefinePlugin,
container,
util: { createHash },
} = require('@rspack/core');
const { ModuleFederationPlugin } = require('@module-federation/enhanced/rspack');
const CleanWebpackPlugin = require('clean-webpack-plugin').CleanWebpackPlugin;
const HtmlWebpackPlugin = require('html-webpack-plugin');
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
Expand All @@ -22,7 +22,6 @@ const frameworkVersion = require('@openmrs/esm-framework/package.json').version;
const timestamp = getTimestamp();
const production = 'production';
const allowedSuffixes = ['-app', '-widgets'];
const { ModuleFederationPlugin } = container;

const openmrsAddCookie = process.env.OMRS_ADD_COOKIE;
const openmrsApiUrl = removeTrailingSlash(process.env.OMRS_API_URL || '/openmrs');
Expand Down Expand Up @@ -462,6 +461,20 @@ module.exports = (env, argv = []) => {
}),
new ModuleFederationPlugin({
name,
// The app shell is the only build that ships `@module-federation/runtime-core`:
// `provideExternalRuntime` publishes it as `_FEDERATION_RUNTIME_CORE` for remotes to read, and
// Module Federation only allows it on a build with no `exposes`. `src/federation-runtime.ts`
// publishes the runtime's stateless helpers alongside it.
experiments: {
provideExternalRuntime: true,
// Every app shares this build's `runtime-core`, so anything pruned here is pruned for all of
// them. `target` is safe on those terms — nothing in a distribution runs the runtime in Node,
// and it saves ~5 kB gzipped. `disableSnapshot` is deliberately not set: it would leave every
// app's federation instance with an empty plugin array, and it saved 16 gzipped bytes.
optimization: { target: 'web' },
},
manifest: false,
dts: false,
shared: sharedDependencies.reduce((obj, depName) => {
// This just attempts to align the requiredVersion with what we usually have in peerDependencies
let version = dependencies[depName];
Expand Down
54 changes: 54 additions & 0 deletions packages/shell/esm-app-shell/src/federation-runtime.test.ts
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 ')));
});
});
42 changes: 42 additions & 0 deletions packages/shell/esm-app-shell/src/federation-runtime.ts

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The 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`).',
);
}
}
5 changes: 5 additions & 0 deletions packages/shell/esm-app-shell/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import { publishFederationRuntime } from './federation-runtime';
import type { SpaConfig } from '@openmrs/esm-framework/src/internal';

// Before anything else: every frontend module reads the Module Federation runtime from the globals
// this publishes, and modules are only ever loaded from code reached via `initializeSpa` below.
publishFederationRuntime();

function _createSpaBase(baseUrl: string) {
return () => baseUrl;
}
Expand Down
33 changes: 33 additions & 0 deletions packages/shell/esm-app-shell/turbo.json
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/**/*"]
}
38 changes: 38 additions & 0 deletions packages/tooling/integration-tests/package.json

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The 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"
}
}
Loading
Loading