Skip to content

feat(nf): use native federation with ng builder instead of adapter #588

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
303 changes: 34 additions & 269 deletions libs/native-federation/src/builders/build/builder.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
import * as path from 'path';
import * as fs from 'fs';
import * as mrmime from 'mrmime';

import { ApplicationBuilderOptions } from '@angular/build/src/builders/application';
import { Schema } from '@angular/build/src/builders/application/schema';
import { OutputHashing, Schema } from '@angular/build/src/builders/application/schema';

import {
BuilderContext,
Expand All @@ -15,71 +13,27 @@ import {
buildApplication,
buildApplicationInternal,
} from '@angular/build/src/builders/application';
import { serveWithVite } from '@angular/build/src/builders/dev-server/vite-server';

import { DevServerBuilderOptions } from '@angular-devkit/build-angular';
import { normalizeOptions } from '@angular-devkit/build-angular/src/builders/dev-server/options';

import { setLogLevel, logger } from '@softarc/native-federation/build';
import { logger } from '@softarc/native-federation/build';

import { FederationOptions } from '@softarc/native-federation/build';
import { setBuildAdapter } from '@softarc/native-federation/build';
import {
createAngularBuildAdapter,
setMemResultHandler,
} from '../../utils/angular-esbuild-adapter';
import { getExternals } from '@softarc/native-federation/build';
import { loadFederationConfig } from '@softarc/native-federation/build';
import { buildForFederation } from '@softarc/native-federation/build';
import { targetFromTargetString } from '@angular-devkit/architect';

import { NfBuilderSchema } from './schema';
import {
reloadBrowser,
reloadShell,
setError,
startServer,
} from '../../utils/dev-server';
import { RebuildHubs } from '../../utils/rebuild-events';
import { updateIndexHtml, updateScriptTags } from '../../utils/updateIndexHtml';
import { existsSync, mkdirSync, rmSync } from 'fs';
import {
EsBuildResult,
MemResults,
NgCliAssetResult,
} from '../../utils/mem-resuts';
import { JsonObject } from '@angular-devkit/core';
import type { Plugin } from 'esbuild';
import { existsSync, mkdirSync, rmSync } from 'fs';
import { entryPointsPlugin } from '../../utils/entry-points-plugin';
import { externalsPlugin } from '../../utils/externals-plugin';
import { initFederationBuild } from '../../utils/init-federation-build';
import { createSharedMappingsPlugin } from '../../utils/shared-mappings-plugin';
import { Connect } from 'vite';
import { PluginBuild } from 'esbuild';
import { FederationInfo } from '@softarc/native-federation-runtime';

function _buildApplication(options, context, pluginsOrExtensions) {
let extensions;
if (pluginsOrExtensions && Array.isArray(pluginsOrExtensions)) {
extensions = {
codePlugins: pluginsOrExtensions,
};
} else {
extensions = pluginsOrExtensions;
}
return buildApplicationInternal(
options,
context,
{ write: false },
extensions
);
}
import { transformIndexHtml, updateScriptTags } from '../../utils/updateIndexHtml';
import { NfBuilderSchema } from './schema';

export async function* runBuilder(
nfOptions: NfBuilderSchema,
rawOptions: Schema,
context: BuilderContext
): AsyncIterable<BuilderOutput> {
let target = targetFromTargetString(nfOptions.target);

let _options = (await context.getTargetOptions(
target
)) as unknown as JsonObject & Schema;
let target = targetFromTargetString(rawOptions.target);

let builder = await context.getBuilderNameForTarget(target);

Expand All @@ -105,37 +59,23 @@ export async function* runBuilder(
}

let options = (await context.validateOptions(
_options,
rawOptions,
builder
)) as JsonObject & Schema;

const outerOptions = options as DevServerBuilderOptions;
const normOuterOptions = nfOptions.dev
? await normalizeOptions(context, context.target.project, outerOptions)
: null;

if (nfOptions.dev) {
target = targetFromTargetString(outerOptions.buildTarget);
_options = (await context.getTargetOptions(
target
)) as unknown as JsonObject & Schema;

builder = await context.getBuilderNameForTarget(target);
options = (await context.validateOptions(_options, builder)) as JsonObject &
Schema;
// we don't want builder to clear "dist" as long as initFederationBuild will put remoteEntry.json there before real build
options.deleteOutputPath = false;
// it is impossible to hash files because initFederationBuild calc their names before real build
// TODO: could pass through by patching remoteEntry.json after build, but there would be troubles with serve though
options.outputHashing = OutputHashing.None;
// federation responding for downloading all the parts, there is no need for builder to preload them
if (typeof options.index !== 'boolean') {
options.index = {
input: typeof options.index === 'string' ? options.index : options.index.input,
preloadInitial: false,
}
}

const runServer = !!nfOptions.port;
const write = !runServer;
const watch = !!runServer || nfOptions.watch;

options.watch = watch;
const rebuildEvents = new RebuildHubs();

const adapter = createAngularBuildAdapter(options, context, rebuildEvents);
setBuildAdapter(adapter);

setLogLevel(options.verbose ? 'verbose' : 'info');

const outputPath = options.outputPath;

Expand All @@ -154,200 +94,25 @@ export async function* runBuilder(
outputOptions.browser
);

const fedOptions: FederationOptions = {
workspaceRoot: context.workspaceRoot,
outputPath: browserOutputPath,
federationConfig: infereConfigPath(options.tsConfig),
tsConfig: options.tsConfig,
verbose: options.verbose,
watch: false, // options.watch,
dev: !!nfOptions.dev,
};

const config = await loadFederationConfig(fedOptions);
const externals = getExternals(config);
const plugins = [
createSharedMappingsPlugin(config.sharedMappings),
{
name: 'externals',
setup(build: PluginBuild) {
if (build.initialOptions.platform !== 'node') {
build.initialOptions.external = externals.filter(
(e) => e !== 'tslib'
);
}
},
},
];

const middleware: Connect.NextHandleFunction[] = [
(req, res, next) => {
const fileName = path.join(
fedOptions.workspaceRoot,
fedOptions.outputPath,
req.url
);
const exists = fs.existsSync(fileName);

if (req.url !== '/' && req.url !== '' && exists) {
const lookup = mrmime.lookup;
const mimeType = lookup(path.extname(fileName)) || 'text/javascript';
const rawBody = fs.readFileSync(fileName, 'utf-8');
const body = addDebugInformation(req.url, rawBody);
res.writeHead(200, {
'Content-Type': mimeType,
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE',
'Access-Control-Allow-Headers': 'Content-Type',
});
res.end(body);
} else {
next();
}
},
];

const memResults = new MemResults();

let first = true;
let lastResult: { success: boolean } | undefined;

if (existsSync(fedOptions.outputPath)) {
rmSync(fedOptions.outputPath, { recursive: true });
}

if (!existsSync(fedOptions.outputPath)) {
mkdirSync(fedOptions.outputPath, { recursive: true });
if (existsSync(browserOutputPath)) {
rmSync(browserOutputPath, { recursive: true });
}

if (!write) {
setMemResultHandler((outFiles, outDir) => {
const fullOutDir = outDir
? path.join(fedOptions.workspaceRoot, outDir)
: null;
memResults.add(outFiles.map((f) => new EsBuildResult(f, fullOutDir)));
});
if (!existsSync(browserOutputPath)) {
mkdirSync(browserOutputPath, { recursive: true });
}

await buildForFederation(config, fedOptions, externals);

options.deleteOutputPath = false;

// TODO: Clarify how DevServer needs to be executed. Not sure if its right.
// TODO: Clarify if buildApplication is needed `executeDevServerBuilder` seems to choose the correct DevServer

const appBuilderName = '@angular-devkit/build-angular:application';

const builderRun = nfOptions.dev
? serveWithVite(
normOuterOptions,
appBuilderName,
_buildApplication,
context,
nfOptions.skipHtmlTransform
? {}
: { indexHtml: transformIndexHtml(nfOptions) },
{
buildPlugins: plugins,
middleware,
}
)
: buildApplication(options, context, plugins);

// builderRun.output.subscribe(async (output) => {
for await (const output of builderRun) {
lastResult = output;

if (!output.success) {
setError('Compilation Error');
reloadBrowser();
continue;
} else {
setError(null);
}

if (!write && output.outputFiles) {
memResults.add(output.outputFiles.map((file) => new EsBuildResult(file)));
}

if (!write && output.assetFiles) {
memResults.add(
output.assetFiles.map((file) => new NgCliAssetResult(file))
);
}

if (write && !nfOptions.dev && !nfOptions.skipHtmlTransform) {
updateIndexHtml(fedOptions, nfOptions);
}

// if (first && runServer) {
// startServer(nfOptions, fedOptions.outputPath, memResults);
// }

// if (!first && runServer) {
// reloadBrowser();
// }

if (!runServer) {
yield output;
}

if (!first && nfOptions.dev) {
setTimeout(async () => {
// logger.info('Rebuilding federation artefacts ...');
// await Promise.all([rebuildEvents.rebuild.emit()]);
await buildForFederation(config, fedOptions, externals);
logger.info('Done!');
const fedData = await initFederationBuild(context.workspaceRoot, browserOutputPath, options.tsConfig);

if (runServer) {
setTimeout(() => reloadShell(nfOptions.shell), 0);
}
}, nfOptions.rebuildDelay);
}

first = false;
}
const plugins: Plugin[] = [
entryPointsPlugin(fedData.entries),
createSharedMappingsPlugin(fedData.sharedMappings),
externalsPlugin(fedData.externals),
];

yield lastResult || { success: false };
return yield* buildApplication(options, context, {codePlugins: plugins, indexHtmlTransformer: transformIndexHtml()});
}

// eslint-disable-next-line @typescript-eslint/no-explicit-any
export default createBuilder(runBuilder) as any;

function infereConfigPath(tsConfig: string): string {
const relProjectPath = path.dirname(tsConfig);
const relConfigPath = path.join(relProjectPath, 'federation.config.js');

return relConfigPath;
}

function transformIndexHtml(
nfOptions: NfBuilderSchema
): (content: string) => Promise<string> {
return (content: string): Promise<string> =>
Promise.resolve(
updateScriptTags(content, 'main.js', 'polyfills.js', nfOptions)
);
}

function addDebugInformation(fileName: string, rawBody: string): string {
if (fileName !== '/remoteEntry.json') {
return rawBody;
}

const remoteEntry = JSON.parse(rawBody) as FederationInfo;
const shared = remoteEntry.shared;

if (!shared) {
return rawBody;
}

const sharedForVite = shared.map((s) => ({
...s,
packageName: `/@id/${s.packageName}`,
}));

remoteEntry.shared = [...shared, ...sharedForVite];

return JSON.stringify(remoteEntry, null, 2);
}
Loading