@mfeflow/runtime 0.4.0 → 0.5.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/dist/index.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  export { composeProviders, withProps, type ProviderComponent } from './providers.js';
2
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';
3
+ export { DEFAULT_REGISTRY_URL, isRegistryIndex, loadRegistryIndex, REGISTRY_GLOBAL, REGISTRY_FALLBACK, registeredRemotes, registrySources, registrySourcesFromConfig, type RegistrySources, registerRemotesFromIndex, type LoadRegistryOptions, type RegisterOptions, type RegistryIndex, type RegistryRemote, } from './registry.js';
4
4
  export { DefaultRemoteFallback, RemoteBoundary, remoteComponent, type FallbackProps, type RemoteBoundaryProps, type RemoteOptions, } from './remote.js';
5
5
  export { ASSERT_PARAM, assertRequested, checkSingletons, type SingletonCheck } from './singletons.js';
6
6
  export { applyDevOverrides, OVERRIDE_PARAM, OVERRIDE_STORAGE_KEY, type DevOverrideOptions, } from './overrides.js';
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  export { composeProviders, withProps } from "./providers.js";
2
2
  export { REMOTE_ERROR_EVENT, reportRemoteError } from "./report.js";
3
- export { DEFAULT_REGISTRY_URL, REGISTRY_GLOBAL, isRegistryIndex, loadRegistryIndex, registerRemotesFromIndex, registeredRemotes } from "./registry.js";
3
+ export { DEFAULT_REGISTRY_URL, REGISTRY_FALLBACK, REGISTRY_GLOBAL, isRegistryIndex, loadRegistryIndex, registerRemotesFromIndex, registeredRemotes, registrySources, registrySourcesFromConfig } from "./registry.js";
4
4
  export { DefaultRemoteFallback, RemoteBoundary, remoteComponent } from "./remote.js";
5
5
  export { ASSERT_PARAM, assertRequested, checkSingletons } from "./singletons.js";
6
6
  export { OVERRIDE_PARAM, OVERRIDE_STORAGE_KEY, applyDevOverrides } from "./overrides.js";
@@ -15,16 +15,19 @@ function createEntryRetryPlugin(options = {}) {
15
15
  }
16
16
  await sleep(delayMs * attempt);
17
17
  if (globalLoading[uniqueKey] === failed) delete globalLoading[uniqueKey];
18
+ const before = globalLoading[uniqueKey];
18
19
  try {
19
- const exports = await getRemoteEntry({
20
+ const attemptLoad = getRemoteEntry({
20
21
  origin,
21
22
  remoteInfo,
22
23
  remoteEntryExports,
23
24
  getEntryUrl: (url)=>`${url}${url.includes('?') ? '&' : '?'}retry=${String(attempt)}`
24
25
  });
26
+ const started = globalLoading[uniqueKey];
27
+ failed = started === before ? void 0 : started;
28
+ const exports = await attemptLoad;
25
29
  if (exports) return exports;
26
30
  } catch {}
27
- failed = globalLoading[uniqueKey];
28
31
  }
29
32
  return;
30
33
  } finally{
@@ -5,6 +5,7 @@ declare global {
5
5
  where: string;
6
6
  message: string;
7
7
  time: number;
8
+ version?: string;
8
9
  }[] | undefined;
9
10
  }
10
11
  /** Reports every Module Federation load failure with the remote's name. */
@@ -1,14 +1,21 @@
1
1
  const EVENT = 'mfeflow:remote-error';
