@rollipop/plugin-module-federation 0.0.0 → 1.0.0-alpha.21

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/CHANGELOG.md ADDED
@@ -0,0 +1,22 @@
1
+ # @rollipop/plugin-module-federation
2
+
3
+ ## 1.0.0-alpha.21
4
+
5
+ ### Patch Changes
6
+
7
+ - Updated dependencies [e45aedd]
8
+ - Updated dependencies [9d18670]
9
+ - Updated dependencies [7934e0d]
10
+ - Updated dependencies [757756b]
11
+ - Updated dependencies [14c92f6]
12
+ - Updated dependencies [3e58d68]
13
+ - rollipop@1.0.0-alpha.21
14
+
15
+ ## 0.1.0-alpha.20
16
+
17
+ ### Patch Changes
18
+
19
+ - 140902b: impl @rollipop/plugin-module-federation
20
+ - Updated dependencies [d6e10db]
21
+ - Updated dependencies [140902b]
22
+ - rollipop@0.1.0-alpha.20
package/README.md CHANGED
@@ -1 +1,127 @@
1
- # rollipop-empty
1
+ # @rollipop/plugin-module-federation
2
+
3
+ Module Federation for Rollipop. Wires `@module-federation/runtime` into the host bundle and emits a self-contained IIFE for each remote, with shared dependencies resolved through a host-owned global registry.
4
+
5
+ ## Status
6
+
7
+ > **Initial implementation.** This plugin is an early-stage feature. The public
8
+ > API and the bundle layout may change, and the role model is intentionally
9
+ > narrower than standard Module Federation — see [Roles](#roles) and
10
+ > [Constraints](#constraints).
11
+ >
12
+ > Notably, a single config takes exactly one role (host **or** remote). Standard
13
+ > (web) Module Federation lets one build both `expose` modules and `consume`
14
+ > remotes; that bidirectional / chained-federation model is **not supported
15
+ > yet**. Lifting it requires emitting the federation container as a separate
16
+ > artifact from the app bundle, which is not implemented at this stage.
17
+
18
+ ## Roles
19
+
20
+ A single Rollipop config takes one role. Defining both `remotes` and `exposes` throws.
21
+
22
+ | Role | Has | Bundle output |
23
+ | ------ | --------- | ---------------------------------------------------------------------------- |
24
+ | Host | `remotes` | Normal Rollipop bundle. `import('<remote>/<expose>')` is rewritten at build. |
25
+ | Remote | `exposes` | IIFE that registers a federation container on `globalThis`. |
26
+
27
+ ## Host
28
+
29
+ ```ts
30
+ // rollipop.host.config.ts
31
+ import { federation } from '@rollipop/plugin-module-federation';
32
+ import { defineConfig } from 'rollipop';
33
+
34
+ export default defineConfig({
35
+ entry: 'src/host/index.js',
36
+ plugins: [
37
+ federation({
38
+ name: 'host_app',
39
+ remotes: {
40
+ remote_app: 'remote_app@http://localhost:8082/index.bundle?platform=ios',
41
+ },
42
+ shared: {
43
+ react: { singleton: true, eager: true, requiredVersion: '19.2.3' },
44
+ 'react-native': { singleton: true, eager: true, requiredVersion: '0.84.1' },
45
+ },
46
+ runtime: {
47
+ // Register a `ModuleFederationScriptLoader` on `globalThis.__rollipop_script_loader__`.
48
+ // Typically delegates to a native TurboModule that does fetch + JSI evaluate.
49
+ implement: `
50
+ globalThis.__rollipop_script_loader__ = {
51
+ async loadScript({ scriptId, url }) {
52
+ await NativeScriptManager.loadScript(scriptId, { url });
53
+ },
54
+ };
55
+ `,
56
+ },
57
+ }),
58
+ ],
59
+ });
60
+ ```
61
+
62
+ In your app, consume the remote with dynamic import:
63
+
64
+ ```ts
65
+ const RemoteNavigator = React.lazy(() =>
66
+ import('remote_app/RemoteNavigator').then((m) => ({ default: m.default ?? m })),
67
+ );
68
+ ```
69
+
70
+ The plugin rewrites the `import()` call into a `loadRemote` invocation against the federation runtime — no native `import()` evaluation happens at runtime.
71
+
72
+ ## Remote
73
+
74
+ ```ts
75
+ // rollipop.remote.config.ts
76
+ import { federation } from '@rollipop/plugin-module-federation';
77
+ import { defineConfig } from 'rollipop';
78
+
79
+ export default defineConfig({
80
+ entry: 'src/remote/index.js',
81
+ plugins: [
82
+ federation({
83
+ name: 'remote_app',
84
+ exposes: {
85
+ './RemoteNavigator': './src/remote/exposed/RemoteNavigator.tsx',
86
+ },
87
+ shared: {
88
+ react: { singleton: true, requiredVersion: '19.2.3' },
89
+ 'react-native': { singleton: true, requiredVersion: '0.84.1' },
90
+ },
91
+ }),
92
+ ],
93
+ });
94
+ ```
95
+
96
+ The bundle is emitted as IIFE. Each shared dep import (e.g. `import RN from 'react-native'`) is replaced with a stub that reads from the host's `globalThis.__rollipop_shared__` registry and throws if the dep is not registered. Rollipop's prelude / polyfills are skipped because the host has already initialized the React Native runtime.
97
+
98
+ ## Script loader contract
99
+
100
+ Defined at `@rollipop/plugin-module-federation/runtime`:
101
+
102
+ ```ts
103
+ export interface ModuleFederationScriptLoader {
104
+ loadScript(args: { scriptId: string; url: string; parentUrl?: string }): Promise<void>;
105
+ }
106
+ ```
107
+
108
+ The host registers an implementation on `globalThis.__rollipop_script_loader__`. The plugin's MF runtime adapter delegates `loadEntry` to it. After the script is evaluated the container is read back from `globalThis[<entryGlobalName>]`.
109
+
110
+ ## Globals exposed at runtime
111
+
112
+ | Global | Owner | Purpose |
113
+ | ---------------------------- | ------ | ----------------------------------------------------------------------- |
114
+ | `__rollipop_script_loader__` | user | `ModuleFederationScriptLoader` implementation. |
115
+ | `__rollipop_shared__` | host | Shared dependency registry. Lazily populated; missing keys throw. |
116
+ | `__rollipop_load_remote__` | plugin | `(id: string) => Promise<unknown>` — wraps the federation `loadRemote`. |
117
+
118
+ ## Constraints
119
+
120
+ - A single config cannot define both `remotes` and `exposes` — this is a
121
+ current-implementation limitation, not an inherent Module Federation rule.
122
+ Split a host and a remote into two configs and run them as separate Rollipop
123
+ processes. See [Status](#status).
124
+ - React Native does not support native `import()`. Static imports of remote modules are intentionally not supported — use dynamic `import('<remote>/<expose>')`.
125
+ - Subpath imports of shared deps (`react/jsx-dev-runtime`, etc.) are bundled into the remote and consume the parent shared instance through `__rollipop_shared__`.
126
+
127
+ See `examples/module-federation` for a working RN 0.84 host + remote setup with a Pure C++/Obj-C++ TurboModule script loader.
@@ -0,0 +1,72 @@
1
+ import { Plugin } from "rollipop";
2
+
3
+ //#region src/types.d.ts
4
+ /**
5
+ * Module Federation plugin configuration.
6
+ *
7
+ * A single config takes one role at a time:
8
+ *
9
+ * - **Host** — declare `remotes`. The bundle consumes federated modules via `import('<remote>/<expose>')`,
10
+ * which the plugin rewrites to a runtime `loadRemote` call against the host's federation instance.
11
+ * - **Remote** — declare `exposes`. The plugin emits an IIFE bundle whose container is registered on `globalThis`
12
+ * so the host can read it after the script is evaluated by the user-provided script loader.
13
+ *
14
+ * Defining both `remotes` and `exposes` in the same config throws.
15
+ * Split into two configs and run them as separate Rollipop processes.
16
+ */
17
+ interface ModuleFederationConfig {
18
+ /**
19
+ * Federation name. Must be a non-empty string and stable across builds.
20
+ */
21
+ name: string;
22
+ /**
23
+ * Remotes consumed by this bundle (host role).
24
+ *
25
+ * The value is either the remote entry URL or an object form. Either way,
26
+ * `entry` is the URL the user-provided script loader fetches at runtime.
27
+ */
28
+ remotes?: Record<string, string | ModuleFederationRemoteConfig>;
29
+ /**
30
+ * Modules exposed to other federated bundles (remote role).
31
+ *
32
+ * Each key is the public path (must start with `'./'`).
33
+ * The value is the source file to expose, resolved relative to the project root.
34
+ */
35
+ exposes?: Record<string, string>;
36
+ /**
37
+ * Shared dependencies that the host owns and remotes consume from the shared registry.
38
+ *
39
+ * Use the array form for the simplest case (versions are read from `node_modules`).
40
+ * The object form lets each entry tune `requiredVersion`, `singleton`, and `eager`.
41
+ */
42
+ shared?: string[] | Record<string, string | ModuleFederationSharedDependencyConfig>;
43
+ /**
44
+ * Share resolution strategy passed through to `@module-federation/runtime`.
45
+ */
46
+ shareStrategy?: 'version-first' | 'loaded-first';
47
+ /**
48
+ * Runtime configuration for the host bundle.
49
+ *
50
+ * `implement` is raw source that registers `globalThis.__rollipop_script_loader__` with a `ModuleFederationScriptLoader` implementation.
51
+ * Injected as a polyfill so it runs before any module init.
52
+ */
53
+ runtime?: ModuleFederationRuntimeConfig;
54
+ }
55
+ interface ModuleFederationRemoteConfig {
56
+ name: string;
57
+ entry: string;
58
+ type?: 'var';
59
+ }
60
+ interface ModuleFederationSharedDependencyConfig {
61
+ requiredVersion?: string;
62
+ singleton?: boolean;
63
+ eager?: boolean;
64
+ }
65
+ interface ModuleFederationRuntimeConfig {
66
+ implement: string;
67
+ }
68
+ //#endregion
69
+ //#region src/plugin.d.ts
70
+ declare function moduleFederationPlugin(config: ModuleFederationConfig): Plugin;
71
+ //#endregion
72
+ export { type ModuleFederationConfig, type ModuleFederationRemoteConfig, type ModuleFederationRuntimeConfig, type ModuleFederationSharedDependencyConfig, moduleFederationPlugin as federation };
package/dist/index.js ADDED
@@ -0,0 +1,607 @@
1
+ import { createRequire } from "node:module";
2
+ import path from "node:path";
3
+ import MagicString from "magic-string";
4
+ import { id, include, prefixRegex } from "rollipop/pluginutils";
5
+ import baseDedent from "dedent";
6
+ import fs from "node:fs";
7
+ //#region src/constants.ts
8
+ const PLUGIN_NAME = "rollipop:module-federation";
9
+ const VIRTUAL_PREFIX = "\0rollipop:module-federation:";
10
+ const VIRTUAL_HOST_INIT_ID = `${VIRTUAL_PREFIX}host-init`;
11
+ const VIRTUAL_RUNTIME_ADAPTER_ID = `${VIRTUAL_PREFIX}runtime-adapter`;
12
+ const VIRTUAL_SHARE_SCOPE_ID = `${VIRTUAL_PREFIX}share-scope`;
13
+ `${VIRTUAL_PREFIX}`;
14
+ const VIRTUAL_SHARED_SHIM_PREFIX = `${VIRTUAL_PREFIX}shared:`;
15
+ const VIRTUAL_REMOTE_PROXY_PREFIX = `${VIRTUAL_PREFIX}remote:`;
16
+ const SCRIPT_LOADER_GLOBAL = "__rollipop_script_loader__";
17
+ const SHARED_REGISTRY_GLOBAL = "__rollipop_shared__";
18
+ const REMOTE_CACHE_GLOBAL = "__rollipop_module_federation_cache__";
19
+ const HMR_HOT_PATH = "/hot";
20
+ const HMR_EVENT = "mf:remote-update";
21
+ //#endregion
22
+ //#region src/virtual/_dedent.ts
23
+ const dedent = baseDedent.withOptions({ escapeSpecialCharacters: false });
24
+ const Q = "\\u0027";
25
+ //#endregion
26
+ //#region src/virtual/host-init.ts
27
+ function generateHostInitCode(config) {
28
+ const remotesArray = Object.values(config.remotes).map((remote) => ({
29
+ name: remote.name,
30
+ entry: remote.entry,
31
+ type: remote.type,
32
+ entryGlobalName: remote.entryGlobalName
33
+ }));
34
+ const sharedEntries = Object.entries(config.shared);
35
+ const sharedRegistryPropExprs = sharedEntries.map(([name]) => ` ${JSON.stringify(name)}: require(${JSON.stringify(name)}),`).join("\n");
36
+ const sharedRuntimeMap = sharedEntries.reduce((acc, [name, info]) => ({
37
+ ...acc,
38
+ [name]: {
39
+ version: info.version,
40
+ shareConfig: {
41
+ singleton: info.singleton,
42
+ requiredVersion: info.requiredVersion ?? false,
43
+ eager: info.eager
44
+ }
45
+ }
46
+ }), {});
47
+ return dedent`
48
+ import adapter from ${JSON.stringify(VIRTUAL_RUNTIME_ADAPTER_ID)};
49
+ import { createInstance } from '@module-federation/runtime';
50
+
51
+ globalThis.${SHARED_REGISTRY_GLOBAL} = globalThis.${SHARED_REGISTRY_GLOBAL} || {
52
+ ${sharedRegistryPropExprs}
53
+ };
54
+
55
+ const sharedConfig = ${JSON.stringify(sharedRuntimeMap, null, 2)};
56
+ for (const sharedName of Object.keys(sharedConfig)) {
57
+ sharedConfig[sharedName].lib = () => globalThis.${SHARED_REGISTRY_GLOBAL}[sharedName];
58
+ }
59
+
60
+ const instance = createInstance({
61
+ name: ${JSON.stringify(config.name)},
62
+ remotes: ${JSON.stringify(remotesArray, null, 2)},
63
+ shared: sharedConfig,
64
+ plugins: [adapter],
65
+ shareStrategy: ${JSON.stringify(config.shareStrategy)},
66
+ });
67
+
68
+ const remoteList = ${JSON.stringify(remotesArray)};
69
+
70
+ if (globalThis.${REMOTE_CACHE_GLOBAL} == null) {
71
+ const cache = {
72
+ modules: Object.create(null),
73
+ pending: Object.create(null),
74
+ // Per-id invalidation: only ids actually present in the cache when
75
+ // HMR fired need to bypass the federation runtime. Cleared after
76
+ // each successful bypass load — new ids fall back to the normal
77
+ // loadRemote flow with full shared-module negotiation.
78
+ invalidatedIds: new Set(),
79
+ subscribers: new Set(),
80
+ load(id) {
81
+ if (this.modules[id] !== undefined) {
82
+ return Promise.resolve(this.modules[id]);
83
+ }
84
+ if (this.pending[id]) {
85
+ return this.pending[id];
86
+ }
87
+ const isInvalidated = this.invalidatedIds.has(id);
88
+ let fetcher;
89
+ if (isInvalidated) {
90
+ const slash = id.indexOf('/');
91
+ const remoteName = slash === -1 ? id : id.slice(0, slash);
92
+ const exposePath = slash === -1 ? '.' : './' + id.slice(slash + 1);
93
+ fetcher = Promise.resolve().then(() => {
94
+ const container = globalThis[remoteName];
95
+ if (container == null) {
96
+ throw new Error('[rollipop:module-federation] container ${Q}' + remoteName + '${Q} not registered');
97
+ }
98
+ return container.get(exposePath).then((factory) => {
99
+ return typeof factory === 'function' ? factory() : factory;
100
+ });
101
+ });
102
+ } else {
103
+ fetcher = instance.loadRemote(id);
104
+ }
105
+ this.pending[id] = fetcher.then((mod) => {
106
+ this.modules[id] = mod;
107
+ delete this.pending[id];
108
+ if (isInvalidated) {
109
+ this.invalidatedIds.delete(id);
110
+ }
111
+ return mod;
112
+ });
113
+ return this.pending[id];
114
+ },
115
+ invalidate(remoteName) {
116
+ for (const key of Object.keys(this.modules)) {
117
+ if (key === remoteName || key.startsWith(remoteName + '/')) {
118
+ this.invalidatedIds.add(key);
119
+ delete this.modules[key];
120
+ }
121
+ }
122
+ for (const cb of this.subscribers) {
123
+ try {
124
+ cb(remoteName);
125
+ } catch (_e) {}
126
+ }
127
+ },
128
+ };
129
+ globalThis.${REMOTE_CACHE_GLOBAL} = cache;
130
+ }
131
+
132
+ const __cache = globalThis.${REMOTE_CACHE_GLOBAL};
133
+
134
+ async function applyRemoteUpdate(remote) {
135
+ const generation = (remote.__mfHmrGen = (remote.__mfHmrGen || 0) + 1);
136
+ const cacheBust = (remote.entry.indexOf('?') === -1 ? '?' : '&') + '_mf_hmr=' + generation;
137
+ try {
138
+ await globalThis.${SCRIPT_LOADER_GLOBAL}.loadScript({
139
+ scriptId: remote.name + '@' + generation,
140
+ url: remote.entry + cacheBust,
141
+ });
142
+ } catch (error) {
143
+ console.error(
144
+ '[rollipop:module-federation] failed to reload remote ${Q}' + remote.name + '${Q} during HMR. App is running stale code until next successful update.',
145
+ error,
146
+ );
147
+ return;
148
+ }
149
+ __cache.invalidate(remote.name);
150
+ }
151
+
152
+ function subscribeRemoteHmr(remote) {
153
+ let wsUrl;
154
+ try {
155
+ const url = new URL(remote.entry);
156
+ const wsProtocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
157
+ wsUrl = wsProtocol + '//' + url.host + ${JSON.stringify(HMR_HOT_PATH)};
158
+ } catch (_e) {
159
+ return;
160
+ }
161
+
162
+ const connect = () => {
163
+ let socket;
164
+ try {
165
+ socket = new WebSocket(wsUrl);
166
+ } catch (_e) {
167
+ setTimeout(connect, 1000);
168
+ return;
169
+ }
170
+ socket.onmessage = (event) => {
171
+ let parsed;
172
+ try {
173
+ parsed = JSON.parse(typeof event.data === 'string' ? event.data : '');
174
+ } catch (_e) {
175
+ return;
176
+ }
177
+ if (parsed == null || parsed.type !== ${JSON.stringify(HMR_EVENT)}) {
178
+ return;
179
+ }
180
+ if (parsed.payload != null && parsed.payload.name !== remote.name) {
181
+ return;
182
+ }
183
+ applyRemoteUpdate(remote);
184
+ };
185
+ socket.onclose = () => setTimeout(connect, 1000);
186
+ socket.onerror = () => {
187
+ try {
188
+ socket.close();
189
+ } catch (_e) {}
190
+ };
191
+ };
192
+ connect();
193
+ }
194
+
195
+ // Defer to the next tick so React Native's InitializeCore has set up
196
+ // WebSocket / setTimeout before the subscription starts.
197
+ if (typeof __DEV__ !== 'undefined' && __DEV__) {
198
+ Promise.resolve().then(() => {
199
+ if (typeof WebSocket === 'undefined') {
200
+ return;
201
+ }
202
+ for (const r of remoteList) {
203
+ subscribeRemoteHmr(r);
204
+ }
205
+ });
206
+ }
207
+ `;
208
+ }
209
+ //#endregion
210
+ //#region src/virtual/runtime-adapter.ts
211
+ function generateRuntimeAdapterCode() {
212
+ return dedent`
213
+ const adapter = {
214
+ name: 'rollipop-script-loader-adapter',
215
+ async loadEntry({ remoteInfo }) {
216
+ const loader = globalThis.${SCRIPT_LOADER_GLOBAL};
217
+ if (loader == null) {
218
+ throw new Error(
219
+ '[${PLUGIN_NAME}] ${Q}globalThis.${SCRIPT_LOADER_GLOBAL}${Q} is not registered. Provide ${Q}runtime.implement${Q} in plugin config.'
220
+ );
221
+ }
222
+ await loader.loadScript({
223
+ scriptId: remoteInfo.name,
224
+ url: remoteInfo.entry,
225
+ });
226
+ const container = globalThis[remoteInfo.entryGlobalName];
227
+ if (container == null) {
228
+ throw new Error(
229
+ '[${PLUGIN_NAME}] Remote container ${Q}' + remoteInfo.entryGlobalName + '${Q} was not registered after script load.'
230
+ );
231
+ }
232
+ return container;
233
+ },
234
+ };
235
+
236
+ export default adapter;
237
+ `;
238
+ }
239
+ //#endregion
240
+ //#region src/virtual/share-scope.ts
241
+ function generateShareScopeCode() {
242
+ return dedent`
243
+ export { loadRemote, loadShare, loadShareSync } from '@module-federation/runtime';
244
+ `;
245
+ }
246
+ //#endregion
247
+ //#region src/host/load.ts
248
+ function loadVirtualModule(id, config) {
249
+ switch (id) {
250
+ case VIRTUAL_RUNTIME_ADAPTER_ID: return generateRuntimeAdapterCode();
251
+ case VIRTUAL_HOST_INIT_ID: return generateHostInitCode(config);
252
+ case VIRTUAL_SHARE_SCOPE_ID: return generateShareScopeCode();
253
+ default: return null;
254
+ }
255
+ }
256
+ //#endregion
257
+ //#region src/host/resolve.ts
258
+ function resolveVirtualId(source) {
259
+ if (source.startsWith("\0rollipop:module-federation:")) return source;
260
+ return null;
261
+ }
262
+ //#endregion
263
+ //#region src/host/transform.ts
264
+ function transformHostEntry(code, id) {
265
+ const magicString = new MagicString(code);
266
+ magicString.prepend(`import ${JSON.stringify(VIRTUAL_HOST_INIT_ID)};\n`);
267
+ return {
268
+ code: magicString.toString(),
269
+ map: magicString.generateMap({
270
+ hires: true,
271
+ source: id
272
+ })
273
+ };
274
+ }
275
+ //#endregion
276
+ //#region src/shared/resolve-version.ts
277
+ function resolveSharedVersion(packageName, projectRoot) {
278
+ try {
279
+ const packageJsonPath = createRequire(`${projectRoot}/__placeholder__.js`).resolve(`${packageName}/package.json`);
280
+ const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf-8"));
281
+ return typeof packageJson.version === "string" ? packageJson.version : void 0;
282
+ } catch {
283
+ return;
284
+ }
285
+ }
286
+ //#endregion
287
+ //#region src/normalize.ts
288
+ const DEFAULT_SHARE_STRATEGY = "version-first";
289
+ function normalizeConfig(config, projectRoot) {
290
+ validateConfig(config);
291
+ const remotes = normalizeRemotes(config.remotes ?? {});
292
+ const exposes = config.exposes ?? {};
293
+ const shared = normalizeShared(config.shared ?? [], projectRoot);
294
+ return {
295
+ name: config.name,
296
+ remotes,
297
+ exposes,
298
+ shared,
299
+ shareStrategy: config.shareStrategy ?? DEFAULT_SHARE_STRATEGY,
300
+ runtime: config.runtime,
301
+ hasRemotes: Object.keys(remotes).length > 0,
302
+ hasExposes: Object.keys(exposes).length > 0
303
+ };
304
+ }
305
+ function normalizeRemotes(remotes) {
306
+ return Object.entries(remotes).reduce((acc, [key, value]) => {
307
+ const remote = typeof value === "string" ? parseRemoteString(key, value) : value;
308
+ return {
309
+ ...acc,
310
+ [key]: {
311
+ name: remote.name,
312
+ entry: remote.entry,
313
+ type: remote.type ?? "var",
314
+ entryGlobalName: remote.name
315
+ }
316
+ };
317
+ }, {});
318
+ }
319
+ function parseRemoteString(key, value) {
320
+ const at = value.indexOf("@");
321
+ if (at <= 0) return {
322
+ name: key,
323
+ entry: value
324
+ };
325
+ return {
326
+ name: value.slice(0, at),
327
+ entry: value.slice(at + 1)
328
+ };
329
+ }
330
+ function normalizeShared(shared, projectRoot) {
331
+ return (Array.isArray(shared) ? shared.map((name) => [name, {}]) : Object.entries(shared).map(([name, value]) => typeof value === "string" ? [name, { requiredVersion: value }] : [name, value])).reduce((acc, [name, opts]) => ({
332
+ ...acc,
333
+ [name]: {
334
+ version: resolveSharedVersion(name, projectRoot),
335
+ requiredVersion: opts.requiredVersion,
336
+ singleton: opts.singleton ?? false,
337
+ eager: opts.eager ?? false
338
+ }
339
+ }), {});
340
+ }
341
+ function validateConfig(config) {
342
+ if (!config.name || typeof config.name !== "string") throw new Error(`[${PLUGIN_NAME}] 'name' is required and must be a non-empty string`);
343
+ if (config.remotes != null) for (const [key, value] of Object.entries(config.remotes)) {
344
+ if (typeof value === "string") continue;
345
+ if (!value.name || !value.entry) throw new Error(`[${PLUGIN_NAME}] Remote '${key}' must have both 'name' and 'entry'`);
346
+ }
347
+ if (config.exposes != null) for (const [key, value] of Object.entries(config.exposes)) {
348
+ if (!key.startsWith("./")) throw new Error(`[${PLUGIN_NAME}] Expose key '${key}' must start with './'`);
349
+ if (typeof value !== "string") throw new Error(`[${PLUGIN_NAME}] Expose value for '${key}' must be a string file path`);
350
+ }
351
+ }
352
+ //#endregion
353
+ //#region src/virtual/remote-entry.ts
354
+ function generateRemoteEntryCode(options) {
355
+ const exposeEntries = Object.entries(options.exposes);
356
+ return dedent`
357
+ ${exposeEntries.map(([, filePath], index) => `import * as __expose_${index} from ${JSON.stringify(filePath)};`).join("\n")}
358
+
359
+ const moduleMap = {
360
+ ${exposeEntries.map(([key], index) => ` ${JSON.stringify(key)}: () => __expose_${index},`).join("\n")}
361
+ };
362
+
363
+ const container = {
364
+ init() {},
365
+ async get(path) {
366
+ const factory = moduleMap[path];
367
+ if (factory == null) {
368
+ throw new Error('[${PLUGIN_NAME}] Module ${Q}' + path + '${Q} is not exposed by ${Q}${options.name}${Q}');
369
+ }
370
+ return factory;
371
+ },
372
+ };
373
+
374
+ globalThis[${JSON.stringify(options.name)}] = container;
375
+ globalThis.${SHARED_REGISTRY_GLOBAL} = globalThis.${SHARED_REGISTRY_GLOBAL} || {};
376
+ `;
377
+ }
378
+ //#endregion
379
+ //#region src/virtual/remote-proxy.ts
380
+ function generateRemoteProxyCode({ remoteId, reactAware }) {
381
+ const idLiteral = JSON.stringify(remoteId);
382
+ if (reactAware) return dedent`
383
+ import * as __mfReact from 'react';
384
+
385
+ const __cache = globalThis.${REMOTE_CACHE_GLOBAL};
386
+ const __id = ${idLiteral};
387
+
388
+ function __ensureLoaded() {
389
+ if (__cache.modules[__id] !== undefined) {
390
+ return null;
391
+ }
392
+ return __cache.load(__id);
393
+ }
394
+
395
+ function __getMod() {
396
+ return __cache.modules[__id];
397
+ }
398
+
399
+ function __FederatedProxy(props) {
400
+ const [, setVersion] = __mfReact.useState(0);
401
+ __mfReact.useEffect(() => {
402
+ const listener = () => setVersion((v) => v + 1);
403
+ __cache.subscribers.add(listener);
404
+ return () => {
405
+ __cache.subscribers.delete(listener);
406
+ };
407
+ }, []);
408
+
409
+ const mod = __getMod();
410
+ if (mod === undefined) {
411
+ throw __ensureLoaded();
412
+ }
413
+ const fn = mod.default ?? mod;
414
+ return __mfReact.createElement(fn, props);
415
+ }
416
+
417
+ const __proxy = new Proxy(__FederatedProxy, {
418
+ get(target, prop) {
419
+ if (prop === '__esModule') {
420
+ return true;
421
+ }
422
+ if (prop === 'then') {
423
+ return undefined;
424
+ }
425
+ const mod = __getMod();
426
+ if (mod === undefined) {
427
+ throw __ensureLoaded();
428
+ }
429
+ if (prop in mod) {
430
+ return mod[prop];
431
+ }
432
+ if (mod.default != null && prop in mod.default) {
433
+ return mod.default[prop];
434
+ }
435
+ return target[prop];
436
+ },
437
+ });
438
+
439
+ export default __proxy;
440
+ `;
441
+ return dedent`
442
+ const __cache = globalThis.${REMOTE_CACHE_GLOBAL};
443
+ const __id = ${idLiteral};
444
+
445
+ function __getMod() {
446
+ if (__cache.modules[__id] !== undefined) {
447
+ return __cache.modules[__id];
448
+ }
449
+ throw __cache.load(__id);
450
+ }
451
+
452
+ function __invoke(...args) {
453
+ const mod = __getMod();
454
+ const fn = mod.default ?? mod;
455
+ return fn.apply(this, args);
456
+ }
457
+
458
+ const __proxy = new Proxy(__invoke, {
459
+ get(target, prop) {
460
+ if (prop === '__esModule') {
461
+ return true;
462
+ }
463
+ if (prop === 'then') {
464
+ return undefined;
465
+ }
466
+ const mod = __getMod();
467
+ if (prop in mod) {
468
+ return mod[prop];
469
+ }
470
+ if (mod.default != null && prop in mod.default) {
471
+ return mod.default[prop];
472
+ }
473
+ return target[prop];
474
+ },
475
+ });
476
+
477
+ export default __proxy;
478
+ `;
479
+ }
480
+ //#endregion
481
+ //#region src/virtual/shared-shim.ts
482
+ function generateSharedShimCode(sharedName) {
483
+ return dedent`
484
+ const __mod = globalThis.${SHARED_REGISTRY_GLOBAL} && globalThis.${SHARED_REGISTRY_GLOBAL}[${JSON.stringify(sharedName)}];
485
+ if (__mod == null) {
486
+ throw new Error('[${PLUGIN_NAME}] shared module ${Q}${sharedName}${Q} is not registered on the host. Add it to the host config${Q}s ${Q}shared${Q} field.');
487
+ }
488
+ module.exports = __mod;
489
+ `;
490
+ }
491
+ //#endregion
492
+ //#region src/plugin.ts
493
+ function moduleFederationPlugin(config) {
494
+ const federationConfig = config;
495
+ const hasRemotes = Object.keys(federationConfig.remotes ?? {}).length > 0;
496
+ const hasExposes = Object.keys(federationConfig.exposes ?? {}).length > 0;
497
+ if (hasRemotes && hasExposes) throw new Error(`[${PLUGIN_NAME}] A single config cannot define both 'remotes' and 'exposes'. Split into two configs (one host, one remote) and run them as separate Rollipop processes.`);
498
+ let resolvedConfig = null;
499
+ let normalized = null;
500
+ let broadcast = null;
501
+ const exposesAbsolute = {};
502
+ const sharedRoots = new Set(collectSharedNames(federationConfig));
503
+ let debounceTimer = null;
504
+ return {
505
+ name: PLUGIN_NAME,
506
+ config(pluginConfig) {
507
+ const serializer = pluginConfig.serializer ??= {};
508
+ if (hasExposes) {
509
+ serializer.prelude = [];
510
+ serializer.polyfills = [];
511
+ return { dangerously_overrideRolldownOptions: (opts) => ({
512
+ input: opts.input,
513
+ output: {
514
+ ...opts.output,
515
+ format: "iife"
516
+ }
517
+ }) };
518
+ }
519
+ if (hasRemotes && config.runtime?.implement != null) (serializer.polyfills ??= []).push({
520
+ type: "iife",
521
+ code: config.runtime.implement
522
+ });
523
+ },
524
+ configResolved(config) {
525
+ resolvedConfig = config;
526
+ normalized = normalizeConfig(federationConfig, config.root);
527
+ if (hasExposes) for (const [key, filePath] of Object.entries(normalized.exposes)) exposesAbsolute[key] = path.resolve(config.root, filePath);
528
+ },
529
+ resolveId: { handler(source) {
530
+ if (hasExposes && sharedRoots.has(source)) return { id: VIRTUAL_SHARED_SHIM_PREFIX + source };
531
+ if (hasRemotes && normalized != null) {
532
+ const remoteNames = Object.keys(normalized.remotes);
533
+ for (const name of remoteNames) if (source === name || source.startsWith(`${name}/`)) return { id: VIRTUAL_REMOTE_PROXY_PREFIX + source };
534
+ }
535
+ const resolved = resolveVirtualId(source);
536
+ if (resolved != null) return { id: resolved };
537
+ return null;
538
+ } },
539
+ load: {
540
+ filter: [include(id(prefixRegex(VIRTUAL_PREFIX)))],
541
+ handler(id) {
542
+ if (id.startsWith(VIRTUAL_SHARED_SHIM_PREFIX)) return {
543
+ code: generateSharedShimCode(id.slice(VIRTUAL_SHARED_SHIM_PREFIX.length)),
544
+ moduleType: "js"
545
+ };
546
+ if (id.startsWith(VIRTUAL_REMOTE_PROXY_PREFIX)) return {
547
+ code: generateRemoteProxyCode({
548
+ remoteId: id.slice(VIRTUAL_REMOTE_PROXY_PREFIX.length),
549
+ reactAware: resolvedConfig?.mode !== "production"
550
+ }),
551
+ moduleType: "js"
552
+ };
553
+ if (normalized == null) return null;
554
+ const code = loadVirtualModule(id, normalized);
555
+ if (code != null) return {
556
+ code,
557
+ moduleType: "js"
558
+ };
559
+ return null;
560
+ }
561
+ },
562
+ transform: { handler(code, id) {
563
+ if (normalized == null || resolvedConfig == null) return null;
564
+ if (id.startsWith("\0rollipop:module-federation:")) return null;
565
+ const isEntry = this.getModuleInfo(id)?.isEntry ?? false;
566
+ if (hasExposes) {
567
+ if (!isEntry) return null;
568
+ const containerCode = generateRemoteEntryCode({
569
+ name: normalized.name,
570
+ exposes: exposesAbsolute
571
+ });
572
+ const ms = new MagicString(code);
573
+ ms.append("\n" + containerCode);
574
+ return {
575
+ code: ms.toString(),
576
+ map: ms.generateMap({
577
+ hires: true,
578
+ source: id
579
+ })
580
+ };
581
+ }
582
+ if (hasRemotes && isEntry) return transformHostEntry(code, id);
583
+ return null;
584
+ } },
585
+ watchChange() {
586
+ if (!hasExposes || broadcast == null) return;
587
+ if (debounceTimer != null) clearTimeout(debounceTimer);
588
+ debounceTimer = setTimeout(() => {
589
+ debounceTimer = null;
590
+ broadcast?.();
591
+ }, 100);
592
+ },
593
+ configureServer(server) {
594
+ if (!hasExposes) return;
595
+ broadcast = () => {
596
+ server.hot.sendAll(HMR_EVENT, { name: federationConfig.name });
597
+ };
598
+ }
599
+ };
600
+ }
601
+ function collectSharedNames(config) {
602
+ const shared = config.shared;
603
+ if (shared == null) return [];
604
+ return Array.isArray(shared) ? shared : Object.keys(shared);
605
+ }
606
+ //#endregion
607
+ export { moduleFederationPlugin as federation };
@@ -0,0 +1,17 @@
1
+ //#region src/runtime.d.ts
2
+ interface ModuleFederationScriptLoader {
3
+ loadScript(args: {
4
+ scriptId: string;
5
+ url: string;
6
+ parentUrl?: string;
7
+ }): Promise<void>;
8
+ }
9
+ interface RemoteEntryExports {
10
+ init(shareScope: unknown, initScope?: unknown[]): void | Promise<void>;
11
+ get(modulePath: string): () => Promise<unknown>;
12
+ }
13
+ declare global {
14
+ var __rollipop_script_loader__: ModuleFederationScriptLoader | undefined;
15
+ }
16
+ //#endregion
17
+ export { ModuleFederationScriptLoader, RemoteEntryExports };
@@ -0,0 +1 @@
1
+ export {};
package/package.json CHANGED
@@ -1,4 +1,52 @@
1
1
  {
2
2
  "name": "@rollipop/plugin-module-federation",
3
- "version": "0.0.0"
4
- }
3
+ "version": "1.0.0-alpha.21",
4
+ "homepage": "https://github.com/leegeunhyeok/rollipop#readme",
5
+ "bugs": {
6
+ "url": "https://github.com/leegeunhyeok/rollipop/issues"
7
+ },
8
+ "license": "MIT",
9
+ "author": "leegeunhyeok <dev.ghlee@gmail.com> (https://github.com/leegeunhyeok)",
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "git+https://github.com/leegeunhyeok/rollipop.git",
13
+ "directory": "packages/plugin-module-federation"
14
+ },
15
+ "files": [
16
+ "dist"
17
+ ],
18
+ "type": "module",
19
+ "main": "./dist/index.js",
20
+ "types": "./dist/index.d.ts",
21
+ "exports": {
22
+ ".": {
23
+ "types": "./dist/index.d.ts",
24
+ "default": "./dist/index.js"
25
+ },
26
+ "./runtime": {
27
+ "types": "./dist/runtime.d.ts",
28
+ "default": "./dist/runtime.js"
29
+ },
30
+ "./package.json": "./package.json"
31
+ },
32
+ "scripts": {
33
+ "prepack": "yarn build",
34
+ "typecheck": "tsc --noEmit",
35
+ "test": "vp test --run",
36
+ "build": "vp pack"
37
+ },
38
+ "dependencies": {
39
+ "@module-federation/runtime": "^2.4.0",
40
+ "@module-federation/sdk": "^2.4.0",
41
+ "dedent": "^1.7.2",
42
+ "magic-string": "^0.30.21"
43
+ },
44
+ "devDependencies": {
45
+ "rollipop": "1.0.0-alpha.21",
46
+ "typescript": "6.0.3",
47
+ "vite-plus": "latest"
48
+ },
49
+ "peerDependencies": {
50
+ "rollipop": "1.0.0-alpha.21"
51
+ }
52
+ }
package/.editorconfig DELETED
@@ -1,10 +0,0 @@
1
- root = true
2
-
3
- [*]
4
- end_of_line = lf
5
- insert_final_newline = true
6
-
7
- [*.{js,json,yml}]
8
- charset = utf-8
9
- indent_style = space
10
- indent_size = 2
package/.gitattributes DELETED
@@ -1,4 +0,0 @@
1
- /.yarn/** linguist-vendored
2
- /.yarn/releases/* binary
3
- /.yarn/plugins/**/* binary
4
- /.pnp.* binary linguist-generated