@bleedingdev/modern-js-federation-runtime 3.9.0-ultramodern.8 → 3.9.3-ultramodern.1
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/cjs/module-federation/consume-surface.js +6 -16
- package/dist/cjs/module-federation/distributed-ssr.js +6 -1
- package/dist/cjs/module-federation/index.js +13 -239
- package/dist/cjs/module-federation/manifest-recovery-runtime-plugin.js +1 -1
- package/dist/cjs/module-federation/renderer-contract.js +159 -0
- package/dist/cjs/module-federation/renderer-runtime-plugin.js +265 -0
- package/dist/cjs/module-federation/telemetry.js +214 -0
- package/dist/esm/module-federation/consume-surface.mjs +3 -13
- package/dist/esm/module-federation/distributed-ssr.mjs +6 -1
- package/dist/esm/module-federation/index.mjs +1 -111
- package/dist/esm/module-federation/manifest-recovery-runtime-plugin.mjs +1 -1
- package/dist/esm/module-federation/renderer-contract.mjs +103 -0
- package/dist/esm/module-federation/renderer-runtime-plugin.mjs +222 -0
- package/dist/esm/module-federation/telemetry.mjs +142 -0
- package/dist/esm-node/module-federation/consume-surface.mjs +3 -13
- package/dist/esm-node/module-federation/distributed-ssr.mjs +6 -1
- package/dist/esm-node/module-federation/index.mjs +1 -111
- package/dist/esm-node/module-federation/manifest-recovery-runtime-plugin.mjs +1 -1
- package/dist/esm-node/module-federation/renderer-contract.mjs +104 -0
- package/dist/esm-node/module-federation/renderer-runtime-plugin.mjs +223 -0
- package/dist/esm-node/module-federation/telemetry.mjs +143 -0
- package/dist/types/module-federation/consume-surface.d.ts +5 -5
- package/dist/types/module-federation/distributed-ssr.d.ts +18 -1
- package/dist/types/module-federation/index.d.ts +1 -67
- package/dist/types/module-federation/renderer-contract.d.ts +20 -0
- package/dist/types/module-federation/renderer-runtime-plugin.d.ts +98 -0
- package/dist/types/module-federation/telemetry.d.ts +71 -0
- package/package.json +54 -18
- package/src/module-federation/consume-surface.ts +18 -33
- package/src/module-federation/distributed-ssr.tsx +27 -2
- package/src/module-federation/index.ts +1 -267
- package/src/module-federation/manifest-recovery-runtime-plugin.ts +5 -1
- package/src/module-federation/renderer-contract.ts +176 -0
- package/src/module-federation/renderer-runtime-plugin.ts +396 -0
- package/src/module-federation/telemetry.ts +297 -0
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
import {
|
|
2
|
+
getResourceUrl,
|
|
3
|
+
isBrowserEnvValue,
|
|
4
|
+
isReactNativeEnv,
|
|
5
|
+
} from '@module-federation/sdk';
|
|
6
|
+
import {
|
|
7
|
+
assertRendererFederationCompatibility,
|
|
8
|
+
RENDERER_FEDERATION_METADATA_KEY,
|
|
9
|
+
type RendererFederationCompatibility,
|
|
10
|
+
type RendererFederationContract,
|
|
11
|
+
readRendererFederationCompatibility,
|
|
12
|
+
readRendererFederationContract,
|
|
13
|
+
rendererFederationError,
|
|
14
|
+
} from './renderer-contract';
|
|
15
|
+
|
|
16
|
+
const ATTESTATION = Symbol.for(
|
|
17
|
+
'ultramodern.renderer-federation.attestation.v1',
|
|
18
|
+
);
|
|
19
|
+
const record = (value: unknown): value is Record<string, unknown> =>
|
|
20
|
+
value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
21
|
+
|
|
22
|
+
type Snapshot = Record<string, unknown>;
|
|
23
|
+
type SnapshotArgs = {
|
|
24
|
+
remoteSnapshot: Snapshot;
|
|
25
|
+
from: 'global' | 'manifest';
|
|
26
|
+
manifestJson?: unknown;
|
|
27
|
+
};
|
|
28
|
+
type Attestation = Readonly<{
|
|
29
|
+
contract: RendererFederationContract;
|
|
30
|
+
coordinates: string;
|
|
31
|
+
}>;
|
|
32
|
+
|
|
33
|
+
// Snapshot fields are projected by the native SDK and retain both its browser
|
|
34
|
+
// and SSR entries. Bind the attestation to exactly those immutable coordinates.
|
|
35
|
+
const coordinates = (snapshot: Snapshot): string =>
|
|
36
|
+
JSON.stringify(
|
|
37
|
+
[
|
|
38
|
+
'globalName',
|
|
39
|
+
'buildVersion',
|
|
40
|
+
'remoteEntry',
|
|
41
|
+
'remoteEntryType',
|
|
42
|
+
'ssrRemoteEntry',
|
|
43
|
+
'ssrRemoteEntryType',
|
|
44
|
+
'publicPath',
|
|
45
|
+
'ssrPublicPath',
|
|
46
|
+
'getPublicPath',
|
|
47
|
+
].map(key => [key, snapshot[key]]),
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
function attestation(snapshot: Snapshot): Attestation {
|
|
51
|
+
const descriptor = Object.getOwnPropertyDescriptor(snapshot, ATTESTATION);
|
|
52
|
+
const value: unknown = descriptor?.value;
|
|
53
|
+
if (
|
|
54
|
+
!descriptor ||
|
|
55
|
+
descriptor.writable ||
|
|
56
|
+
descriptor.configurable ||
|
|
57
|
+
!record(value) ||
|
|
58
|
+
typeof value.coordinates !== 'string' ||
|
|
59
|
+
value.coordinates !== coordinates(snapshot)
|
|
60
|
+
)
|
|
61
|
+
throw rendererFederationError(
|
|
62
|
+
'cached remote snapshot is missing or has changed its attestation.',
|
|
63
|
+
);
|
|
64
|
+
return {
|
|
65
|
+
contract: readRendererFederationContract(value.contract),
|
|
66
|
+
coordinates: value.coordinates,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const assertManifestRemote = (remote: unknown): void => {
|
|
71
|
+
if (!record(remote))
|
|
72
|
+
throw rendererFederationError('remote registration is absent.');
|
|
73
|
+
// Native snapshot handling treats any non-JSON entry as an unchecked direct
|
|
74
|
+
// container. Version-only registrations may resolve to a native manifest.
|
|
75
|
+
if (
|
|
76
|
+
'entry' in remote &&
|
|
77
|
+
(typeof remote.entry !== 'string' || !remote.entry.includes('.json'))
|
|
78
|
+
)
|
|
79
|
+
throw rendererFederationError(
|
|
80
|
+
'renderer components require a native JSON manifest remote.',
|
|
81
|
+
);
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
type Shared = Record<string, unknown> & { version?: string };
|
|
85
|
+
type ShareResolution =
|
|
86
|
+
| { shared: Shared; useTreesShaking?: boolean }
|
|
87
|
+
| undefined;
|
|
88
|
+
type ResolveShareArgs = {
|
|
89
|
+
pkgName: string;
|
|
90
|
+
shareScopeMap: Record<string, Record<string, Record<string, Shared>>>;
|
|
91
|
+
resolver: () => ShareResolution;
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
function sameFactory(left: Shared, right: Shared): boolean {
|
|
95
|
+
if (left === right) return true;
|
|
96
|
+
if (typeof left.lib === 'function' && typeof right.lib === 'function')
|
|
97
|
+
return left.lib === right.lib;
|
|
98
|
+
return typeof left.get === 'function' && left.get === right.get;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const containerCoordinates = (info: Record<string, unknown>): string =>
|
|
102
|
+
JSON.stringify(
|
|
103
|
+
['name', 'entry', 'type', 'entryGlobalName', 'buildVersion'].map(key => [
|
|
104
|
+
key,
|
|
105
|
+
info[key],
|
|
106
|
+
]),
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
const entryCoordinates = (info: Record<string, unknown>): string =>
|
|
110
|
+
JSON.stringify(
|
|
111
|
+
['name', 'entry', 'type', 'entryGlobalName'].map(key => [key, info[key]]),
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Exact versions every shared copy of the consuming renderer tuple must carry.
|
|
116
|
+
* React keeps its established package set; native renderers derive theirs from
|
|
117
|
+
* the tuple's runtime, hydration, bootstrap and router owners.
|
|
118
|
+
*/
|
|
119
|
+
export function rendererShareVersions(
|
|
120
|
+
expected: RendererFederationCompatibility,
|
|
121
|
+
): ReadonlyMap<string, string> {
|
|
122
|
+
const { profile, runtime, bootstrap } = expected;
|
|
123
|
+
if (profile.renderer === 'react')
|
|
124
|
+
return new Map([
|
|
125
|
+
['react', runtime.version],
|
|
126
|
+
['react-dom', profile.hydration.version],
|
|
127
|
+
['react-dom/client', profile.hydration.version],
|
|
128
|
+
['@modern-js/runtime', bootstrap.version],
|
|
129
|
+
]);
|
|
130
|
+
const versions = new Map<string, string>();
|
|
131
|
+
for (const [name, version] of [
|
|
132
|
+
[runtime.name, runtime.version],
|
|
133
|
+
[profile.hydration.name, profile.hydration.version],
|
|
134
|
+
[bootstrap.name, bootstrap.version],
|
|
135
|
+
[profile.router.name, profile.router.version],
|
|
136
|
+
[profile.router.coreName, profile.router.coreVersion],
|
|
137
|
+
] as const) {
|
|
138
|
+
const prior = versions.get(name);
|
|
139
|
+
if (prior !== undefined && prior !== version)
|
|
140
|
+
throw rendererFederationError(
|
|
141
|
+
`renderer tuple names ${name} with conflicting versions.`,
|
|
142
|
+
);
|
|
143
|
+
versions.set(name, version);
|
|
144
|
+
}
|
|
145
|
+
return versions;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Gate the native snapshot and share lifecycles before executing component factories. */
|
|
149
|
+
export function createRendererFederationRuntimePlugin(
|
|
150
|
+
options: RendererFederationCompatibility,
|
|
151
|
+
) {
|
|
152
|
+
const expected = readRendererFederationCompatibility(options);
|
|
153
|
+
const selected = new Map<string, Shared>();
|
|
154
|
+
const approvedRemoteInfos = new WeakMap<object, string>();
|
|
155
|
+
const observedEntries = new WeakMap<object, string>();
|
|
156
|
+
const acceptedEntries = new Set<string>();
|
|
157
|
+
const versions = rendererShareVersions(expected);
|
|
158
|
+
return {
|
|
159
|
+
name: 'ultramodern-renderer-federation-contract',
|
|
160
|
+
beforeRegisterRemote(args: { remote: unknown }) {
|
|
161
|
+
assertManifestRemote(args.remote);
|
|
162
|
+
return args;
|
|
163
|
+
},
|
|
164
|
+
async afterMatchRemote(args: { remote?: unknown; error?: unknown }) {
|
|
165
|
+
if (!args.error) assertManifestRemote(args.remote);
|
|
166
|
+
return args;
|
|
167
|
+
},
|
|
168
|
+
async loadRemoteSnapshot(args: SnapshotArgs) {
|
|
169
|
+
const snapshot = args.remoteSnapshot;
|
|
170
|
+
if (args.from === 'manifest') {
|
|
171
|
+
const manifest = args.manifestJson;
|
|
172
|
+
const metadata =
|
|
173
|
+
record(manifest) && record(manifest.metaData)
|
|
174
|
+
? manifest.metaData[RENDERER_FEDERATION_METADATA_KEY]
|
|
175
|
+
: undefined;
|
|
176
|
+
const contract = readRendererFederationContract(metadata);
|
|
177
|
+
// The native loading promise is process-global. Validate publication
|
|
178
|
+
// independently here; an incompatible creator host must not poison a
|
|
179
|
+
// later compatible host's cached promise. Every host checks below.
|
|
180
|
+
if (!record(snapshot) || typeof snapshot.remoteEntry !== 'string')
|
|
181
|
+
throw rendererFederationError(
|
|
182
|
+
'native remote entry coordinates are absent.',
|
|
183
|
+
);
|
|
184
|
+
const existing = Object.getOwnPropertyDescriptor(snapshot, ATTESTATION);
|
|
185
|
+
if (existing) {
|
|
186
|
+
const prior = attestation(snapshot);
|
|
187
|
+
if (JSON.stringify(prior.contract) !== JSON.stringify(contract))
|
|
188
|
+
throw rendererFederationError(
|
|
189
|
+
'remote snapshot has conflicting publication ownership.',
|
|
190
|
+
);
|
|
191
|
+
} else {
|
|
192
|
+
Object.defineProperty(snapshot, ATTESTATION, {
|
|
193
|
+
value: Object.freeze({
|
|
194
|
+
contract,
|
|
195
|
+
coordinates: coordinates(snapshot),
|
|
196
|
+
}),
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
} else {
|
|
200
|
+
assertRendererFederationCompatibility(
|
|
201
|
+
expected,
|
|
202
|
+
attestation(snapshot).contract,
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
return args;
|
|
206
|
+
},
|
|
207
|
+
async afterLoadSnapshot(args: {
|
|
208
|
+
remoteSnapshot: Snapshot;
|
|
209
|
+
moduleInfo?: { name: string };
|
|
210
|
+
}) {
|
|
211
|
+
// Native __MANIFEST_LOADING__ promises are global: every host must run
|
|
212
|
+
// this awaited gate even when another host created the retained snapshot.
|
|
213
|
+
assertRendererFederationCompatibility(
|
|
214
|
+
expected,
|
|
215
|
+
attestation(args.remoteSnapshot).contract,
|
|
216
|
+
);
|
|
217
|
+
const snapshot = args.remoteSnapshot;
|
|
218
|
+
const browser =
|
|
219
|
+
isBrowserEnvValue ||
|
|
220
|
+
isReactNativeEnv() ||
|
|
221
|
+
!('ssrRemoteEntry' in snapshot);
|
|
222
|
+
const entry = browser ? snapshot.remoteEntry : snapshot.ssrRemoteEntry;
|
|
223
|
+
if (typeof entry !== 'string' || !entry)
|
|
224
|
+
throw rendererFederationError(
|
|
225
|
+
'native projected remote entry is absent.',
|
|
226
|
+
);
|
|
227
|
+
// Reuse the native public URL projection. Its preload path makes a new
|
|
228
|
+
// RemoteInfo without buildVersion before custom afterResolve runs.
|
|
229
|
+
let entryUrl = getResourceUrl(
|
|
230
|
+
snapshot as Parameters<typeof getResourceUrl>[0],
|
|
231
|
+
entry,
|
|
232
|
+
);
|
|
233
|
+
// Mirror the native runtime's server projection exactly: the accepted
|
|
234
|
+
// coordinates must equal the entry it loads, root-relative ones included.
|
|
235
|
+
if (!isBrowserEnvValue && !entryUrl.startsWith('http'))
|
|
236
|
+
entryUrl = `https:${entryUrl}`;
|
|
237
|
+
acceptedEntries.add(
|
|
238
|
+
entryCoordinates({
|
|
239
|
+
name: args.moduleInfo?.name ?? snapshot.globalName,
|
|
240
|
+
entry: entryUrl,
|
|
241
|
+
type: browser
|
|
242
|
+
? snapshot.remoteEntryType
|
|
243
|
+
: snapshot.ssrRemoteEntryType || 'global',
|
|
244
|
+
entryGlobalName: snapshot.globalName,
|
|
245
|
+
}),
|
|
246
|
+
);
|
|
247
|
+
return args;
|
|
248
|
+
},
|
|
249
|
+
async afterResolve(args: {
|
|
250
|
+
remote: { name: string };
|
|
251
|
+
remoteInfo: Record<string, unknown>;
|
|
252
|
+
remoteSnapshot?: Snapshot;
|
|
253
|
+
origin: {
|
|
254
|
+
moduleCache: Map<string, { remoteInfo: Record<string, unknown> }>;
|
|
255
|
+
};
|
|
256
|
+
}) {
|
|
257
|
+
if (!args.remoteSnapshot)
|
|
258
|
+
throw rendererFederationError(
|
|
259
|
+
'native remote resolution has no attested snapshot.',
|
|
260
|
+
);
|
|
261
|
+
assertRendererFederationCompatibility(
|
|
262
|
+
expected,
|
|
263
|
+
attestation(args.remoteSnapshot).contract,
|
|
264
|
+
);
|
|
265
|
+
// Native Module reuse is by name. Raw-container insertion bypasses entry
|
|
266
|
+
// loading, even for a declared manifest. Accept only Module coordinates
|
|
267
|
+
// that this host previously resolved through the attested native path.
|
|
268
|
+
const cached = args.origin.moduleCache.get(args.remote.name);
|
|
269
|
+
if (
|
|
270
|
+
cached &&
|
|
271
|
+
(approvedRemoteInfos.get(cached.remoteInfo) !==
|
|
272
|
+
containerCoordinates(cached.remoteInfo) ||
|
|
273
|
+
containerCoordinates(cached.remoteInfo) !==
|
|
274
|
+
containerCoordinates(args.remoteInfo))
|
|
275
|
+
)
|
|
276
|
+
throw rendererFederationError(
|
|
277
|
+
'cached remote container has unattested or conflicting ownership.',
|
|
278
|
+
);
|
|
279
|
+
const priorCoordinates = approvedRemoteInfos.get(args.remoteInfo);
|
|
280
|
+
if (
|
|
281
|
+
priorCoordinates !== undefined &&
|
|
282
|
+
priorCoordinates !== containerCoordinates(args.remoteInfo)
|
|
283
|
+
)
|
|
284
|
+
throw rendererFederationError(
|
|
285
|
+
'native remote container coordinates changed after attestation.',
|
|
286
|
+
);
|
|
287
|
+
approvedRemoteInfos.set(
|
|
288
|
+
args.remoteInfo,
|
|
289
|
+
containerCoordinates(args.remoteInfo),
|
|
290
|
+
);
|
|
291
|
+
return args;
|
|
292
|
+
},
|
|
293
|
+
loadEntry(args: { remoteInfo: Record<string, unknown> }): void {
|
|
294
|
+
if (!acceptedEntries.has(entryCoordinates(args.remoteInfo)))
|
|
295
|
+
throw rendererFederationError(
|
|
296
|
+
'remote entry coordinates differ from the attested native snapshot.',
|
|
297
|
+
);
|
|
298
|
+
},
|
|
299
|
+
async afterLoadEntry(args: {
|
|
300
|
+
remoteInfo: Record<string, unknown>;
|
|
301
|
+
remoteEntryExports?: unknown;
|
|
302
|
+
cached?: boolean;
|
|
303
|
+
error?: unknown;
|
|
304
|
+
recovered?: boolean;
|
|
305
|
+
}) {
|
|
306
|
+
if (args.error && !args.recovered) return args;
|
|
307
|
+
const binding = containerCoordinates(args.remoteInfo);
|
|
308
|
+
const entryBinding = entryCoordinates(args.remoteInfo);
|
|
309
|
+
if (args.cached && approvedRemoteInfos.get(args.remoteInfo) !== binding)
|
|
310
|
+
throw rendererFederationError(
|
|
311
|
+
'remote entry has no attested native container resolution.',
|
|
312
|
+
);
|
|
313
|
+
if (!acceptedEntries.has(entryBinding))
|
|
314
|
+
throw rendererFederationError(
|
|
315
|
+
'remote entry coordinates differ from the attested native snapshot.',
|
|
316
|
+
);
|
|
317
|
+
const entry = args.remoteEntryExports;
|
|
318
|
+
if (
|
|
319
|
+
(typeof entry !== 'object' || entry === null) &&
|
|
320
|
+
typeof entry !== 'function'
|
|
321
|
+
)
|
|
322
|
+
throw rendererFederationError('native container exports are absent.');
|
|
323
|
+
// The native loader emits cached:true for Module-provided exports,
|
|
324
|
+
// including initRawContainer. Such exports must have been observed from
|
|
325
|
+
// this host's validated native load before any container.get executes.
|
|
326
|
+
if (args.cached && observedEntries.get(entry) !== entryBinding)
|
|
327
|
+
throw rendererFederationError(
|
|
328
|
+
'cached native container exports have unattested ownership.',
|
|
329
|
+
);
|
|
330
|
+
approvedRemoteInfos.set(args.remoteInfo, binding);
|
|
331
|
+
observedEntries.set(entry, entryBinding);
|
|
332
|
+
return args;
|
|
333
|
+
},
|
|
334
|
+
resolveShare(args: ResolveShareArgs) {
|
|
335
|
+
const version = versions.get(args.pkgName);
|
|
336
|
+
if (version === undefined) return args;
|
|
337
|
+
const resolve = args.resolver;
|
|
338
|
+
return {
|
|
339
|
+
...args,
|
|
340
|
+
resolver() {
|
|
341
|
+
const result = resolve();
|
|
342
|
+
if (!result) return result;
|
|
343
|
+
const shared =
|
|
344
|
+
result.useTreesShaking && record(result.shared.treeShaking)
|
|
345
|
+
? result.shared.treeShaking
|
|
346
|
+
: result.shared;
|
|
347
|
+
if (result.shared.version !== version)
|
|
348
|
+
throw rendererFederationError(
|
|
349
|
+
`shared ${args.pkgName} version must be ${version}.`,
|
|
350
|
+
);
|
|
351
|
+
const prior = selected.get(args.pkgName);
|
|
352
|
+
if (prior && !sameFactory(prior, shared))
|
|
353
|
+
throw rendererFederationError(
|
|
354
|
+
`shared ${args.pkgName} has conflicting runtime ownership.`,
|
|
355
|
+
);
|
|
356
|
+
// Unloaded alternative registrations are valid MF candidates. A
|
|
357
|
+
// second already loaded factory for this renderer runtime is not.
|
|
358
|
+
for (const scope of Object.values(args.shareScopeMap))
|
|
359
|
+
for (const candidate of Object.values(scope[args.pkgName] ?? {})) {
|
|
360
|
+
const active =
|
|
361
|
+
record(candidate.treeShaking) && candidate.treeShaking.loaded
|
|
362
|
+
? candidate.treeShaking
|
|
363
|
+
: candidate;
|
|
364
|
+
if ((active.loaded || active.lib) && !sameFactory(active, shared))
|
|
365
|
+
throw rendererFederationError(
|
|
366
|
+
`shared ${args.pkgName} has duplicate loaded runtime identities.`,
|
|
367
|
+
);
|
|
368
|
+
}
|
|
369
|
+
selected.set(
|
|
370
|
+
args.pkgName,
|
|
371
|
+
Object.freeze({ lib: shared.lib, get: shared.get }),
|
|
372
|
+
);
|
|
373
|
+
return result;
|
|
374
|
+
},
|
|
375
|
+
};
|
|
376
|
+
},
|
|
377
|
+
afterLoadShare(args: { pkgName: string; selectedShared?: Shared }) {
|
|
378
|
+
// Native loading fills lib after resolveShare. Observe that completed
|
|
379
|
+
// factory identity here; rejection remains in the pre-factory resolver
|
|
380
|
+
// because native afterLoadShare deliberately swallows thrown errors.
|
|
381
|
+
if (versions.has(args.pkgName) && args.selectedShared) {
|
|
382
|
+
const shared =
|
|
383
|
+
record(args.selectedShared.treeShaking) &&
|
|
384
|
+
args.selectedShared.treeShaking.loaded
|
|
385
|
+
? args.selectedShared.treeShaking
|
|
386
|
+
: args.selectedShared;
|
|
387
|
+
selected.set(
|
|
388
|
+
args.pkgName,
|
|
389
|
+
Object.freeze({ lib: shared.lib, get: shared.get }),
|
|
390
|
+
);
|
|
391
|
+
}
|
|
392
|
+
},
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
export default createRendererFederationRuntimePlugin;
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
// @effect-diagnostics asyncFunction:off extendsNativeError:off globalFetch:off strictBooleanExpressions:off
|
|
2
|
+
|
|
3
|
+
export const MODULE_FEDERATION_FALLBACK_SIGNAL_EVENT =
|
|
4
|
+
'modernjs:mf-runtime-fallback';
|
|
5
|
+
|
|
6
|
+
export const MODULE_FEDERATION_RECOVERY_SIGNAL_EVENT =
|
|
7
|
+
'modernjs:mf-runtime-recovery';
|
|
8
|
+
|
|
9
|
+
export const DEFAULT_RUNTIME_FALLBACK_SIGNAL_ENDPOINT =
|
|
10
|
+
'/_modern/contract-gates/runtime-fallback';
|
|
11
|
+
|
|
12
|
+
export const DEFAULT_RUNTIME_FALLBACK_SIGNAL_AUTH_HEADER =
|
|
13
|
+
'x-modernjs-runtime-signal-token';
|
|
14
|
+
|
|
15
|
+
export type ModuleFederationFallbackClassification =
|
|
16
|
+
| 'timeout'
|
|
17
|
+
| 'network'
|
|
18
|
+
| 'contract'
|
|
19
|
+
| 'version-skew'
|
|
20
|
+
| 'remote-unavailable';
|
|
21
|
+
|
|
22
|
+
export type ModuleFederationFallbackStatus =
|
|
23
|
+
| 'degraded'
|
|
24
|
+
| 'failed'
|
|
25
|
+
| 'recovered';
|
|
26
|
+
|
|
27
|
+
export type ModuleFederationFallbackTelemetryInput = {
|
|
28
|
+
appName: string;
|
|
29
|
+
classification: ModuleFederationFallbackClassification;
|
|
30
|
+
entry?: string;
|
|
31
|
+
error?: unknown;
|
|
32
|
+
eventName?: string;
|
|
33
|
+
exportName?: string;
|
|
34
|
+
metadata?: Record<string, unknown>;
|
|
35
|
+
phase: 'discovery' | 'load' | 'mount' | 'hydrate' | 'recover';
|
|
36
|
+
remote: string;
|
|
37
|
+
runtimeDigest?: string;
|
|
38
|
+
status?: ModuleFederationFallbackStatus;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export type ModuleFederationFallbackTelemetryPayload = {
|
|
42
|
+
appName: string;
|
|
43
|
+
entry?: string;
|
|
44
|
+
eventName: string;
|
|
45
|
+
phase: ModuleFederationFallbackTelemetryInput['phase'];
|
|
46
|
+
reason: ModuleFederationFallbackClassification;
|
|
47
|
+
runtimeDigest?: string;
|
|
48
|
+
schemaVersion: 1;
|
|
49
|
+
metadata: {
|
|
50
|
+
classification: ModuleFederationFallbackClassification;
|
|
51
|
+
errorMessage?: string;
|
|
52
|
+
errorName?: string;
|
|
53
|
+
exportName?: string;
|
|
54
|
+
remote: string;
|
|
55
|
+
runtimeDigest?: string;
|
|
56
|
+
status: ModuleFederationFallbackStatus;
|
|
57
|
+
} & Record<string, unknown>;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export type ModuleFederationFallbackTelemetryEmitOptions = {
|
|
61
|
+
authHeaderName?: string;
|
|
62
|
+
authToken?: string;
|
|
63
|
+
endpoint?: string;
|
|
64
|
+
fetchImpl?: typeof fetch;
|
|
65
|
+
postSignal?: boolean;
|
|
66
|
+
/** Positive reporting deadline; defaults to one second. */
|
|
67
|
+
timeoutMs?: number;
|
|
68
|
+
/** Observe the exact payload and await delivery explicitly in tests. */
|
|
69
|
+
observe?: (
|
|
70
|
+
payload: ModuleFederationFallbackTelemetryPayload,
|
|
71
|
+
delivery: Promise<ModuleFederationFallbackTelemetryEmitResult>,
|
|
72
|
+
) => void;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
export type ModuleFederationFallbackTelemetryEmitResult = {
|
|
76
|
+
dispatched: boolean;
|
|
77
|
+
posted: boolean;
|
|
78
|
+
postStatus?: number;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
export class ModuleFederationRemoteLoadTimeoutError extends Error {
|
|
82
|
+
constructor(remote: string, timeoutMs: number) {
|
|
83
|
+
super(`Loading remote "${remote}" timed out after ${timeoutMs}ms`);
|
|
84
|
+
this.name = 'ModuleFederationRemoteLoadTimeoutError';
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export class ModuleFederationRemoteLoadError extends Error {
|
|
89
|
+
readonly remote: string;
|
|
90
|
+
readonly attempts: number;
|
|
91
|
+
readonly causeError: Error;
|
|
92
|
+
|
|
93
|
+
constructor(remote: string, attempts: number, causeError: Error) {
|
|
94
|
+
super(
|
|
95
|
+
`Unable to load remote "${remote}" after ${attempts} attempt${
|
|
96
|
+
attempts > 1 ? 's' : ''
|
|
97
|
+
}: ${causeError.message}`,
|
|
98
|
+
);
|
|
99
|
+
this.name = 'ModuleFederationRemoteLoadError';
|
|
100
|
+
this.remote = remote;
|
|
101
|
+
this.attempts = attempts;
|
|
102
|
+
this.causeError = causeError;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export class ModuleFederationRemoteComponentContractError extends Error {
|
|
107
|
+
constructor(remote: string, exportName: string) {
|
|
108
|
+
super(
|
|
109
|
+
`Remote "${remote}" export "${exportName}" is not a valid React component`,
|
|
110
|
+
);
|
|
111
|
+
this.name = 'ModuleFederationRemoteComponentContractError';
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function toError(error: unknown) {
|
|
116
|
+
if (error instanceof Error) {
|
|
117
|
+
return error;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return new Error(
|
|
121
|
+
typeof error === 'string' ? error : 'Unknown remote load error',
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function classifyModuleFederationFallback(
|
|
126
|
+
error: unknown,
|
|
127
|
+
): ModuleFederationFallbackClassification {
|
|
128
|
+
const normalizedError = toError(error);
|
|
129
|
+
if (normalizedError instanceof ModuleFederationRemoteLoadError) {
|
|
130
|
+
return classifyModuleFederationFallback(normalizedError.causeError);
|
|
131
|
+
}
|
|
132
|
+
if (normalizedError instanceof ModuleFederationRemoteLoadTimeoutError) {
|
|
133
|
+
return 'timeout';
|
|
134
|
+
}
|
|
135
|
+
if (normalizedError instanceof ModuleFederationRemoteComponentContractError) {
|
|
136
|
+
return 'contract';
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const message = normalizedError.message;
|
|
140
|
+
if (
|
|
141
|
+
/version|requiredVersion|singleton|share scope|shared module/i.test(message)
|
|
142
|
+
) {
|
|
143
|
+
return 'version-skew';
|
|
144
|
+
}
|
|
145
|
+
if (/network|fetch|script|timeout|chunk|loading/i.test(message)) {
|
|
146
|
+
return 'network';
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return 'remote-unavailable';
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function createModuleFederationFallbackTelemetry(
|
|
153
|
+
input: ModuleFederationFallbackTelemetryInput,
|
|
154
|
+
): ModuleFederationFallbackTelemetryPayload {
|
|
155
|
+
const error = input.error !== undefined ? toError(input.error) : undefined;
|
|
156
|
+
const status = input.status ?? 'degraded';
|
|
157
|
+
const eventName =
|
|
158
|
+
input.eventName ??
|
|
159
|
+
(status === 'recovered'
|
|
160
|
+
? MODULE_FEDERATION_RECOVERY_SIGNAL_EVENT
|
|
161
|
+
: MODULE_FEDERATION_FALLBACK_SIGNAL_EVENT);
|
|
162
|
+
const metadata: ModuleFederationFallbackTelemetryPayload['metadata'] = {
|
|
163
|
+
...(input.metadata ?? {}),
|
|
164
|
+
classification: input.classification,
|
|
165
|
+
remote: input.remote,
|
|
166
|
+
status,
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
if (input.exportName !== undefined) {
|
|
170
|
+
metadata.exportName = input.exportName;
|
|
171
|
+
}
|
|
172
|
+
if (input.runtimeDigest !== undefined) {
|
|
173
|
+
metadata.runtimeDigest = input.runtimeDigest;
|
|
174
|
+
}
|
|
175
|
+
if (error !== undefined) {
|
|
176
|
+
metadata.errorName = error.name;
|
|
177
|
+
metadata.errorMessage = error.message;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const payload: ModuleFederationFallbackTelemetryPayload = {
|
|
181
|
+
appName: input.appName,
|
|
182
|
+
eventName,
|
|
183
|
+
metadata,
|
|
184
|
+
phase: input.phase,
|
|
185
|
+
reason: input.classification,
|
|
186
|
+
schemaVersion: 1,
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
if (input.entry !== undefined) {
|
|
190
|
+
payload.entry = input.entry;
|
|
191
|
+
}
|
|
192
|
+
if (input.runtimeDigest !== undefined) {
|
|
193
|
+
payload.runtimeDigest = input.runtimeDigest;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return payload;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export function toModuleFederationFallbackAttributes(
|
|
200
|
+
payload: ModuleFederationFallbackTelemetryPayload,
|
|
201
|
+
): Record<string, string> {
|
|
202
|
+
return {
|
|
203
|
+
'data-mf-fallback-app': payload.appName,
|
|
204
|
+
'data-mf-fallback-classification': payload.reason,
|
|
205
|
+
'data-mf-fallback-phase': payload.phase,
|
|
206
|
+
'data-mf-fallback-remote': String(payload.metadata.remote),
|
|
207
|
+
'data-mf-fallback-status': String(payload.metadata.status),
|
|
208
|
+
'data-mf-telemetry-event': payload.eventName,
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export function emitModuleFederationFallbackTelemetry(
|
|
213
|
+
input: ModuleFederationFallbackTelemetryInput,
|
|
214
|
+
options: ModuleFederationFallbackTelemetryEmitOptions = {},
|
|
215
|
+
): Promise<ModuleFederationFallbackTelemetryEmitResult> {
|
|
216
|
+
return emitModuleFederationFallbackPayload(
|
|
217
|
+
createModuleFederationFallbackTelemetry(input),
|
|
218
|
+
options,
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Dispatch once locally; remote delivery is bounded and best effort. */
|
|
223
|
+
export function emitModuleFederationFallbackPayload(
|
|
224
|
+
payload: ModuleFederationFallbackTelemetryPayload,
|
|
225
|
+
options: ModuleFederationFallbackTelemetryEmitOptions = {},
|
|
226
|
+
): Promise<ModuleFederationFallbackTelemetryEmitResult> {
|
|
227
|
+
const delivery = deliver(payload, options);
|
|
228
|
+
try {
|
|
229
|
+
options.observe?.(payload, delivery);
|
|
230
|
+
} catch {
|
|
231
|
+
/* Observers cannot break fallback. */
|
|
232
|
+
}
|
|
233
|
+
return delivery;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
async function deliver(
|
|
237
|
+
payload: ModuleFederationFallbackTelemetryPayload,
|
|
238
|
+
options: ModuleFederationFallbackTelemetryEmitOptions,
|
|
239
|
+
): Promise<ModuleFederationFallbackTelemetryEmitResult> {
|
|
240
|
+
let dispatched = false;
|
|
241
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
242
|
+
const controller = new AbortController();
|
|
243
|
+
try {
|
|
244
|
+
if (
|
|
245
|
+
typeof window !== 'undefined' &&
|
|
246
|
+
typeof window.dispatchEvent === 'function' &&
|
|
247
|
+
typeof CustomEvent !== 'undefined'
|
|
248
|
+
) {
|
|
249
|
+
window.dispatchEvent(
|
|
250
|
+
new CustomEvent(payload.eventName, { detail: payload }),
|
|
251
|
+
);
|
|
252
|
+
dispatched = true;
|
|
253
|
+
}
|
|
254
|
+
const fetchImpl = options.fetchImpl ?? globalThis.fetch;
|
|
255
|
+
if (
|
|
256
|
+
!(options.postSignal === true || options.endpoint) ||
|
|
257
|
+
typeof fetchImpl !== 'function'
|
|
258
|
+
) {
|
|
259
|
+
return { dispatched, posted: false };
|
|
260
|
+
}
|
|
261
|
+
const headers = new Headers({ 'content-type': 'application/json' });
|
|
262
|
+
if (options.authToken)
|
|
263
|
+
headers.set(
|
|
264
|
+
options.authHeaderName ?? DEFAULT_RUNTIME_FALLBACK_SIGNAL_AUTH_HEADER,
|
|
265
|
+
options.authToken,
|
|
266
|
+
);
|
|
267
|
+
const timeoutMs =
|
|
268
|
+
Number.isSafeInteger(options.timeoutMs) && (options.timeoutMs ?? 0) > 0
|
|
269
|
+
? options.timeoutMs!
|
|
270
|
+
: 1_000;
|
|
271
|
+
// Race even custom transports that ignore AbortSignal. Promise.race also
|
|
272
|
+
// observes a late rejection after the timeout wins.
|
|
273
|
+
const expired = new Promise<undefined>(resolve => {
|
|
274
|
+
timer = setTimeout(() => {
|
|
275
|
+
controller.abort();
|
|
276
|
+
resolve(undefined);
|
|
277
|
+
}, timeoutMs);
|
|
278
|
+
});
|
|
279
|
+
const response = await Promise.race([
|
|
280
|
+
fetchImpl(options.endpoint ?? DEFAULT_RUNTIME_FALLBACK_SIGNAL_ENDPOINT, {
|
|
281
|
+
body: JSON.stringify(payload),
|
|
282
|
+
headers,
|
|
283
|
+
method: 'POST',
|
|
284
|
+
keepalive: true,
|
|
285
|
+
signal: controller.signal,
|
|
286
|
+
}),
|
|
287
|
+
expired,
|
|
288
|
+
]);
|
|
289
|
+
return response
|
|
290
|
+
? { dispatched, posted: true, postStatus: response.status }
|
|
291
|
+
: { dispatched, posted: false };
|
|
292
|
+
} catch {
|
|
293
|
+
return { dispatched, posted: false };
|
|
294
|
+
} finally {
|
|
295
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
296
|
+
}
|
|
297
|
+
}
|