Skip to content

Commit 449e935

Browse files
authored
Merge pull request #217 from valor-software/mf-in-mobile-apps-ns
another eduardo's article about mf & ns
2 parents f633c65 + f92eb71 commit 449e935

6 files changed

Lines changed: 381 additions & 0 deletions

File tree

41.5 KB
Loading
3.18 MB
Loading
12 KB
Loading
1.75 MB
Loading
Lines changed: 369 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,369 @@
1+
= Module Federation in mobile apps powered by NativeScript
2+
3+
== Introduction
4+
5+
Module federation has been one of the most popular topics in development lately. People love the way it allows teams to develop applications independently and integrate them all into a single final application. While that seems good for the web, how could Module Federation look in a mobile native application?
6+
7+
Let’s get the elephant out of the room first. The whole point of module federation is that teams can deploy their applications independently, but native apps have their bundles and code shipped holistically with the app. Even if they didn’t, having the user wait or be unable to load your app in bad or no connectivity would lead to terrible UX. Before going down this path, you need careful thought and a really good reason.
8+
9+
image::jeff-goldblum-jurassic-park.gif[]
10+
11+
So let’s start with a use case. One of our large enterprise clients has a WYSIWYG editor for NativeScript, complete with their own native components library. They have their own SSO and app “shell” that is common to all of their apps, but their users are able to customize the content, including pushing changes only to specific screens. To generate this they needed to be able to generate bundles dynamically and push them to the application so they could easily switch between apps, and update only the user’s bundle.
12+
13+
This application highlights one of the beauties of NativeScript. The users don’t need to have knowledge of native code at all, and if they need to extend something, they can do it directly in JavaScript or TypeScript, while also allowing them to add native code once they feel like they need it.
14+
15+
Now back to the application. This was initially built before bundlers were widely used, and once bundlers became the norm, it became a tricky situation where they’d need to map the available modules and override the require functions to provide the user code with the expected module. A mess. Enter Webpack Module Federation.
16+
17+
== Exposing an application
18+
19+
[, js]
20+
----
21+
import { Component, NgModule, NO_ERRORS_SCHEMA } from "@angular/core";
22+
import { RouterModule } from "@angular/router";
23+
import { NativeScriptCommonModule } from "@nativescript/angular";
24+
import { timer } from "rxjs";
25+
26+
@Component({
27+
template: `<Label>Hello from wmf! Here's a counter: {{ timer | async }}</Label>`,
28+
})
29+
export class MyComponent {
30+
timer = timer(0, 1000);
31+
}
32+
33+
@NgModule({
34+
declarations: [MyComponent],
35+
imports: [NativeScriptCommonModule, RouterModule.forChild([{ path: "", component: MyComponent }])],
36+
schemas: [NO_ERRORS_SCHEMA]
37+
})
38+
export class FederatedModule {}
39+
----
40+
41+
Since we’ll need to download all the JS files anyway, for testing purposes, I’ve made it all compile to a single chunk and discard the non-remote entrypoint. To do this I used the default NativeScript webpack config and augmented with a few details to build it directly to my current app’s assets directory.
42+
43+
[, js]
44+
----
45+
const webpack = require("@nativescript/webpack");
46+
const coreWebpack = require('webpack');
47+
const path = require(`path`);
48+
const NoEmitPlugin = require('no-emit-webpack-plugin');
49+
50+
module.exports = (env) => {
51+
webpack.init(env);
52+
53+
const packageJson = require('./package.json');
54+
55+
// Learn how to customize:
56+
// <https://docs.nativescript.org/webpack>
57+
58+
webpack.chainWebpack((config, env) => {
59+
config.entryPoints.clear();
60+
config.resolve.alias.set('~', path.join(__dirname, 'federated-src'));
61+
config.resolve.alias.set('@', path.join(__dirname, 'federated-src'));
62+
config.plugins.delete('CopyWebpackPlugin');
63+
config.output.path(path.join(__dirname, 'src', 'assets'));
64+
config.optimization.runtimeChunk(true);
65+
config.module.delete('bundle');
66+
config.plugin('NoEmitPlugin').use(NoEmitPlugin, ['dummy.js']);
67+
config.plugin('MaxChunks').use(coreWebpack.optimize.LimitChunkCountPlugin, [{ maxChunks: 1 }]);
68+
config.plugin('WebpackModuleFederationPlugin').use(coreWebpack.container.ModuleFederationPlugin, [{
69+
name: 'federated',
70+
exposes: {
71+
'./federated.module': './federated-src/federated.module.ts'
72+
},
73+
library: {
74+
type: 'commonjs'
75+
},
76+
shared: {
77+
'@nativescript/core': { eager: true, singleton: true, requiredVersion: "*", import: false },
78+
'@nativescript/angular': { eager: true, singleton: true, requiredVersion: "*", import: false },
79+
'@angular/core': { eager: true, singleton: true, requiredVersion: "*", import: false },
80+
'@angular/router': { eager: true, singleton: true, requiredVersion: "*", import: false }, }
81+
}]);
82+
});
83+
84+
const config = webpack.resolveConfig();
85+
config.entry = { 'dummy': './federated-src/federated.module.ts' };
86+
return config;
87+
};
88+
----
89+
90+
== Loading the remote entrypoint
91+
92+
One of the tricky parts of this whole process is that we can’t download the app piece by piece, as underneath we’re using commonjs (node’s require) to evaluate and load the modules into memory. To do this we need to download all of the output into the application and then we can load it.
93+
As a POC, we can start with a simple remote configuration which allows us to load the entrypoint as a normal module.
94+
95+
[, json]
96+
----
97+
// federated webpack config
98+
{
99+
name: 'federated',
100+
exposes: {
101+
'./federated.module': './federated-src/federated.module.ts'
102+
},
103+
library: {
104+
type: 'commonjs'
105+
},
106+
}
107+
108+
// host config
109+
110+
{
111+
remoteType: "commonjs",
112+
remotes: {
113+
"federated": "~/assets/federated.js"
114+
}
115+
}
116+
----
117+
118+
And the import it as a route like:
119+
120+
[, json]
121+
----
122+
{
123+
path: 'federated', loadChildren: () => import('federated/federated.module').then((m) => m.FederatedModule),
124+
}
125+
----
126+
127+
Unfortunately, we’d have to have all the federated modules shipped in the final application, so to load things dynamically, we should instead use the following code to load arbitrary entrypoints:
128+
129+
[, js]
130+
----
131+
/// <reference path="../../node_modules/webpack/module.d.ts" />
132+
133+
type Factory = () => any;
134+
type ShareScope = typeof __webpack_share_scopes__[string];
135+
136+
interface Container {
137+
init(shareScope: ShareScope): void;
138+
139+
get(module: string): Factory;
140+
}
141+
142+
export enum FileType {
143+
Component = "Component",
144+
Module = "Module",
145+
Css = "CSS",
146+
Html = "Html",
147+
}
148+
149+
export interface LoadRemoteFileOptions {
150+
// actual file being imported
151+
remoteEntry: string;
152+
// used as a "key" to store the file in the cache
153+
remoteName: string;
154+
// what file to import
155+
// must match the "exposes" property of the federated bundle
156+
// Example:
157+
// exposes: {'.': './file.ts', './otherFile': './some/path/otherFile.ts'}
158+
// calling this function with '.' will import './file.ts'
159+
// calling this function with './otherFile' will import './some/path/otherFile.ts'
160+
exposedFile: string;
161+
// mostly unused for the moment, just use Module
162+
// can be used in the future to change how to load specific files
163+
exposeFileType: FileType;
164+
}
165+
166+
export class MfeUtil {
167+
// holds list of loaded script
168+
private fileMap: Record<string, boolean> = {};
169+
private moduleMap: Record<string, Container> = {};
170+
171+
findExposedModule = async <T>(
172+
uniqueName: string,
173+
exposedFile: string
174+
): Promise<T | undefined> => {
175+
let Module: T | undefined;
176+
// Initializes the shared scope. Fills it with known provided modules from this build and all remotes
177+
await __webpack_init_sharing__("default");
178+
const container = this.moduleMap[uniqueName];
179+
// Initialize the container, it may provide shared modules
180+
await container.init(__webpack_share_scopes__.default);
181+
const factory = await container.get(exposedFile);
182+
Module = factory();
183+
return Module;
184+
};
185+
186+
public loadRootFromFile(filePath: string) {
187+
return this.loadRemoteFile({
188+
exposedFile: ".",
189+
exposeFileType: FileType.Module,
190+
remoteEntry: filePath,
191+
remoteName: filePath,
192+
});
193+
}
194+
195+
public loadRemoteFile = async (
196+
loadRemoteModuleOptions: LoadRemoteFileOptions
197+
): Promise<any> => {
198+
await this.loadRemoteEntry(
199+
loadRemoteModuleOptions.remoteEntry,
200+
loadRemoteModuleOptions.remoteName
201+
);
202+
return await this.findExposedModule<any>(
203+
loadRemoteModuleOptions.remoteName,
204+
loadRemoteModuleOptions.exposedFile
205+
);
206+
};
207+
208+
private loadRemoteEntry = async (
209+
remoteEntry: string,
210+
uniqueName?: string
211+
): Promise<void> => {
212+
return new Promise<void>((resolve, reject) => {
213+
if (this.fileMap[remoteEntry]) {
214+
resolve();
215+
return;
216+
}
217+
218+
this.fileMap[remoteEntry] = true;
219+
220+
const required = __non_webpack_require__(remoteEntry);
221+
this.moduleMap[uniqueName] = required as Container;
222+
resolve();
223+
return;
224+
});
225+
};
226+
}
227+
228+
export const moduleFederationImporter = new MfeUtil();
229+
----
230+
231+
This code is able to load any .js file on the device, so it can be used in conjunction with a download strategy to download the files and then load them dynamically. For example, we can first download the full file, and then load it:
232+
233+
[, js]
234+
----
235+
{
236+
path: "federated",
237+
loadChildren: async () => {
238+
const file = await Http.getFile('http://127.0.0.1:3000/federated.js');
239+
240+
return (await moduleFederationImporter
241+
.loadRemoteFile({
242+
exposedFile: "./federated.module",
243+
exposeFileType: FileType.Module,
244+
remoteEntry: file.path,
245+
remoteName: "federated",
246+
})).FederatedModule;
247+
},
248+
},
249+
----
250+
251+
Alternatively, we could also download it as a zip and extract, or you could, theoretically, override the way that webpack loads the chunks in the federated module to download them piece by piece as needed.
252+
Sharing the common modules
253+
The complexity of sharing modules cannot be understated. The initial https://github.com/webpack/webpack/pull/10838[Webpack Module Federation PR, window=_blank] that provided the full container and consumer API is smaller then the https://github.com/webpack/webpack/pull/10960[PR that introduced version shared dependencies, window=_blank].
254+
A native app is not just a webpage, but the full browser itself. While the web provides a lot of APIs directly, NativeScript provides a lot of them through the @nativescript/core package, so that’s one dependency that has to be a singleton and we can’t under any circumstance have multiple versions of it. In this example, we’re also using angular, so let’s share that as well:
255+
256+
[, js]
257+
----
258+
shared: {
259+
'@nativescript/core': { eager: true, singleton: true, requiredVersion: "*" },
260+
'@nativescript/angular': { eager: true, singleton: true, requiredVersion: "*" },
261+
'@angular/core': { eager: true, singleton: true, requiredVersion: "*" },
262+
'@angular/router': { eager: true, singleton: true, requiredVersion: "*" },
263+
}
264+
----
265+
266+
Here we also share them as eager, since those packages are critical to the bootstrap of the application. For example, @nativescript/core is responsible for calling UIApplicationMain on iOS, so if you fail to call it, the app will instantly close.
267+
268+
== Result
269+
270+
First, we create a simple standalone component that will show a Label and a nested page which will be loaded asynchronous:
271+
272+
[, js]
273+
----
274+
import { Component, NO_ERRORS_SCHEMA } from "@angular/core";
275+
import {
276+
NativeScriptCommonModule,
277+
NativeScriptRouterModule,
278+
} from "@nativescript/angular";
279+
280+
@Component({
281+
standalone: true,
282+
template: `<StackLayout>
283+
<Label>Hello from standalone component</Label>
284+
<GridLayout><page-router-outlet></page-router-outlet></GridLayout>
285+
</StackLayout>`,
286+
schemas: [NO_ERRORS_SCHEMA],
287+
imports: [NativeScriptCommonModule, NativeScriptRouterModule],
288+
})
289+
export class ShellComponent {}
290+
----
291+
292+
Then we can define the Federated Module:
293+
294+
[, js]
295+
----
296+
@Component({
297+
template: `<Label>Hello from wmf! Here's a counter: {{ timer | async }}</Label>`,
298+
})
299+
export class MyComponent {
300+
timer = timer(0, 1000);
301+
}
302+
303+
@NgModule({
304+
declarations: [MyComponent],
305+
imports: [NativeScriptCommonModule, RouterModule.forChild([{ path: "", component: MyComponent }])],
306+
schemas: [NO_ERRORS_SCHEMA]
307+
})
308+
export class FederatedModule {}
309+
----
310+
311+
And finally, we can setup the routing:
312+
313+
[, js]
314+
----
315+
import { NgModule } from "@angular/core";
316+
import { Routes } from "@angular/router";
317+
import { NativeScriptRouterModule } from "@nativescript/angular";
318+
import { FileType, moduleFederationImporter } from "./mfe.utils";
319+
import { Http } from "@nativescript/core";
320+
import { ShellComponent } from "./shell.component";
321+
322+
const routes: Routes = [
323+
{ path: "", redirectTo: "/shell", pathMatch: "full" },
324+
{
325+
path: "shell",
326+
component: ShellComponent,
327+
loadChildren: async () => {
328+
const file = await Http.getFile("http://127.0.0.1:3000/federated.js");
329+
330+
return (
331+
await moduleFederationImporter.loadRemoteFile({
332+
exposedFile: "./federated.module",
333+
exposeFileType: FileType.Module,
334+
remoteEntry: file.path,
335+
remoteName: "federated",
336+
})
337+
).FederatedModule;
338+
},
339+
},
340+
];
341+
342+
@NgModule({
343+
imports: [NativeScriptRouterModule.forRoot(routes), ShellComponent],
344+
exports: [NativeScriptRouterModule],
345+
})
346+
export class AppRoutingModule {}
347+
----
348+
349+
Which results in the following screen, fully working module federation in NativeScript!
350+
351+
[.small-img]
352+
image::img1.png[]
353+
354+
== Conclusion
355+
356+
Although Module Federations is still limited on the native application side, we’re already exploring possibilities on how to import modules from the web directly, instead of having to download them manually, giving it first class support and allowing full code splitted remote modules:
357+
358+
[, js]
359+
----
360+
const entry = await import('https://example.com/remoteEntry.js');
361+
entry.get(...)
362+
// entry magically fetches https://example.com/chunk.0.js if needed
363+
----
364+
365+
Module Federation is very promising for creating distribution of efforts and on demand releases without having to go through the pain of constant app store approval processes. While not for everyone it is a very exciting opportunity for large teams.
366+
367+
== Need help?
368+
369+
Valor Software is both an official partner of both the NativeScript organization and Module Federation organization. If you're looking at using Module Federation with your NativeScript application and would like some help. Reach out to our team, mailto:sales@valor-software.com[sales@valor-software.com]
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
{
2+
"title": "Module Federation in mobile apps powered by NativeScript",
3+
"order": 49,
4+
"domains": ["dev_quality_assurance"],
5+
"authorImg": "assets/articles/module-federation-in-mobile-apps-powered-by-nativescript/eduardo.jpg",
6+
"language": "en",
7+
"bgImg": "assets/articles/module-federation-in-mobile-apps-powered-by-nativescript/hero.png",
8+
"author": "Eduardo Speroni",
9+
"position": "JS Developer",
10+
"date": "Mon Jan 09 2022 10:45:55 GMT+0000 (Coordinated Universal Time)",
11+
"seoDescription": "Research and assessment on how could Module Federation look as a mobile native application with Native Script?"
12+
}

0 commit comments

Comments
 (0)