Skip to content

Commit

Permalink
feat: init module (extracted from Nussknacker UI)
Browse files Browse the repository at this point in the history
  • Loading branch information
JulianWielga committed Jun 13, 2024
1 parent 7df77c8 commit c74ca55
Show file tree
Hide file tree
Showing 15 changed files with 302 additions and 1 deletion.
67 changes: 67 additions & 0 deletions src/FederatedComponent.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { ModuleString, ModuleUrl, ScriptUrl } from "./types";
import { useFederatedModule } from "./hooks";
import React, { Fragment } from "react";
import { splitUrl } from "./tools";
import ReactDOM from "react-dom";
import { FederatedModuleProvider, FederatedModuleProviderProps } from "./FederatedModuleProvider";

function Component<P>({
scope,
...props
}: {
scope: ModuleString;
} & P) {
const {
module: { default: Component },
} = useFederatedModule(scope);
return <Component {...props} />;
}

export type FederatedComponentProps<P extends NonNullable<unknown>> = P & {
url: ModuleUrl;
scope: ModuleString;
};

export function FederatedComponent<P extends NonNullable<unknown>>({
url,
buildHash,
fallback,
...props
}: FederatedComponentProps<P> & Pick<FederatedModuleProviderProps, "fallback" | "buildHash">) {
return (
<FederatedModuleProvider url={url} fallback={fallback} buildHash={buildHash}>
<Component {...props} />
</FederatedModuleProvider>
);
}

interface LoaderOptions {
Wrapper?: React.FunctionComponent<React.PropsWithChildren<unknown>>;
getAuthToken?: () => Promise<string>;
}

export function getFederatedComponentLoader({ Wrapper = Fragment, ...options }: LoaderOptions = {}) {
return <P extends NonNullable<unknown>>(url: string, props: P) => {
const rootContainer = document.createElement(`div`);
document.body.appendChild(rootContainer);
const [urlValue, scopeValue, scriptValue] = splitUrl(url as ModuleUrl);
ReactDOM.render(
<Wrapper>
<FederatedComponent<
P & {
scriptOrigin: ScriptUrl;
getAuthToken?: () => Promise<string>;
}
>
url={urlValue}
scope={scopeValue}
scriptOrigin={scriptValue}
getAuthToken={options.getAuthToken}
fallback={null}
{...props}
/>
</Wrapper>,
rootContainer,
);
};
}
42 changes: 42 additions & 0 deletions src/FederatedModuleProvider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { lazy } from "@loadable/component";
import React, { PropsWithChildren, SuspenseProps, useMemo } from "react";
import { LibContextProvider } from "./store";
import { loadFederatedModule, splitUrl } from "./tools";
import { Module, ModuleUrl } from "./types";

export type FederatedModuleProviderProps = Pick<SuspenseProps, "fallback"> &
PropsWithChildren<{
url: ModuleUrl;
buildHash?: string;
}>;

/**
* Loads external module (federation) from url. Works as modules context provider.
* After loading module renders children providing context with module accessible by hooks
* @param url "url" to module in format `${federatedModuleName}/${exposedModule}@http(...).js`
* @param fallback Fallback component passed to React.Suspense
* @param buildHash Optional cache busting
* @param children
* @constructor
*/
export function FederatedModuleProvider<M extends Module>({
children,
url,
fallback,
buildHash,
}: FederatedModuleProviderProps): JSX.Element {
const [, context] = useMemo(() => splitUrl(url), [url]);
const FederatedModule = useMemo(() => lazy.lib(async () => loadFederatedModule(url, buildHash)), [buildHash, url]);

return (
<React.Suspense fallback={fallback}>
<FederatedModule>
{(lib: M) => (
<LibContextProvider<M> lib={lib} scope={context}>
{children}
</LibContextProvider>
)}
</FederatedModule>
</React.Suspense>
);
}
1 change: 1 addition & 0 deletions src/hooks/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { useFederatedModule } from "./useFederatedModule";
15 changes: 15 additions & 0 deletions src/hooks/useFederatedModule.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { useContext } from "react";
import { Module, ModuleString } from "../types";
import { ExternalLibContext } from "../store/context";
import { ExternalLibContextType } from "../store/types";

export function useFederatedModule<M extends Module = Module>(scope?: ModuleString): { module: M; context: ExternalLibContextType } {
const context = useContext(ExternalLibContext);
const module = context?.modules[scope] as M;

if (scope && !module) {
throw new Error(`useExternalLib must be used within a ExternalLibContext.Provider. Module not loaded.`);
}

return { module, context };
}
9 changes: 8 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,8 @@
export const test = 123
export { FederatedModuleProvider } from "./FederatedModuleProvider";
export { useFederatedModule } from "./hooks";
export { FederatedComponent, getFederatedComponentLoader } from "./FederatedComponent";
export { createScript, splitUrl, loadFederatedModule } from "./tools";

export type { FederatedModuleProviderProps } from "./FederatedModuleProvider";
export type { FederatedComponentProps } from "./FederatedComponent";
export type { Module, ModuleUrl, ModuleString, ScriptUrl, PathString, QueryString, ScopeString } from "./types";
35 changes: 35 additions & 0 deletions src/store/LibContextProvider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import React, { PropsWithChildren } from "react";
import { Module, ModuleString } from "../types";
import { ExternalLibContext } from "./context";
import { ModulesStore } from "./modulesStore";
import { useFederatedModule } from "../hooks";

