Skip to content
Merged
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
4 changes: 2 additions & 2 deletions packages/angular/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,15 @@
},
"peerDependencies": {
"@softarc/native-federation": "~4.0.0",
"@softarc/native-federation-runtime": "~4.0.0",
"@softarc/native-federation-orchestrator": "^4.0.0",
"@angular-devkit/architect": ">=0.2102.0",
"@angular-devkit/build-angular": ">=21.2.0",
"@angular-devkit/core": ">=21.2.0",
"@angular/build": ">=21.2.0"
},
"dependencies": {
"@softarc/native-federation": "~4.0.0",
"@softarc/native-federation-runtime": "~4.0.0",
"@softarc/native-federation-orchestrator": "^4.0.0",
"@angular-devkit/architect": "^0.2102.0",
"@angular-devkit/build-angular": "^21.2.0",
"@angular-devkit/core": "^21.2.0",
Expand Down
199 changes: 193 additions & 6 deletions packages/angular/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,194 @@
export * from '@softarc/native-federation/domain';
export {
initFederation,
type InitFederationOptions,
loadRemoteModule,
type LoadRemoteModuleOptions,
} from '@softarc/native-federation-runtime';
import {
initFederation as internalInitFederation,
type NativeFederationResult,
} from '@softarc/native-federation-orchestrator';
import {
useShimImportMap,
consoleLogger,
globalThisStorageEntry,
type LogType,
} from '@softarc/native-federation-orchestrator/options';

export type Imports = Record<string, string>;
export type Scopes = Record<string, Imports>;
export type ImportMap = {
imports: Imports;
scopes: Scopes;
};

/**
* Options for {@link loadRemoteModule}. Mirrors the shape in
* `@softarc/native-federation-runtime`.
*
* @property remoteEntry - URL to the remote's `remoteEntry.json`. Enables
* lazy-loading remotes not registered during `initFederation`.
* @property remoteName - Name of the remote. If omitted, derived from
* `remoteEntry` (its manifest's `name`).
* @property exposedModule - Key exposed by the remote (e.g. `'./Component'`).
* @property fallback - Value returned on failure. Truthy-only — `null`/`0`/`''`
* count as "no fallback".
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type LoadRemoteModuleOptions<T = any> = {
remoteEntry?: string;
remoteName?: string;
exposedModule: string;
fallback?: T;
};

export interface InitFederationOptions {
cacheTag?: string;
logging?: LogType;
}

let resolveFirstInit!: (
value: NativeFederationResult | PromiseLike<NativeFederationResult>
) => void;
let rejectFirstInit!: (reason?: unknown) => void;
let firstInitCaptured = false;
let federationPromise: Promise<NativeFederationResult> = new Promise(
(resolve, reject) => {
resolveFirstInit = resolve;
rejectFirstInit = reject;
}
);

export function initFederation(
remotesOrManifestUrl?: Record<string, string> | string,
options?: InitFederationOptions
) {
const p = internalInitFederation(remotesOrManifestUrl ?? {}, {
...useShimImportMap({ shimMode: true }),
logger: consoleLogger,
storage: globalThisStorageEntry,
hostRemoteEntry: { url: './remoteEntry.json', cacheTag: options?.cacheTag },
logLevel: options?.logging ?? 'debug',
});
if (!firstInitCaptured) {
firstInitCaptured = true;
p.then(resolveFirstInit, rejectFirstInit);
}
federationPromise = p;
return p;
}

function normalizeOptions<T>(
optionsOrRemoteName: LoadRemoteModuleOptions<T> | string,
exposedModule?: string
): LoadRemoteModuleOptions<T> {
if (typeof optionsOrRemoteName === 'string' && exposedModule) {
return { remoteName: optionsOrRemoteName, exposedModule };
}
if (typeof optionsOrRemoteName === 'object' && !exposedModule) {
return optionsOrRemoteName;
}
throw new Error(
'unexpected arguments: please pass options or a remoteName/exposedModule-pair'
);
}

function logClientError(error: string): void {
if (typeof window !== 'undefined') {
console.error(error);
}
}

async function resolveRemoteNameFromEntry(remoteEntry: string): Promise<string> {
const res = await fetch(remoteEntry);
if (!res.ok) {
throw new Error(
`Failed to fetch remoteEntry at ${remoteEntry}: ${res.status} ${res.statusText}`
);
}
const info = (await res.json()) as { name?: string };
if (!info.name) {
throw new Error(`remoteEntry at ${remoteEntry} does not declare a 'name'`);
}
return info.name;
}

/**
* Dynamically loads a remote module. Spec-compatible with `loadRemoteModule`
* from `@softarc/native-federation-runtime`; bridges to the orchestrator
* (`@softarc/native-federation-orchestrator`) under the hood.
*
* ```ts
* await loadRemoteModule({ remoteName: 'mfe1', exposedModule: './Component' });
* await loadRemoteModule('mfe1', './Component');
* ```
*
* Flow: normalize args → await `federationPromise` (may be called before
* `initFederation`, then waits) → if only `remoteEntry` was given, fetch its
* manifest for the name → if `remoteEntry` set, `initRemoteEntry(...)` first →
* delegate to the orchestrator's `loadRemoteModule(remoteName, exposedModule)`.
* On error, return truthy `fallback` (logging `console.error` in browsers) or
* rethrow.
*
* @throws on bad arg combos, unresolvable `remoteName`, or load failure when
* no truthy `fallback` is set.
*
* @deprecated Prefer the `loadRemoteModule` returned by the `initFederation`
* promise. This top-level helper relies on a module-scoped federation
* instance and only resolves against the most recent `initFederation` call,
* which is brittle in tests and multi-host setups. Example:
* ```ts
* const { loadRemoteModule } = await initFederation(...);
* await loadRemoteModule('mfe1', './Component');
* ```
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export async function loadRemoteModule<T = any>(
options: LoadRemoteModuleOptions<T>
): Promise<T>;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export async function loadRemoteModule<T = any>(
remoteName: string,
exposedModule: string
): Promise<T>;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export async function loadRemoteModule<T = any>(
optionsOrRemoteName: LoadRemoteModuleOptions<T> | string,
exposedModule?: string
): Promise<T> {
const options = normalizeOptions<T>(optionsOrRemoteName, exposedModule);
const { fallback } = options;

try {
let federation = await federationPromise;

if (!options.remoteName && options.remoteEntry) {
options.remoteName = await resolveRemoteNameFromEntry(options.remoteEntry);
}

if (options.remoteEntry) {
federation = await federation.initRemoteEntry(
options.remoteEntry,
options.remoteName
);
}

if (!options.remoteName) {
const err = 'unexpected arguments: Please pass remoteName or remoteEntry';
if (!fallback) throw new Error(err);
logClientError(err);
return fallback;
}

return await federation.loadRemoteModule<T>(
options.remoteName,
options.exposedModule
);
} catch (err) {
if (fallback) {
logClientError(
'error loading remote module: ' +
(err instanceof Error ? err.message : String(err))
);
return fallback;
}
throw err;
}
}

export { type NativeFederationResult } from '@softarc/native-federation-orchestrator';
1 change: 0 additions & 1 deletion packages/angular/src/schematics/update-v4/schema.d.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
export interface UpdateV4Schema {
project: string;
orchestrator: boolean;
}
6 changes: 0 additions & 6 deletions packages/angular/src/schematics/update-v4/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,6 @@
"index": 0
},
"x-prompt": "Project name (press enter for default project)"
},
"orchestrator": {
"type": "boolean",
"description": "Switch main.ts to use @softarc/native-federation-orchestrator instead of the legacy runtime",
"default": false,
"x-prompt": "Would you like to use the new orchestrator runtime?"
}
}
}
112 changes: 6 additions & 106 deletions packages/angular/src/schematics/update-v4/schematic.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import type { Rule, Tree } from '@angular-devkit/schematics';
import type { UpdateV4Schema } from './schema.js';

import { NodePackageInstallTask } from '@angular-devkit/schematics/tasks';
import { getWorkspaceFileName } from '../init/schematic.js';

import * as path from 'path';
Expand All @@ -10,24 +9,15 @@ const V3_PACKAGE = '@angular-architects/native-federation';
const V4_PACKAGE = '@angular-architects/native-federation-v4';
const V3_BUILDER = `${V3_PACKAGE}:build`;
const V4_BUILDER = `${V4_PACKAGE}:build`;
const V3_RUNTIME_IMPORT = `@angular-architects/native-federation`;
const V4_RUNTIME_IMPORT = `@softarc/native-federation-runtime`;
const ORCHESTRATOR_PACKAGE = `@softarc/native-federation-orchestrator`;

export default function updateV4(options: UpdateV4Schema): Rule {
return async function (tree: Tree, context) {
return async function (tree: Tree) {
const workspaceFileName = getWorkspaceFileName(tree);
const workspace = JSON.parse(tree.read(workspaceFileName)?.toString('utf8') ?? '{}');

updateBuilderReferences(tree, workspace, workspaceFileName);
migrateFederationConfigs(tree, workspace, options);
migrateMainTs(tree, workspace, options);

if (options.orchestrator) {
installOrchestratorPackage(tree);
migrateMainTsToOrchestrator(tree, workspace, options);
context.addTask(new NodePackageInstallTask());
}
};
}

Expand Down Expand Up @@ -127,7 +117,7 @@ function migrateFederationConfigs(tree: Tree, workspace: any, options: UpdateV4S

/**
* Step 4: Update main.ts imports from @angular-architects/native-federation
* to @softarc/native-federation-runtime
* to @angular-architects/native-federation-v4
*/
function migrateMainTs(tree: Tree, workspace: any, options: UpdateV4Schema): void {
const projects = resolveProjects(workspace, options);
Expand All @@ -146,8 +136,10 @@ function migrateMainTs(tree: Tree, workspace: any, options: UpdateV4Schema): voi
let content = tree.readText(main);
const originalContent = content;

// Update initFederation import
content = content.replace(new RegExp(escapeRegExp(V3_RUNTIME_IMPORT), 'g'), V4_RUNTIME_IMPORT);
content = content.replace(
new RegExp(`(['"])${escapeRegExp(V3_PACKAGE)}\\1`, 'g'),
`$1${V4_PACKAGE}$1`
);

if (content !== originalContent) {
tree.overwrite(main, content);
Expand All @@ -156,98 +148,6 @@ function migrateMainTs(tree: Tree, workspace: any, options: UpdateV4Schema): voi
}
}

/**
* Optional Step 5: Add @softarc/native-federation-orchestrator to package.json dependencies
*/
function installOrchestratorPackage(tree: Tree): void {
const packageJson = JSON.parse(tree.read('package.json')?.toString('utf8') ?? '{}');

if (!packageJson.dependencies) {
packageJson.dependencies = {};
}

if (!packageJson.dependencies[ORCHESTRATOR_PACKAGE]) {
packageJson.dependencies[ORCHESTRATOR_PACKAGE] = '^4.0.0';
tree.overwrite('package.json', JSON.stringify(packageJson, null, 2));
console.log(`Added ${ORCHESTRATOR_PACKAGE} to dependencies`);
}
}

/**
* Optional Step 6: Surgically update main.ts to use the orchestrator.
*
* - Replaces the initFederation import source with @softarc/native-federation-orchestrator
* - Adds the orchestrator /options import
* - Rewrites the initFederation() call:
* - If it had a first argument, keeps it and appends the orchestrator options as second arg
* - If it had no arguments, uses {} as first arg and the orchestrator options as second arg
*/
function migrateMainTsToOrchestrator(tree: Tree, workspace: any, options: UpdateV4Schema): void {
const projects = resolveProjects(workspace, options);

const orchestratorOptions = `{
...useShimImportMap({ shimMode: true }),
logger: consoleLogger,
storage: globalThisStorageEntry,
hostRemoteEntry: './remoteEntry.json',
logLevel: 'debug',
}`;

const optionsImport = `import {
useShimImportMap,
consoleLogger,
globalThisStorageEntry,
} from '${ORCHESTRATOR_PACKAGE}/options';`;

for (const { projectConfig } of projects) {
const main =
projectConfig?.architect?.build?.options?.browser ??
projectConfig?.architect?.build?.options?.main ??
projectConfig?.architect?.esbuild?.options?.browser ??
projectConfig?.architect?.esbuild?.options?.main;

if (!main || !tree.exists(main)) {
continue;
}

let content = tree.readText(main);

// 1. Replace the import source to the orchestrator package
content = content.replace(
new RegExp(
`from\\s+['"](?:${escapeRegExp(V4_RUNTIME_IMPORT)}|${escapeRegExp(V3_RUNTIME_IMPORT)})['"]`,
'g'
),
`from '${ORCHESTRATOR_PACKAGE}'`
);

// 2. Add the /options import after the orchestrator import line
if (!content.includes(`${ORCHESTRATOR_PACKAGE}/options`)) {
content = content.replace(
new RegExp(
`(import\\s+\\{[^}]*\\}\\s+from\\s+['"]${escapeRegExp(ORCHESTRATOR_PACKAGE)}['"];?)`
),
`$1\n${optionsImport}`
);
}

// 3. Rewrite initFederation(...) call — extract existing first arg if present
const initMatch = content.match(/initFederation\s*\(([^)]*)\)/s);

if (initMatch) {
const existingArgs = initMatch[1]!.trim();
const firstArg = existingArgs.length > 0 ? existingArgs : '{}';
content = content.replace(
initMatch[0],
`initFederation(${firstArg}, ${orchestratorOptions})`
);
}

tree.overwrite(main, content);
console.log(`Switched ${main} to use the orchestrator`);
}
}

function resolveProjects(
workspace: any,
options: UpdateV4Schema
Expand Down
Loading
Loading