@mfeflow/runtime 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Sanjeev Sharma
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,44 @@
1
- # Temporary Holding Version
1
+ # @mfeflow/runtime
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Runtime for mfeflow shells. It reads the remote registry index and registers each remote with Module Federation, renders remotes inside `RemoteBoundary` (timeout, fallback, error reporting), checks that singletons are really shared, and composes the shell's providers.
4
+
5
+ ## Install
6
+
7
+ ```sh
8
+ pnpm add @mfeflow/runtime @module-federation/enhanced react
9
+ ```
10
+
11
+ `@module-federation/enhanced` (^2.9.0) and `react` (^19) are peer dependencies.
12
+
13
+ ## Usage
14
+
15
+ ```tsx
16
+ import {
17
+ RemoteBoundary,
18
+ loadRegistryIndex,
19
+ registerRemotesFromIndex,
20
+ reportRemoteError,
21
+ } from '@mfeflow/runtime';
22
+
23
+ try {
24
+ registerRemotesFromIndex(await loadRegistryIndex(), { allowedOrigins: [location.origin] });
25
+ } catch (error) {
26
+ reportRemoteError('shell', 'registry', error);
27
+ }
28
+
29
+ // A slow, missing or crashing remote shows its fallback; the rest of the page keeps working.
30
+ <RemoteBoundary id="cart/App" timeoutMs={5000} loading={<p>Loading</p>} />;
31
+ ```
32
+
33
+ `composeProviders([...])` nests providers (first is outermost) and `withProps` binds props to one. `checkSingletons` and `assertRequested` back the `?mfeflow-assert` check. Optional plugins are at `@mfeflow/runtime/plugins/report` and `@mfeflow/runtime/plugins/entry-retry`.
34
+
35
+ ## Module Federation sharing
36
+
37
+ Bucket: **singleton**. This package keeps page-wide state, so the shell and every remote must use one copy. `@mfeflow/rsbuild` shares it as a Module Federation singleton by default (`createShellConfig` and `createRemoteConfig`); if you configure sharing by hand, mark it `singleton: true`.
38
+
39
+ ## Links
40
+
41
+ - [mfeflow repository](https://github.com/mfeflow/mfeflow) and its README
42
+ - [Documentation](https://mfeflow.github.io/mfeflow/)
43
+
44
+ MIT licensed.
@@ -0,0 +1,8 @@
1
+ export { composeProviders, withProps, type ProviderComponent } from './providers.js';
2
+ export { REMOTE_ERROR_EVENT, reportRemoteError, type RemoteErrorReport } from './report.js';
3
+ export { DEFAULT_REGISTRY_URL, isRegistryIndex, loadRegistryIndex, REGISTRY_GLOBAL, registeredRemotes, registerRemotesFromIndex, type LoadRegistryOptions, type RegisterOptions, type RegistryIndex, type RegistryRemote, } from './registry.js';
4
+ export { DefaultRemoteFallback, RemoteBoundary, remoteComponent, type FallbackProps, type RemoteBoundaryProps, type RemoteOptions, } from './remote.js';
5
+ export { ASSERT_PARAM, assertRequested, checkSingletons, type SingletonCheck } from './singletons.js';
6
+ export { applyDevOverrides, OVERRIDE_PARAM, OVERRIDE_STORAGE_KEY, type DevOverrideOptions, } from './overrides.js';
7
+ export { DevOverlay, type DevOverlayProps } from './overlay.js';
8
+ export { fetchRuntimeConfig, RUNTIME_CONFIG_KEY, RUNTIME_CONFIG_URL, type RuntimeConfigValues, } from './runtime-config.js';
package/dist/index.js ADDED
@@ -0,0 +1,8 @@
1
+ export { composeProviders, withProps } from "./providers.js";
2
+ export { REMOTE_ERROR_EVENT, reportRemoteError } from "./report.js";
3
+ export { DEFAULT_REGISTRY_URL, REGISTRY_GLOBAL, isRegistryIndex, loadRegistryIndex, registerRemotesFromIndex, registeredRemotes } from "./registry.js";
4
+ export { DefaultRemoteFallback, RemoteBoundary, remoteComponent } from "./remote.js";
5
+ export { ASSERT_PARAM, assertRequested, checkSingletons } from "./singletons.js";
6
+ export { OVERRIDE_PARAM, OVERRIDE_STORAGE_KEY, applyDevOverrides } from "./overrides.js";
7
+ export { DevOverlay } from "./overlay.js";
8
+ export { RUNTIME_CONFIG_KEY, RUNTIME_CONFIG_URL, fetchRuntimeConfig } from "./runtime-config.js";
@@ -0,0 +1,11 @@
1
+ export interface DevOverlayProps {
2
+ /** Render nothing unless true (e.g. `import.meta.env.DEV`). */
3
+ enabled: boolean;
4
+ /** Start open. Default false; toggle with Alt+Shift+M. */
5
+ open?: boolean;
6
+ }
7
+ /**
8
+ * Dev-only panel: which remotes and versions the shell registered (overrides
9
+ * marked), and every remote error as it happens. Alt+Shift+M toggles it.
10
+ */
11
+ export declare function DevOverlay({ enabled, open }: DevOverlayProps): import("react").JSX.Element | null;
@@ -0,0 +1,100 @@
1
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useState } from "react";
3
+ import { registeredRemotes } from "./registry.js";
4
+ import { REMOTE_ERROR_EVENT } from "./report.js";
5
+ const box = {
6
+ position: 'fixed',
7
+ insetBlockEnd: '12px',
8
+ insetInlineEnd: '12px',
9
+ zIndex: 2147483647,
10
+ maxWidth: '420px',
11
+ maxHeight: '60vh',
12
+ overflow: 'auto',
13
+ padding: '12px',
14
+ borderRadius: '8px',
15
+ font: '12px/1.4 ui-monospace, monospace',
16
+ background: '#111827',
17
+ color: '#e5e7eb',
18
+ boxShadow: '0 8px 24px rgba(0,0,0,.35)'
19
+ };
20
+ function DevOverlay({ enabled, open = false }) {
21
+ const [visible, setVisible] = useState(open);
22
+ const [reports, setReports] = useState(()=>globalThis.mfeflowReports ?? []);
23
+ useEffect(()=>{
24
+ if (!enabled) return;
25
+ const onKey = (e)=>{
26
+ if (e.altKey && e.shiftKey && 'KeyM' === e.code) setVisible((v)=>!v);
27
+ };
28
+ const onReport = ()=>{
29
+ setReports([
30
+ ...globalThis.mfeflowReports ?? []
31
+ ]);
32
+ };
33
+ window.addEventListener('keydown', onKey);
34
+ window.addEventListener(REMOTE_ERROR_EVENT, onReport);
35
+ return ()=>{
36
+ window.removeEventListener('keydown', onKey);
37
+ window.removeEventListener(REMOTE_ERROR_EVENT, onReport);
38
+ };
39
+ }, [
40
+ enabled
41
+ ]);
42
+ if (!enabled || !visible) return null;
43
+ const remotes = registeredRemotes();
44
+ return /*#__PURE__*/ jsxs("aside", {
45
+ style: box,
46
+ "aria-label": "mfeflow dev overlay",
47
+ "data-mfeflow-overlay": "",
48
+ children: [
49
+ /*#__PURE__*/ jsx("strong", {
50
+ children: "mfeflow"
51
+ }),
52
+ " \xb7 ",
53
+ remotes.length,
54
+ " remotes",
55
+ /*#__PURE__*/ jsx("ul", {
56
+ style: {
57
+ margin: '8px 0',
58
+ paddingInlineStart: '16px'
59
+ },
60
+ children: remotes.map((r)=>/*#__PURE__*/ jsxs("li", {
61
+ children: [
62
+ r.name,
63
+ " ",
64
+ /*#__PURE__*/ jsx("span", {
65
+ style: {
66
+ opacity: 0.7
67
+ },
68
+ children: r.version ?? 'unversioned'
69
+ }),
70
+ 'override' === r.version ? ' (override)' : null
71
+ ]
72
+ }, r.name))
73
+ }),
74
+ reports.length > 0 ? /*#__PURE__*/ jsxs(Fragment, {
75
+ children: [
76
+ /*#__PURE__*/ jsx("strong", {
77
+ children: "errors"
78
+ }),
79
+ /*#__PURE__*/ jsx("ul", {
80
+ style: {
81
+ margin: '8px 0',
82
+ paddingInlineStart: '16px',
83
+ color: '#fca5a5'
84
+ },
85
+ children: reports.map((r)=>/*#__PURE__*/ jsxs("li", {
86
+ children: [
87
+ r.mfe,
88
+ " \xb7 ",
89
+ r.where,
90
+ ": ",
91
+ r.message
92
+ ]
93
+ }, `${String(r.time)}-${r.mfe}-${r.where}`))
94
+ })
95
+ ]
96
+ }) : null
97
+ ]
98
+ });
99
+ }
100
+ export { DevOverlay };
@@ -0,0 +1,22 @@
1
+ import type { RegistryIndex } from './registry.js';
2
+ export interface DevOverrideOptions {
3
+ /** Must be explicitly true, e.g. `import.meta.env.DEV` or a staging flag. Never on in production. */
4
+ enabled: boolean;
5
+ /** Hostnames overrides may point at. Default `localhost` and `127.0.0.1` (any port). */
6
+ allowedHosts?: readonly string[];
7
+ /** Defaults to `location.search`. */
8
+ search?: string;
9
+ /** Where overrides persist across navigations. Defaults to `sessionStorage`. */
10
+ storage?: Pick<Storage, 'getItem' | 'setItem' | 'removeItem'>;
11
+ }
12
+ export declare const OVERRIDE_PARAM = "mfe-override";
13
+ export declare const OVERRIDE_STORAGE_KEY = "shell:mfe-overrides";
14
+ /**
15
+ * Points chosen remotes at local or preview builds without touching the
16
+ * registry: `?mfe-override=cart@http://localhost:4312/mf-manifest.json`
17
+ * (comma-separate several). Overrides persist for the session;
18
+ * `?mfe-override=reset` clears them. Only allowlisted hosts are accepted.
19
+ * Needs the manifest loader, so it works in development builds only; when
20
+ * disabled and the parameter is present it reports that it was ignored.
21
+ */
22
+ export declare function applyDevOverrides(index: RegistryIndex, options: DevOverrideOptions): RegistryIndex;
@@ -0,0 +1,76 @@
1
+ import { reportRemoteError } from "./report.js";
2
+ const OVERRIDE_PARAM = 'mfe-override';
3
+ const OVERRIDE_STORAGE_KEY = 'shell:mfe-overrides';
4
+ function parse(value) {
5
+ const out = {};
6
+ for (const part of value.split(',')){
7
+ const at = part.indexOf('@');
8
+ if (!(at <= 0)) out[part.slice(0, at).trim()] = part.slice(at + 1).trim();
9
+ }
10
+ return out;
11
+ }
12
+ function isRecordOfStrings(value) {
13
+ return 'object' == typeof value && null !== value && !Array.isArray(value) && Object.values(value).every((v)=>'string' == typeof v);
14
+ }
15
+ function applyDevOverrides(index, options) {
16
+ const search = options.search ?? ("u" < typeof location ? '' : location.search);
17
+ if (!options.enabled) {
18
+ if (new URLSearchParams(search).has(OVERRIDE_PARAM)) reportRemoteError('shell', 'override', new Error('overrides are disabled in this build'));
19
+ return index;
20
+ }
21
+ const storage = options.storage ?? ("u" < typeof sessionStorage ? void 0 : sessionStorage);
22
+ const hosts = options.allowedHosts ?? [
23
+ 'localhost',
24
+ '127.0.0.1'
25
+ ];
26
+ const param = new URLSearchParams(search).get(OVERRIDE_PARAM);
27
+ let overrides = {};
28
+ if ('reset' === param) storage?.removeItem(OVERRIDE_STORAGE_KEY);
29
+ else {
30
+ try {
31
+ const saved = JSON.parse(storage?.getItem(OVERRIDE_STORAGE_KEY) ?? '{}');
32
+ if (isRecordOfStrings(saved)) overrides = saved;
33
+ } catch {}
34
+ if (param) overrides = {
35
+ ...overrides,
36
+ ...parse(param)
37
+ };
38
+ }
39
+ const accepted = {};
40
+ for (const [name, entry] of Object.entries(overrides)){
41
+ let host;
42
+ try {
43
+ host = new URL(entry).hostname;
44
+ } catch {
45
+ reportRemoteError(name, 'override', new Error(`invalid override URL "${entry}"`));
46
+ continue;
47
+ }
48
+ if (!hosts.includes(host)) {
49
+ reportRemoteError(name, 'override', new Error(`override host ${host} is not allowed`));
50
+ continue;
51
+ }
52
+ accepted[name] = entry;
53
+ }
54
+ if (Object.keys(accepted).length > 0) storage?.setItem(OVERRIDE_STORAGE_KEY, JSON.stringify(accepted));
55
+ const known = new Set(index.remotes.map((r)=>r.name));
56
+ const remotes = index.remotes.map((r)=>{
57
+ const entry = accepted[r.name];
58
+ if (void 0 === entry) return r;
59
+ const { remoteEntry: _replaced, ...rest } = r;
60
+ return {
61
+ ...rest,
62
+ entry,
63
+ version: 'override'
64
+ };
65
+ });
66
+ for (const [name, entry] of Object.entries(accepted))if (!known.has(name)) remotes.push({
67
+ name,
68
+ entry,
69
+ version: 'override'
70
+ });
71
+ return {
72
+ ...index,
73
+ remotes
74
+ };
75
+ }
76
+ export { OVERRIDE_PARAM, OVERRIDE_STORAGE_KEY, applyDevOverrides };
@@ -0,0 +1,10 @@
1
+ import type { ModuleFederationRuntimePlugin } from '@module-federation/enhanced/runtime';
2
+ export interface EntryRetryOptions {
3
+ /** Retries after the first failure. Default 2. */
4
+ times?: number;
5
+ /** Wait before retry n is `delayMs * n`. Default 300. */
6
+ delayMs?: number;
7
+ }
8
+ /** Retries a failed remote entry with a growing delay and a `?retry=n` cache-buster. */
9
+ export declare function createEntryRetryPlugin(options?: EntryRetryOptions): ModuleFederationRuntimePlugin;
10
+ export default function mfeflowEntryRetryPlugin(): ModuleFederationRuntimePlugin;
@@ -0,0 +1,64 @@
1
+ const sleep = (ms)=>new Promise((resolve)=>{
2
+ setTimeout(resolve, ms);
3
+ });
4
+ function createEntryRetryPlugin(options = {}) {
5
+ const times = options.times ?? 2;
6
+ const delayMs = options.delayMs ?? 300;
7
+ const inFlight = new Map();
8
+ async function attempts({ getRemoteEntry, origin, remoteInfo, remoteEntryExports, globalLoading, uniqueKey }, joinable) {
9
+ let failed = globalLoading[uniqueKey];
10
+ try {
11
+ for(let attempt = 1; attempt <= times; attempt++){
12
+ if (globalLoading[uniqueKey] === failed || void 0 === globalLoading[uniqueKey]) {
13
+ globalLoading[uniqueKey] = joinable;
14
+ failed = joinable;
15
+ }
16
+ await sleep(delayMs * attempt);
17
+ if (globalLoading[uniqueKey] === failed) delete globalLoading[uniqueKey];
18
+ try {
19
+ const exports = await getRemoteEntry({
20
+ origin,
21
+ remoteInfo,
22
+ remoteEntryExports,
23
+ getEntryUrl: (url)=>`${url}${url.includes('?') ? '&' : '?'}retry=${String(attempt)}`
24
+ });
25
+ if (exports) return exports;
26
+ } catch {}
27
+ failed = globalLoading[uniqueKey];
28
+ }
29
+ return;
30
+ } finally{
31
+ if (globalLoading[uniqueKey] === joinable) delete globalLoading[uniqueKey];
32
+ }
33
+ }
34
+ function retry(args) {
35
+ const running = inFlight.get(args.uniqueKey);
36
+ if (running) return running;
37
+ let settle;
38
+ const settled = new Promise((resolve)=>{
39
+ settle = resolve;
40
+ });
41
+ const joinable = settled.then((exports)=>{
42
+ if (!exports) throw new Error(`entry of "${args.remoteInfo.name}" failed to load after retries`);
43
+ return exports;
44
+ });
45
+ joinable.catch(()=>void 0);
46
+ const started = attempts(args, joinable).finally(()=>{
47
+ inFlight.delete(args.uniqueKey);
48
+ }).then((exports)=>{
49
+ settle(exports);
50
+ return exports;
51
+ });
52
+ inFlight.set(args.uniqueKey, started);
53
+ return started;
54
+ }
55
+ return {
56
+ name: 'mfeflow-entry-retry',
57
+ loadEntryError: retry
58
+ };
59
+ }
60
+ function mfeflowEntryRetryPlugin() {
61
+ return createEntryRetryPlugin();
62
+ }
63
+ export default mfeflowEntryRetryPlugin;
64
+ export { createEntryRetryPlugin };
@@ -0,0 +1,11 @@
1
+ import type { ModuleFederationRuntimePlugin } from '@module-federation/enhanced/runtime';
2
+ declare global {
3
+ var mfeflowReports: {
4
+ mfe: string;
5
+ where: string;
6
+ message: string;
7
+ time: number;
8
+ }[] | undefined;
9
+ }
10
+ /** Reports every Module Federation load failure with the remote's name. */
11
+ export default function mfeflowReportPlugin(): ModuleFederationRuntimePlugin;
@@ -0,0 +1,20 @@
1
+ const EVENT = 'mfeflow:remote-error';
2
+ function mfeflowReportPlugin() {
3
+ return {
4
+ name: 'mfeflow-report',
5
+ errorLoadRemote (args) {
6
+ const mfe = args.id.split('/')[0] ?? args.id;
7
+ const report = {
8
+ mfe,
9
+ where: `errorLoadRemote:${args.lifecycle}`,
10
+ message: args.error instanceof Error ? args.error.message : String(args.error),
11
+ time: Date.now()
12
+ };
13
+ (globalThis.mfeflowReports ??= []).push(report);
14
+ if ("u" > typeof window && 'function' == typeof CustomEvent) window.dispatchEvent(new CustomEvent(EVENT, {
15
+ detail: report
16
+ }));
17
+ }
18
+ };
19
+ }
20
+ export default mfeflowReportPlugin;
@@ -0,0 +1,13 @@
1
+ import type { ComponentType, ReactNode } from 'react';
2
+ export type ProviderComponent = ComponentType<{
3
+ children?: ReactNode;
4
+ }>;
5
+ /** Binds props to a provider so it fits in a `composeProviders` list. */
6
+ export declare function withProps<P extends object>(Component: ComponentType<P & {
7
+ children?: ReactNode;
8
+ }>, props: P): ProviderComponent;
9
+ /**
10
+ * Nests providers so the first one in the list is the outermost. The shell and
11
+ * `renderWithPlatform` in tests use the same list, so tests see the real platform.
12
+ */
13
+ export declare function composeProviders(providers: readonly ProviderComponent[]): ProviderComponent;
@@ -0,0 +1,26 @@
1
+ import { jsx } from "react/jsx-runtime";
2
+ function withProps(Component, props) {
3
+ function WithProps({ children }) {
4
+ return /*#__PURE__*/ jsx(Component, {
5
+ ...props,
6
+ children: children
7
+ });
8
+ }
9
+ WithProps.displayName = `withProps(${Component.displayName ?? Component.name})`;
10
+ return WithProps;
11
+ }
12
+ function composeProviders(providers) {
13
+ function ComposedProviders({ children }) {
14
+ let tree = children;
15
+ for(let i = providers.length - 1; i >= 0; i--){
16
+ const Provider = providers[i];
17
+ if (Provider) tree = /*#__PURE__*/ jsx(Provider, {
18
+ children: tree
19
+ });
20
+ }
21
+ return tree;
22
+ }
23
+ ComposedProviders.displayName = `composeProviders(${String(providers.length)})`;
24
+ return ComposedProviders;
25
+ }
26
+ export { composeProviders, withProps };
@@ -0,0 +1,75 @@
1
+ export interface RegistryRemote {
2
+ /** Module Federation name, e.g. `cart`. */
3
+ name: string;
4
+ /** `mf-manifest.json` URL of the version to load. Used when `remoteEntry` is absent. */
5
+ entry: string;
6
+ /**
7
+ * The remote's entry script. Registered in place of the manifest in
8
+ * production, so the shell fetches no manifest and ships no snapshot code.
9
+ */
10
+ remoteEntry?: string;
11
+ /** How `remoteEntry` loads (`module` for ESM). Passed to Module Federation with it. */
12
+ type?: string;
13
+ /** The global `remoteEntry` defines when it differs from `name`. */
14
+ entryGlobalName?: string;
15
+ version?: string;
16
+ routes?: string[];
17
+ owner?: string;
18
+ }
19
+ export interface RegistryIndex {
20
+ shell?: string;
21
+ env?: string;
22
+ generatedAt?: string;
23
+ remotes: RegistryRemote[];
24
+ }
25
+ /** Reported for a registry entry that has only a manifest URL in a shell built without the manifest loader. */
26
+ export declare const NO_REMOTE_ENTRY = "MFE_NO_REMOTE_ENTRY";
27
+ /** True for a valid registry remote. */
28
+ export declare function isRegistryRemote(value: unknown): value is RegistryRemote;
29
+ /**
30
+ * True for a registry index envelope (what the shell fetches at boot). Remotes
31
+ * are checked one by one in `loadRegistryIndex`, so one bad remote does not
32
+ * take the others down.
33
+ */
34
+ export declare function isRegistryIndex(value: unknown): value is RegistryIndex;
35
+ /** Reported for a registry remote that is skipped because a field is invalid. */
36
+ export declare const INVALID_REMOTE = "MFE_INVALID_REMOTE";
37
+ export interface LoadRegistryOptions {
38
+ timeoutMs?: number;
39
+ fetch?: typeof fetch;
40
+ /** Use an index the server or edge inlined as `window.__MFE_REGISTRY__`. Default true. */
41
+ inline?: boolean;
42
+ /** Allow the fallback fetch to leave the page's origin. Default false. */
43
+ allowCrossOrigin?: boolean;
44
+ }
45
+ /**
46
+ * Global the server or CDN edge writes into the shell's HTML (see
47
+ * `registryInjection` in `@mfeflow/contracts`), so the shell registers remotes
48
+ * without another request.
49
+ */
50
+ export declare const REGISTRY_GLOBAL = "__MFE_REGISTRY__";
51
+ export declare const DEFAULT_REGISTRY_URL = "/registry/index.json";
52
+ /**
53
+ * Reads the registry index: `window.__MFE_REGISTRY__` when the edge inlined it,
54
+ * otherwise a same-origin fetch. Throws on HTTP errors, timeouts, invalid JSON
55
+ * and cross-origin URLs. A remote with an invalid field is skipped and reported
56
+ * (`MFE_INVALID_REMOTE`); the valid ones stay.
57
+ */
58
+ export declare function loadRegistryIndex(url?: string, options?: LoadRegistryOptions): Promise<RegistryIndex>;
59
+ /** Remotes registered by the last `registerRemotesFromIndex` call (for the dev overlay). */
60
+ export declare function registeredRemotes(): readonly RegistryRemote[];
61
+ export interface RegisterOptions {
62
+ /** Only remotes whose registered URL (`remoteEntry`, else `entry`) is on one of these origins are registered. */
63
+ allowedOrigins?: readonly string[];
64
+ /** Whether the shell can load a manifest URL. Default: what the shell build set. */
65
+ snapshot?: boolean;
66
+ }
67
+ /**
68
+ * Registers the index's remotes with Module Federation, by remote entry URL
69
+ * when the index has one. Call it once the shell's shared scope is set up
70
+ * (after the app entry runs, never from the build config), so Module Federation
71
+ * does not load every remote's entry while resolving shared modules.
72
+ * Remotes from origins outside `allowedOrigins` are skipped and reported.
73
+ * Returns the registered names.
74
+ */
75
+ export declare function registerRemotesFromIndex(index: RegistryIndex, options?: RegisterOptions): string[];
@@ -0,0 +1,108 @@
1
+ import { ENTRY_TYPES, GLOBAL_NAME, pickRegistryFields } from "@mfeflow/contracts/entry";
2
+ import { registerRemotes } from "@module-federation/enhanced/runtime";
3
+ import { reportRemoteError } from "./report.js";
4
+ const NO_REMOTE_ENTRY = 'MFE_NO_REMOTE_ENTRY';
5
+ function builtWithSnapshot() {
6
+ return 'boolean' == typeof __MFEFLOW_SNAPSHOT__ ? __MFEFLOW_SNAPSHOT__ : true;
7
+ }
8
+ function isStringArray(value) {
9
+ return Array.isArray(value) && value.every((v)=>'string' == typeof v);
10
+ }
11
+ function remoteProblem(value) {
12
+ if ('object' != typeof value || null === value) return 'not an object';
13
+ if (!('name' in value) || 'string' != typeof value.name || !GLOBAL_NAME.test(value.name)) return 'name';
14
+ if (!('entry' in value) || 'string' != typeof value.entry) return 'entry';
15
+ if ('remoteEntry' in value && 'string' != typeof value.remoteEntry) return 'remoteEntry';
16
+ if ('type' in value && ('string' != typeof value.type || !ENTRY_TYPES.includes(value.type))) return 'type';
17
+ if ('entryGlobalName' in value && ('string' != typeof value.entryGlobalName || !GLOBAL_NAME.test(value.entryGlobalName))) return 'entryGlobalName';
18
+ if ('version' in value && 'string' != typeof value.version) return 'version';
19
+ if ('owner' in value && 'string' != typeof value.owner) return 'owner';
20
+ if ('routes' in value && !isStringArray(value.routes)) return 'routes';
21
+ }
22
+ function isRegistryRemote(value) {
23
+ return void 0 === remoteProblem(value);
24
+ }
25
+ function isRegistryIndex(value) {
26
+ return 'object' == typeof value && null !== value && 'remotes' in value && Array.isArray(value.remotes);
27
+ }
28
+ const INVALID_REMOTE = 'MFE_INVALID_REMOTE';
29
+ function withoutInvalidRemotes(index) {
30
+ const remotes = index.remotes.filter((remote)=>{
31
+ const problem = remoteProblem(remote);
32
+ if (void 0 === problem) return true;
33
+ const name = 'string' == typeof remote?.name ? remote.name : '(unnamed)';
34
+ reportRemoteError(name, 'registry', new Error(`${INVALID_REMOTE}: "${name}" is skipped, its ${problem} is invalid`));
35
+ return false;
36
+ });
37
+ return remotes.length === index.remotes.length ? index : {
38
+ ...index,
39
+ remotes
40
+ };
41
+ }
42
+ const REGISTRY_GLOBAL = '__MFE_REGISTRY__';
43
+ const DEFAULT_REGISTRY_URL = '/registry/index.json';
44
+ function sameOrigin(url) {
45
+ if ("u" < typeof location) return true;
46
+ return new URL(url, location.href).origin === location.origin;
47
+ }
48
+ async function loadRegistryIndex(url = DEFAULT_REGISTRY_URL, options = {}) {
49
+ const inlined = Reflect.get(globalThis, REGISTRY_GLOBAL);
50
+ if (false !== options.inline && isRegistryIndex(inlined)) return withoutInvalidRemotes(inlined);
51
+ if (true !== options.allowCrossOrigin && !sameOrigin(url)) throw new Error(`registry index ${url}: must be same-origin`);
52
+ const doFetch = options.fetch ?? fetch;
53
+ const res = await doFetch(url, {
54
+ signal: AbortSignal.timeout(options.timeoutMs ?? 5000)
55
+ });
56
+ if (!res.ok) throw new Error(`registry index ${url}: HTTP ${String(res.status)}`);
57
+ const body = await res.json();
58
+ if (!isRegistryIndex(body)) throw new Error(`registry index ${url}: not a valid index`);
59
+ return withoutInvalidRemotes(body);
60
+ }
61
+ function toMfRemote(r) {
62
+ if (void 0 === r.remoteEntry) return {
63
+ name: r.name,
64
+ entry: r.entry
65
+ };
66
+ return {
67
+ name: r.name,
68
+ entry: r.remoteEntry,
69
+ ...void 0 === r.type ? {} : {
70
+ type: r.type
71
+ },
72
+ ...void 0 === r.entryGlobalName ? {} : {
73
+ entryGlobalName: r.entryGlobalName
74
+ }
75
+ };
76
+ }
77
+ let registered = [];
78
+ function registeredRemotes() {
79
+ return registered;
80
+ }
81
+ function registerRemotesFromIndex(index, options = {}) {
82
+ const allowed = options.allowedOrigins?.map((o)=>new URL(o).origin);
83
+ const snapshot = options.snapshot ?? builtWithSnapshot();
84
+ const ok = [];
85
+ for (const remote of index.remotes){
86
+ if (!snapshot && void 0 === remote.remoteEntry) {
87
+ reportRemoteError(remote.name, 'registry', new Error(`${NO_REMOTE_ENTRY}: "${remote.name}" has only a manifest URL (${remote.entry}) and this shell was built without the manifest loader; publish its remoteEntry or build the shell with snapshot: true`));
88
+ continue;
89
+ }
90
+ const url = remote.remoteEntry ?? remote.entry;
91
+ let origin;
92
+ try {
93
+ origin = new URL(url).origin;
94
+ } catch {
95
+ reportRemoteError(remote.name, 'registry', new Error(`invalid entry URL "${url}"`));
96
+ continue;
97
+ }
98
+ if (allowed && !allowed.includes(origin)) {
99
+ reportRemoteError(remote.name, 'registry', new Error(`origin ${origin} is not allowed`));
100
+ continue;
101
+ }
102
+ ok.push(remote);
103
+ }
104
+ registerRemotes(ok.map(toMfRemote));
105
+ registered = ok.map((r)=>pickRegistryFields(r));
106
+ return ok.map((r)=>r.name);
107
+ }
108
+ export { DEFAULT_REGISTRY_URL, INVALID_REMOTE, NO_REMOTE_ENTRY, REGISTRY_GLOBAL, isRegistryIndex, isRegistryRemote, loadRegistryIndex, registerRemotesFromIndex, registeredRemotes };
@@ -0,0 +1,29 @@
1
+ import { type ComponentType, type ReactNode } from 'react';
2
+ export interface FallbackProps {
3
+ /** Remote name, e.g. `cart`. */
4
+ name: string;
5
+ }
6
+ export declare function DefaultRemoteFallback({ name }: FallbackProps): import("react").JSX.Element;
7
+ export interface RemoteOptions {
8
+ /** Give up and show the fallback after this long. Module Federation has no timeout of its own. Default 5000. */
9
+ timeoutMs?: number | undefined;
10
+ fallback?: ComponentType<FallbackProps> | undefined;
11
+ }
12
+ /**
13
+ * A lazy component for `remote/expose` (e.g. `cart/App`). It never throws: load
14
+ * failures and timeouts render the fallback and are reported. Cached per id.
15
+ */
16
+ export declare function remoteComponent(id: string, options?: RemoteOptions): ComponentType;
17
+ export interface RemoteBoundaryProps extends RemoteOptions {
18
+ /** `remote/expose`, e.g. `cart/App`. */
19
+ id: string;
20
+ /** Shown while the remote loads. */
21
+ loading?: ReactNode | undefined;
22
+ }
23
+ /**
24
+ * Renders a remote module. A missing manifest, a timeout or a render error
25
+ * shows the fallback for that remote only; the rest of the page keeps working.
26
+ */
27
+ export declare function RemoteBoundary({ id, loading, timeoutMs, fallback }: RemoteBoundaryProps): import("react").JSX.Element;
28
+ /** Only for tests. */
29
+ export declare function clearRemoteCacheForTests(): void;
package/dist/remote.js ADDED
@@ -0,0 +1,98 @@
1
+ import { jsx, jsxs } from "react/jsx-runtime";
2
+ import { Component, Suspense, lazy } from "react";
3
+ import { loadRemote } from "@module-federation/enhanced/runtime";
4
+ import { reportRemoteError } from "./report.js";
5
+ function DefaultRemoteFallback({ name }) {
6
+ return /*#__PURE__*/ jsxs("div", {
7
+ role: "alert",
8
+ "data-mfeflow-fallback": name,
9
+ children: [
10
+ name,
11
+ " is unavailable right now."
12
+ ]
13
+ });
14
+ }
15
+ function withTimeout(promise, ms) {
16
+ return new Promise((resolve, reject)=>{
17
+ const timer = setTimeout(()=>{
18
+ reject(new Error(`timed out after ${String(ms)} ms`));
19
+ }, ms);
20
+ promise.then((value)=>{
21
+ clearTimeout(timer);
22
+ resolve(value);
23
+ }, (error)=>{
24
+ clearTimeout(timer);
25
+ reject(error instanceof Error ? error : new Error(String(error)));
26
+ });
27
+ });
28
+ }
29
+ function hasDefaultComponent(mod) {
30
+ return 'object' == typeof mod && null !== mod && 'default' in mod && 'function' == typeof mod.default;
31
+ }
32
+ const cache = new Map();
33
+ function remoteComponent(id, options = {}) {
34
+ const hit = cache.get(id);
35
+ if (hit) return hit;
36
+ const name = id.split('/')[0] ?? id;
37
+ const Fallback = options.fallback ?? DefaultRemoteFallback;
38
+ const C = /*#__PURE__*/ lazy(async ()=>{
39
+ try {
40
+ const mod = await withTimeout(loadRemote(id), options.timeoutMs ?? 5000);
41
+ if (!hasDefaultComponent(mod)) throw new Error(`${id} has no default export component`);
42
+ return {
43
+ default: mod.default
44
+ };
45
+ } catch (error) {
46
+ reportRemoteError(name, 'load', error);
47
+ cache.delete(id);
48
+ return {
49
+ default: function() {
50
+ return /*#__PURE__*/ jsx(Fallback, {
51
+ name: name
52
+ });
53
+ }
54
+ };
55
+ }
56
+ });
57
+ cache.set(id, C);
58
+ return C;
59
+ }
60
+ class RemoteErrorBoundary extends Component {
61
+ state = {
62
+ failed: false
63
+ };
64
+ static getDerivedStateFromError() {
65
+ return {
66
+ failed: true
67
+ };
68
+ }
69
+ componentDidCatch(error, _info) {
70
+ reportRemoteError(this.props.name, 'render', error);
71
+ }
72
+ render() {
73
+ const Fallback = this.props.fallback;
74
+ return this.state.failed ? /*#__PURE__*/ jsx(Fallback, {
75
+ name: this.props.name
76
+ }) : this.props.children;
77
+ }
78
+ }
79
+ function RemoteBoundary({ id, loading, timeoutMs, fallback }) {
80
+ const name = id.split('/')[0] ?? id;
81
+ const Fallback = fallback ?? DefaultRemoteFallback;
82
+ const C = remoteComponent(id, {
83
+ timeoutMs,
84
+ fallback: Fallback
85
+ });
86
+ return /*#__PURE__*/ jsx(RemoteErrorBoundary, {
87
+ name: name,
88
+ fallback: Fallback,
89
+ children: /*#__PURE__*/ jsx(Suspense, {
90
+ fallback: loading ?? null,
91
+ children: /*#__PURE__*/ jsx(C, {})
92
+ })
93
+ });
94
+ }
95
+ function clearRemoteCacheForTests() {
96
+ cache.clear();
97
+ }
98
+ export { DefaultRemoteFallback, RemoteBoundary, clearRemoteCacheForTests, remoteComponent };
@@ -0,0 +1,14 @@
1
+ export interface RemoteErrorReport {
2
+ /** Remote name, e.g. `cart`. */
3
+ mfe: string;
4
+ /** Where it failed: `load`, `render`, `registry`, or `errorLoadRemote:<lifecycle>`. */
5
+ where: string;
6
+ message: string;
7
+ time: number;
8
+ }
9
+ /** Fired on `window` for every report; telemetry and the dev overlay listen to it. */
10
+ export declare const REMOTE_ERROR_EVENT = "mfeflow:remote-error";
11
+ declare global {
12
+ var mfeflowReports: RemoteErrorReport[] | undefined;
13
+ }
14
+ export declare function reportRemoteError(mfe: string, where: string, error: unknown): RemoteErrorReport;
package/dist/report.js ADDED
@@ -0,0 +1,16 @@
1
+ const REMOTE_ERROR_EVENT = 'mfeflow:remote-error';
2
+ function reportRemoteError(mfe, where, error) {
3
+ const report = {
4
+ mfe,
5
+ where,
6
+ message: error instanceof Error ? error.message : String(error),
7
+ time: Date.now()
8
+ };
9
+ (globalThis.mfeflowReports ??= []).push(report);
10
+ if ("u" > typeof window && 'function' == typeof CustomEvent) window.dispatchEvent(new CustomEvent(REMOTE_ERROR_EVENT, {
11
+ detail: report
12
+ }));
13
+ console.error(`[mfeflow] ${mfe} ${where}: ${report.message}`);
14
+ return report;
15
+ }
16
+ export { REMOTE_ERROR_EVENT, reportRemoteError };
@@ -0,0 +1,15 @@
1
+ /** Where the shell keeps the runtime config once loaded: `globalThis[RUNTIME_CONFIG_KEY]`. */
2
+ export declare const RUNTIME_CONFIG_KEY = "__MFE_RUNTIME_CONFIG__";
3
+ /** The file the shell fetches: `public/config/runtime-config.json`, or the one for the environment in a deployment. */
4
+ export declare const RUNTIME_CONFIG_URL = "/config/runtime-config.json";
5
+ export type RuntimeConfigValues = Record<string, unknown>;
6
+ /**
7
+ * The runtime config: the one already on `globalThis`, else the file, fetched once. Every caller
8
+ * (the typed `loadRuntimeConfig` of env-t3, analytics, telemetry) gets the same promise, so the
9
+ * file is requested once however many features read it. It rejects if the file cannot be loaded
10
+ * (the same rejection for every caller; there is no retry), so each caller decides what to do
11
+ * without it.
12
+ */
13
+ export declare function fetchRuntimeConfig(url?: string): Promise<RuntimeConfigValues>;
14
+ /** Forgets the loaded config and the fetch. For tests. */
15
+ export declare function resetRuntimeConfigForTests(): void;
@@ -0,0 +1,29 @@
1
+ const RUNTIME_CONFIG_KEY = '__MFE_RUNTIME_CONFIG__';
2
+ const RUNTIME_CONFIG_URL = '/config/runtime-config.json';
3
+ const PENDING = Symbol.for('mfeflow.runtime-config');
4
+ const isConfig = (value)=>'object' == typeof value && null !== value && !Array.isArray(value);
5
+ const isPending = (value)=>'object' == typeof value && null !== value && Reflect.get(value, 'promise') instanceof Promise;
6
+ async function load(url) {
7
+ const response = await fetch(url);
8
+ if (!response.ok) throw new Error(`${url} answered ${String(response.status)}`);
9
+ const json = await response.json();
10
+ if (!isConfig(json)) throw new Error(`${url} is not a JSON object`);
11
+ Reflect.set(globalThis, RUNTIME_CONFIG_KEY, json);
12
+ return json;
13
+ }
14
+ function fetchRuntimeConfig(url = RUNTIME_CONFIG_URL) {
15
+ const loaded = Reflect.get(globalThis, RUNTIME_CONFIG_KEY);
16
+ if (isConfig(loaded)) return Promise.resolve(loaded);
17
+ const existing = Reflect.get(globalThis, PENDING);
18
+ if (isPending(existing)) return existing.promise;
19
+ const pending = {
20
+ promise: load(url)
21
+ };
22
+ Reflect.set(globalThis, PENDING, pending);
23
+ return pending.promise;
24
+ }
25
+ function resetRuntimeConfigForTests() {
26
+ Reflect.deleteProperty(globalThis, RUNTIME_CONFIG_KEY);
27
+ Reflect.deleteProperty(globalThis, PENDING);
28
+ }
29
+ export { RUNTIME_CONFIG_KEY, RUNTIME_CONFIG_URL, fetchRuntimeConfig, resetRuntimeConfigForTests };
@@ -0,0 +1,20 @@
1
+ export interface SingletonCheck {
2
+ /** Expected shared packages that are not in the share scope at all (e.g. a workspace package without a `version`). */
3
+ missing: string[];
4
+ /** Packages with more than one loaded version. */
5
+ duplicated: string[];
6
+ }
7
+ /**
8
+ * Checks the Module Federation share scope for the given singletons. Run in
9
+ * the shell after remotes load; the spike found a shared package without a
10
+ * `version` silently loaded once per app.
11
+ */
12
+ export declare function checkSingletons(expected: readonly string[]): SingletonCheck;
13
+ /** Query parameter that turns the singleton check on, e.g. `/catalog?mfeflow-assert`. */
14
+ export declare const ASSERT_PARAM = "mfeflow-assert";
15
+ /**
16
+ * Whether the page asked for the singleton check. The check reads the share
17
+ * scope of every loaded remote, so shells run it only in dev, tests and on
18
+ * request, never on a normal page load.
19
+ */
20
+ export declare function assertRequested(search?: string): boolean;
@@ -0,0 +1,26 @@
1
+ function isRecord(value) {
2
+ return 'object' == typeof value && null !== value;
3
+ }
4
+ function shareScopes() {
5
+ const federation = Reflect.get(globalThis, '__FEDERATION__');
6
+ if (!isRecord(federation)) return;
7
+ const share = Reflect.get(federation, '__SHARE__');
8
+ return isRecord(share) ? share : void 0;
9
+ }
10
+ function checkSingletons(expected) {
11
+ const scopes = shareScopes() ?? {};
12
+ const loadedVersions = new Map();
13
+ for (const instance of Object.values(scopes))for (const scope of Object.values(instance))for (const [pkg, versions] of Object.entries(scope))for (const [version, entry] of Object.entries(versions)){
14
+ if (!loadedVersions.has(pkg)) loadedVersions.set(pkg, new Set());
15
+ if (true === entry.loaded || void 0 !== entry.lib) loadedVersions.get(pkg)?.add(version);
16
+ }
17
+ return {
18
+ missing: expected.filter((p)=>!loadedVersions.has(p)),
19
+ duplicated: expected.filter((p)=>(loadedVersions.get(p)?.size ?? 0) > 1)
20
+ };
21
+ }
22
+ const ASSERT_PARAM = 'mfeflow-assert';
23
+ function assertRequested(search = "u" < typeof location ? '' : location.search) {
24
+ return new URLSearchParams(search).has(ASSERT_PARAM);
25
+ }
26
+ export { ASSERT_PARAM, assertRequested, checkSingletons };
package/package.json CHANGED
@@ -1,6 +1,86 @@
1
1
  {
2
2
  "name": "@mfeflow/runtime",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.0",
4
+ "description": "Runtime for mfeflow micro-frontends: remotes from a registry index, RemoteBoundary with timeout and fallback, error reporting, singleton checks, composeProviders.",
5
+ "keywords": [
6
+ "micro-frontend",
7
+ "mfe",
8
+ "module-federation",
9
+ "react",
10
+ "providers",
11
+ "runtime",
12
+ "rsbuild"
13
+ ],
14
+ "homepage": "https://mfeflow.github.io/mfeflow/",
15
+ "bugs": "https://github.com/mfeflow/mfeflow/issues",
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "git+https://github.com/mfeflow/mfeflow.git",
19
+ "directory": "packages/runtime"
20
+ },
21
+ "license": "MIT",
22
+ "author": "Sanjeev Sharma <speedsharma.ai@gmail.com>",
23
+ "type": "module",
24
+ "sideEffects": false,
25
+ "exports": {
26
+ ".": {
27
+ "types": "./dist/index.d.ts",
28
+ "import": "./dist/index.js",
29
+ "default": "./dist/index.js"
30
+ },
31
+ "./providers": {
32
+ "types": "./dist/providers.d.ts",
33
+ "import": "./dist/providers.js",
34
+ "default": "./dist/providers.js"
35
+ },
36
+ "./plugins/report": {
37
+ "types": "./dist/plugins/report.d.ts",
38
+ "import": "./dist/plugins/report.js",
39
+ "default": "./dist/plugins/report.js"
40
+ },
41
+ "./plugins/entry-retry": {
42
+ "types": "./dist/plugins/entry-retry.d.ts",
43
+ "import": "./dist/plugins/entry-retry.js",
44
+ "default": "./dist/plugins/entry-retry.js"
45
+ }
46
+ },
47
+ "files": [
48
+ "dist"
49
+ ],
50
+ "engines": {
51
+ "node": ">=22.12"
52
+ },
53
+ "publishConfig": {
54
+ "access": "public",
55
+ "provenance": true
56
+ },
57
+ "dependencies": {
58
+ "@mfeflow/contracts": "0.1.0"
59
+ },
60
+ "peerDependencies": {
61
+ "@module-federation/enhanced": "^2.9.0",
62
+ "react": "^19.0.0"
63
+ },
64
+ "peerDependenciesMeta": {},
65
+ "devDependencies": {
66
+ "@module-federation/enhanced": "2.9.2",
67
+ "@rsbuild/plugin-react": "2.1.1",
68
+ "@rslib/core": "1.0.5",
69
+ "@testing-library/dom": "10.4.2",
70
+ "@testing-library/react": "16.3.3",
71
+ "@types/node": "26.6.6",
72
+ "@types/react": "19.3.0",
73
+ "@types/react-dom": "19.3.0",
74
+ "jsdom": "30.1.3",
75
+ "react": "19.3.1",
76
+ "react-dom": "19.3.1",
77
+ "typescript": "7.0.2"
78
+ },
79
+ "mfeflow": {
80
+ "bucket": "singleton"
81
+ },
82
+ "scripts": {
83
+ "build": "rslib build",
84
+ "typecheck": "tsc --noEmit"
85
+ }
6
86
  }