interface Props<M extends Module> {
lib: M;
scope: ModuleString;
}

/**
* Module context provider. If store is available in context extend existing store with module.
* @param lib module to store in context
* @param scope name of module - based on remote ModuleFederationPlugin config
* @param children
* @constructor
*/
export function LibContextProvider<M extends Module>({ lib, scope, children }: PropsWithChildren<Props<M>>): JSX.Element {
const { context } = useFederatedModule();

if (!lib) {
return null;
}

if (context) {
context.add(scope, lib);
return <>{children}</>;
}

const modulesStore = new ModulesStore();
modulesStore.add(scope, lib);

return <ExternalLibContext.Provider value={modulesStore}>{children}</ExternalLibContext.Provider>;
}
4 changes: 4 additions & 0 deletions src/store/context.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { createContext } from "react";
import { ExternalLibContextType } from "./types";

export const ExternalLibContext = createContext<ExternalLibContextType>(null);
1 change: 1 addition & 0 deletions src/store/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { LibContextProvider } from "./LibContextProvider";
9 changes: 9 additions & 0 deletions src/store/modulesStore.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { Module, ModuleString } from "../types";
import { ExternalLibContextType, Modules } from "./types";

export class ModulesStore implements ExternalLibContextType {
modules: Modules = {};
add = <M extends Module>(scope: ModuleString, module: M): void => {
this.modules = { ...this.modules, [scope]: module };
};
}
8 changes: 8 additions & 0 deletions src/store/types.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { Module, ModuleString } from "../types";

export type Modules = Record<ModuleString, Module>;

export interface ExternalLibContextType<M extends Module = Module> {
modules: Modules;
add: (scope: ModuleString, module: M) => void;
}
24 changes: 24 additions & 0 deletions src/tools/createScript.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { ScriptUrl } from "../types";

export function createScript(url: `${ScriptUrl}?${string}`): Promise<void> {
const element = document.createElement(`script`);

return new Promise<void>((resolve, reject) => {
element.src = url;
element.type = "text/javascript";
element.async = true;

element.onload = () => {
resolve();
// setTimeout to ensure full init (e.g. relying on document.currentScript etc) before remove
setTimeout(() => document.head.removeChild(element));
};

element.onerror = () => {
console.error(`Dynamic Script Error: ${url}`);
reject();
};

document.head.appendChild(element);
});
}
3 changes: 3 additions & 0 deletions src/tools/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export { createScript } from "./createScript";
export { splitUrl } from "./splitUrl";
export { loadFederatedModule } from "./loadFederatedModule";
26 changes: 26 additions & 0 deletions src/tools/loadFederatedModule.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { Container, Module, ModuleUrl } from "../types";
import { createScript } from "./createScript";
import { splitUrl } from "./splitUrl";

export async function loadFederatedModule<M extends Module = Module>(url: ModuleUrl, buildHash?: string): Promise<M> {
const [, , scriptUrl, scope, module, query = buildHash] = splitUrl(url);

// Initializes the share scope. This fills it with known provided modules from this build and all remotes
await __webpack_init_sharing__(`default`);

// load once
if (!window[scope]) {
await createScript(`${scriptUrl}?${query}`);
}

const container: Container = window[scope]; // or get the container somewhere else
// Initialize the container, it may provide shared modules
await container.init(__webpack_share_scopes__.default);
const factory = await container.get<M>(module);
return factory();
}

declare let __webpack_share_scopes__: {
[name: string]: unknown;
default: unknown;
};
18 changes: 18 additions & 0 deletions src/tools/splitUrl.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { ModuleString, ModuleUrl, PathString, QueryString, ScopeString, ScriptUrl } from "../types";

/**
* Split FederatedModule url to url and module parts.
* @param fullModuleUrl
*/
export function splitUrl(fullModuleUrl: ModuleUrl): [ModuleUrl, ModuleString, ScriptUrl, ScopeString, PathString, QueryString] {
const [module, url] = fullModuleUrl.split("@");
const [scope] = module.split("/");
const path = module.replace(scope, ".");
const [script, query] = url.split("?");

if (!scope || !script.match(/\.js$/)) {
throw new Error("invalid remote module url");
}

return [fullModuleUrl, module as ModuleString, script as ScriptUrl, scope as ScopeString, path as PathString, query as QueryString];
}
41 changes: 41 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { ComponentType } from "react";
import type { Tagged } from "type-fest";

export interface Container {
init(scope: unknown): Promise<unknown>;

get<M = { default: ComponentType<unknown> }>(module: PathString): Promise<() => M>;
}

/**
* remote name from ModuleFederationPlugin
*/
export type ScopeString = Tagged<string, "ScopeString">;

/**
* remote exposed module "path" from ModuleFederationPlugin
*/
export type PathString = Tagged<string, "PathString">;

/**
* url to remote entry .js file
*/
export type ScriptUrl = Tagged<string, "ScriptUrl">;

/**
* query from remote entry url
*/
export type QueryString = Tagged<string, "QueryString">;

/**
* `${ScopeString}/${PathString}`
*/
export type ModuleString = Tagged<string, "ModuleString">;

/**
* `${ModuleString}@${ScriptUrl}`
*/
export type ModuleUrl = Tagged<string, "ModuleUrl">;

type Hooks = Record<`use${Capitalize<string>}`, (...args: unknown[]) => unknown>;
export type Module = { default?: ComponentType<unknown> } & Hooks;

0 comments on commit c74ca55

Please sign in to comment.