2
+ const VERSIONS = Symbol.for('mfeflow.remote-versions');
2
3
  function mfeflowReportPlugin() {
3
4
  return {
4
5
  name: 'mfeflow-report',
5
6
  errorLoadRemote (args) {
6
7
  const mfe = args.id.split('/')[0] ?? args.id;
8
+ const versions = Reflect.get(globalThis, VERSIONS);
9
+ const known = versions instanceof Map ? versions.get(mfe) : void 0;
10
+ const version = 'string' == typeof known ? known : void 0;
7
11
  const report = {
8
12
  mfe,
9
13
  where: `errorLoadRemote:${args.lifecycle}`,
10
14
  message: args.error instanceof Error ? args.error.message : String(args.error),
11
- time: Date.now()
15
+ time: Date.now(),
16
+ ...void 0 === version ? {} : {
17
+ version
18
+ }
12
19
  };
13
20
  (globalThis.mfeflowReports ??= []).push(report);
14
21
  if ("u" > typeof window && 'function' == typeof CustomEvent) window.dispatchEvent(new CustomEvent(EVENT, {
@@ -1,31 +1,11 @@
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
- }
1
+ import { isRegistryIndexRemote, type RegistryIndex, type RegistryIndexRemote } from '@mfeflow/contracts/entry';
2
+ import { type RuntimeConfigValues } from './runtime-config.js';
3
+ export type RegistryRemote = RegistryIndexRemote;
4
+ export type { RegistryIndex };
25
5
  /** Reported for a registry entry that has only a manifest URL in a shell built without the manifest loader. */
26
6
  export declare const NO_REMOTE_ENTRY = "MFE_NO_REMOTE_ENTRY";
27
7
  /** True for a valid registry remote. */
28
- export declare function isRegistryRemote(value: unknown): value is RegistryRemote;
8
+ export declare const isRegistryRemote: typeof isRegistryIndexRemote;
29
9
  /**
30
10
  * True for a registry index envelope (what the shell fetches at boot). Remotes
31
11
  * are checked one by one in `loadRegistryIndex`, so one bad remote does not
@@ -35,11 +15,16 @@ export declare function isRegistryIndex(value: unknown): value is RegistryIndex;
35
15
  /** Reported for a registry remote that is skipped because a field is invalid. */
36
16
  export declare const INVALID_REMOTE = "MFE_INVALID_REMOTE";
37
17
  export interface LoadRegistryOptions {
18
+ /** The whole time budget for all URLs together, in ms. Default 5000. */
38
19
  timeoutMs?: number;
20
+ /** How long each URL except the last gets, in ms. The last one gets what is left. Default 1500. */
21
+ primaryTimeoutMs?: number;
39
22
  fetch?: typeof fetch;
40
23
  /** Use an index the server or edge inlined as `window.__MFE_REGISTRY__`. Default true. */
41
24
  inline?: boolean;
42
- /** Allow the fallback fetch to leave the page's origin. Default false. */
25
+ /** Origins other than the page's that an index URL may be on. */
26
+ allowedOrigins?: readonly string[];
27
+ /** Allow any origin for the index URLs. Default false; prefer `allowedOrigins`. */
43
28
  allowCrossOrigin?: boolean;
44
29
  }
45
30
  /**
@@ -49,13 +34,36 @@ export interface LoadRegistryOptions {
49
34
  */
50
35
  export declare const REGISTRY_GLOBAL = "__MFE_REGISTRY__";
51
36
  export declare const DEFAULT_REGISTRY_URL = "/registry/index.json";
37
+ /** Reported once when the primary index failed and a later URL served the shell. */
38
+ export declare const REGISTRY_FALLBACK = "MFE_REGISTRY_FALLBACK";
52
39
  /**
53
40
  * 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.
41
+ * otherwise the first of `source` (one URL or an ordered list, primary first)
42
+ * that answers. A 5xx, network error, timeout, non-JSON body, invalid envelope
43
+ * or disallowed origin moves on to the next URL; each URL except the last gets
44
+ * `primaryTimeoutMs`, the last gets the rest of `timeoutMs`. Each URL must be on
45
+ * the page's origin or in `allowedOrigins`. Falling back is reported once
46
+ * (`MFE_REGISTRY_FALLBACK`). Throws when every URL fails. A remote with an
47
+ * invalid field is skipped and reported (`MFE_INVALID_REMOTE`); the valid ones stay.
48
+ */
49
+ export declare function loadRegistryIndex(source?: string | readonly string[], options?: LoadRegistryOptions): Promise<RegistryIndex>;
50
+ /**
51
+ * The index URLs and extra allowed origins from the shell's runtime config:
52
+ * `registryUrls` (primary first) and `registryOrigins`. Without them it is the
53
+ * default same-origin URL and no extra origins.
54
+ */
55
+ export declare function registrySourcesFromConfig(config: RuntimeConfigValues): RegistrySources;
56
+ export interface RegistrySources {
57
+ urls: string[];
58
+ allowedOrigins: string[];
59
+ }
60
+ /**
61
+ * Where the shell loads the registry from, without holding up boot. An inlined runtime config is
62
+ * used as is. When the index is inlined nothing is fetched (inline the runtime config too if its
63
+ * remotes live on another origin). Otherwise the config file is used if it answers within
64
+ * `waitMs`; past that, or when it fails, the same-origin default.
57
65
  */
58
- export declare function loadRegistryIndex(url?: string, options?: LoadRegistryOptions): Promise<RegistryIndex>;
66
+ export declare function registrySources(waitMs?: number): Promise<RegistrySources>;
59
67
  /** Remotes registered by the last `registerRemotesFromIndex` call (for the dev overlay). */
60
68
  export declare function registeredRemotes(): readonly RegistryRemote[];
61
69
  export interface RegisterOptions {
package/dist/registry.js CHANGED
@@ -1,27 +1,17 @@
1
- import { ENTRY_TYPES, GLOBAL_NAME, pickRegistryFields } from "@mfeflow/contracts/entry";
1
+ import { isRegistryIndexRemote, pickRegistryFields, registryRemoteIssues } from "@mfeflow/contracts/entry";
2
2
  import { registerRemotes } from "@module-federation/enhanced/runtime";
3
- import { reportRemoteError } from "./report.js";
3
+ import { rememberRemoteVersions, reportRemoteError } from "./report.js";
4
+ import { RUNTIME_CONFIG_KEY, fetchRuntimeConfig } from "./runtime-config.js";
4
5
  const NO_REMOTE_ENTRY = 'MFE_NO_REMOTE_ENTRY';
5
6
  function builtWithSnapshot() {
6
7
  return 'boolean' == typeof __MFEFLOW_SNAPSHOT__ ? __MFEFLOW_SNAPSHOT__ : true;
7
8
  }
8
- function isStringArray(value) {
9
- return Array.isArray(value) && value.every((v)=>'string' == typeof v);
10
- }
11
9
  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);
10
+ const first = registryRemoteIssues(value).find((issue)=>!issue.path.startsWith('exposes'));
11
+ if (void 0 === first) return;
12
+ return '' === first.path ? 'not an object' : first.path.split(/[.[]/)[0] ?? first.path;
24
13
  }
14
+ const isRegistryRemote = isRegistryIndexRemote;
25
15
  function isRegistryIndex(value) {
26
16
  return 'object' == typeof value && null !== value && 'remotes' in value && Array.isArray(value.remotes);
27
17
  }
@@ -41,22 +31,107 @@ function withoutInvalidRemotes(index) {
41
31
  }
42
32
  const REGISTRY_GLOBAL = '__MFE_REGISTRY__';
43
33
  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;
34
+ const REGISTRY_FALLBACK = 'MFE_REGISTRY_FALLBACK';
35
+ function originOf(url) {
36
+ try {
37
+ return new URL(url, "u" < typeof location ? void 0 : location.href).origin;
38
+ } catch {
39
+ return;
40
+ }
47
41
  }
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`);
42
+ function originAllowed(url, options) {
43
+ if (true === options.allowCrossOrigin) return true;
44
+ const origin = originOf(url);
45
+ if (void 0 === origin) return false;
46
+ if ("u" < typeof location || origin === location.origin) return true;
47
+ return (options.allowedOrigins ?? []).some((allowed)=>originOf(allowed) === origin);
48
+ }
49
+ async function fetchIndex(url, timeoutMs, options) {
50
+ if (!originAllowed(url, options)) throw new Error(`registry index ${url}: must be same-origin or in allowedOrigins`);
52
51
  const doFetch = options.fetch ?? fetch;
53
- const res = await doFetch(url, {
54
- signal: AbortSignal.timeout(options.timeoutMs ?? 5000)
55
- });
52
+ const signal = AbortSignal.timeout(timeoutMs);
53
+ let res;
54
+ try {
55
+ res = await doFetch(url, {
56
+ signal
57
+ });
58
+ } catch (error) {
59
+ if (signal.aborted) throw new Error(`registry index ${url}: timed out after ${String(timeoutMs)} ms`, {
60
+ cause: error
61
+ });
62
+ throw new Error(`registry index ${url}: ${error instanceof Error ? error.message : String(error)}`, {
63
+ cause: error
64
+ });
65
+ }
66
+ if (res.redirected && !originAllowed(res.url, options)) throw new Error(`registry index ${url}: redirected to ${res.url}, which is not allowed`);
56
67
  if (!res.ok) throw new Error(`registry index ${url}: HTTP ${String(res.status)}`);
57
- const body = await res.json();
68
+ let body;
69
+ try {
70
+ body = await res.json();
71
+ } catch {
72
+ throw new Error(`registry index ${url}: not valid JSON`);
73
+ }
58
74
  if (!isRegistryIndex(body)) throw new Error(`registry index ${url}: not a valid index`);
59
- return withoutInvalidRemotes(body);
75
+ return body;
76
+ }
77
+ async function loadRegistryIndex(source = DEFAULT_REGISTRY_URL, options = {}) {
78
+ const inlined = Reflect.get(globalThis, REGISTRY_GLOBAL);
79
+ if (false !== options.inline && isRegistryIndex(inlined)) return withoutInvalidRemotes(inlined);
80
+ const urls = 'string' == typeof source ? [
81
+ source
82
+ ] : source;
83
+ if (0 === urls.length) throw new Error('registry index: no URL given');
84
+ const budget = options.timeoutMs ?? 5000;
85
+ const primaryMs = options.primaryTimeoutMs ?? 1500;
86
+ const started = Date.now();
87
+ const failures = [];
88
+ for (const [position, url] of urls.entries()){
89
+ const remaining = budget - (Date.now() - started);
90
+ if (remaining <= 0) {
91
+ failures.push(new Error(`registry index ${url}: no time left`));
92
+ continue;
93
+ }
94
+ const timeoutMs = position === urls.length - 1 ? remaining : Math.min(primaryMs, remaining);
95
+ try {
96
+ const index = await fetchIndex(url, timeoutMs, options);
97
+ const [first] = failures;
98
+ if (void 0 !== first) reportRemoteError('registry', 'registry', new Error(`${REGISTRY_FALLBACK}: ${first.message}; using ${url}`));
99
+ return withoutInvalidRemotes(index);
100
+ } catch (error) {
101
+ failures.push(error instanceof Error ? error : new Error(String(error)));
102
+ }
103
+ }
104
+ const [only] = failures;
105
+ if (1 === failures.length && void 0 !== only) throw only;
106
+ throw new Error(`registry index: every source failed (${failures.map((e)=>e.message).join('; ')})`);
107
+ }
108
+ const strings = (value)=>Array.isArray(value) ? value.filter((v)=>'string' == typeof v && '' !== v) : [];
109
+ function registrySourcesFromConfig(config) {
110
+ const urls = strings(config.registryUrls);
111
+ const allowedOrigins = strings(config.registryOrigins).flatMap((o)=>{
112
+ const url = URL.canParse(o) ? new URL(o) : void 0;
113
+ return url?.protocol === 'https:' || url?.protocol === 'http:' ? url.origin : [];
114
+ });
115
+ return {
116
+ urls: urls.length > 0 ? urls : [
117
+ DEFAULT_REGISTRY_URL
118
+ ],
119
+ allowedOrigins
120
+ };
121
+ }
122
+ async function registrySources(waitMs = 1500) {
123
+ const configInlined = Reflect.has(globalThis, RUNTIME_CONFIG_KEY);
124
+ if (!configInlined && isRegistryIndex(Reflect.get(globalThis, REGISTRY_GLOBAL))) return registrySourcesFromConfig({});
125
+ let timer;
126
+ const late = new Promise((resolve)=>{
127
+ timer = setTimeout(()=>resolve({}), waitMs);
128
+ });
129
+ const config = await Promise.race([
130
+ fetchRuntimeConfig().catch(()=>({})),
131
+ late
132
+ ]);
133
+ clearTimeout(timer);
134
+ return registrySourcesFromConfig(config);
60
135
  }
61
136
  function toMfRemote(r) {
62
137
  if (void 0 === r.remoteEntry) return {
@@ -102,7 +177,8 @@ function registerRemotesFromIndex(index, options = {}) {
102
177
  ok.push(remote);
103
178
  }
104
179
  registerRemotes(ok.map(toMfRemote));
180
+ rememberRemoteVersions(ok);
105
181
  registered = ok.map((r)=>pickRegistryFields(r));
106
182
  return ok.map((r)=>r.name);
107
183
  }
108
- export { DEFAULT_REGISTRY_URL, INVALID_REMOTE, NO_REMOTE_ENTRY, REGISTRY_GLOBAL, isRegistryIndex, isRegistryRemote, loadRegistryIndex, registerRemotesFromIndex, registeredRemotes };
184
+ export { DEFAULT_REGISTRY_URL, INVALID_REMOTE, NO_REMOTE_ENTRY, REGISTRY_FALLBACK, REGISTRY_GLOBAL, isRegistryIndex, isRegistryRemote, loadRegistryIndex, registerRemotesFromIndex, registeredRemotes, registrySources, registrySourcesFromConfig };
package/dist/report.d.ts CHANGED
@@ -5,7 +5,14 @@ export interface RemoteErrorReport {
5
5
  where: string;
6
6
  message: string;
7
7
  time: number;
8
+ /** The registered version of the remote, when the registry gave one. */
9
+ version?: string;
8
10
  }
11
+ /** Remembers each remote's version so its reports say `cart@1.2.3`. Replaces what was remembered before. */
12
+ export declare function rememberRemoteVersions(remotes: readonly {
13
+ name: string;
14
+ version?: string;
15
+ }[]): void;
9
16
  /** Fired on `window` for every report; telemetry and the dev overlay listen to it. */
10
17
  export declare const REMOTE_ERROR_EVENT = "mfeflow:remote-error";
11
18
  declare global {
package/dist/report.js CHANGED
@@ -1,16 +1,34 @@
1
+ const VERSIONS = Symbol.for('mfeflow.remote-versions');
2
+ const isVersionMap = (value)=>value instanceof Map;
3
+ function versionMap() {
4
+ const existing = Reflect.get(globalThis, VERSIONS);
5
+ if (isVersionMap(existing)) return existing;
6
+ const created = new Map();
7
+ Reflect.set(globalThis, VERSIONS, created);
8
+ return created;
9
+ }
10
+ function rememberRemoteVersions(remotes) {
11
+ const versions = versionMap();
12
+ versions.clear();
13
+ for (const { name, version } of remotes)if (void 0 !== version) versions.set(name, version);
14
+ }
1
15
  const REMOTE_ERROR_EVENT = 'mfeflow:remote-error';
2
16
  function reportRemoteError(mfe, where, error) {
17
+ const version = versionMap().get(mfe);
3
18
  const report = {
4
19
  mfe,
5
20
  where,
6
21
  message: error instanceof Error ? error.message : String(error),
7
- time: Date.now()
22
+ time: Date.now(),
23
+ ...void 0 === version ? {} : {
24
+ version
25
+ }
8
26
  };
9
27
  (globalThis.mfeflowReports ??= []).push(report);
10
28
  if ("u" > typeof window && 'function' == typeof CustomEvent) window.dispatchEvent(new CustomEvent(REMOTE_ERROR_EVENT, {
11
29
  detail: report
12
30
  }));
13
- console.error(`[mfeflow] ${mfe} ${where}: ${report.message}`);
31
+ console.error(`[mfeflow] ${void 0 === version ? mfe : `${mfe}@${version}`} ${where}: ${report.message}`);
14
32
  return report;
15
33
  }
16
- export { REMOTE_ERROR_EVENT, reportRemoteError };
34
+ export { REMOTE_ERROR_EVENT, rememberRemoteVersions, reportRemoteError };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mfeflow/runtime",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Runtime for mfeflow micro-frontends: remotes from a registry index, RemoteBoundary with timeout and fallback, error reporting, singleton checks, composeProviders.",
5
5
  "keywords": [
6
6
  "micro-frontend",
@@ -55,7 +55,7 @@
55
55
  "provenance": true
56
56
  },
57
57
  "dependencies": {
58
- "@mfeflow/contracts": "0.4.0"
58
+ "@mfeflow/contracts": "0.5.0"
59
59
  },
60
60
  "peerDependencies": {
61
61
  "@module-federation/enhanced": "^2.9.0",