@arcote.tech/platform 0.7.32 → 0.8.1
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/package.json +5 -5
- package/src/app-mount.tsx +61 -0
- package/src/arc.ts +34 -0
- package/src/federated-app.tsx +113 -0
- package/src/federation.ts +181 -0
- package/src/index.ts +29 -0
- package/src/layout/page-router.tsx +59 -22
- package/src/layout/slot-renderer.tsx +10 -1
- package/src/module-loader.ts +13 -4
- package/src/platform-context.tsx +24 -1
- package/src/registry.ts +76 -6
- package/src/types.ts +103 -0
- package/tests/federation.test.ts +216 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@arcote.tech/platform",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.8.1",
|
|
5
5
|
"private": false,
|
|
6
6
|
"author": "Przemysław Krasiński [arcote.tech]",
|
|
7
7
|
"description": "Arc Platform — module system, router, layout, theme, i18n, platform app shell",
|
|
@@ -14,12 +14,12 @@
|
|
|
14
14
|
"type-check": "tsc --noEmit"
|
|
15
15
|
},
|
|
16
16
|
"dependencies": {
|
|
17
|
-
"@arcote.tech/arc-ds": "^0.
|
|
18
|
-
"@arcote.tech/arc-react": "^0.
|
|
17
|
+
"@arcote.tech/arc-ds": "^0.8.1",
|
|
18
|
+
"@arcote.tech/arc-react": "^0.8.1"
|
|
19
19
|
},
|
|
20
20
|
"peerDependencies": {
|
|
21
|
-
"@arcote.tech/arc": "^0.
|
|
22
|
-
"@arcote.tech/arc-otel": "^0.
|
|
21
|
+
"@arcote.tech/arc": "^0.8.1",
|
|
22
|
+
"@arcote.tech/arc-otel": "^0.8.1",
|
|
23
23
|
"@lingui/core": "^5.0.0",
|
|
24
24
|
"@lingui/react": "^5.0.0",
|
|
25
25
|
"framer-motion": "^12.0.0",
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Standard routingu aplikacji montowanych pod prefiksem (spec/app-routing.md).
|
|
3
|
+
*
|
|
4
|
+
* Strony aplikacji deklarują ścieżki ABSOLUTNE WEWNĄTRZ aplikacji
|
|
5
|
+
* (`page("/tasks", ...)`, `page("/", Landing)`). Host montuje aplikację pod
|
|
6
|
+
* `basePath` (np. "/a/ndt") — przepisanie ścieżek robi rejestracja federowana
|
|
7
|
+
* (federation.ts), a KOD aplikacji zna swój prefiks wyłącznie przez helpery:
|
|
8
|
+
*
|
|
9
|
+
* const href = useAppHref("/tasks"); // "/a/ndt/tasks" | "/tasks"
|
|
10
|
+
* const nav = useAppNavigate();
|
|
11
|
+
* nav("/tasks/" + id); // redirect po akcji
|
|
12
|
+
*
|
|
13
|
+
* Reguła: KAŻDA nawigacja/link WEWNĄTRZ aplikacji idzie przez helpery — ten
|
|
14
|
+
* sam kod działa standalone (basePath = "") i w hoście. Linki do stron HOSTA
|
|
15
|
+
* to zwykłe `useArcNavigate` z pełną ścieżką hosta.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { createContext, useCallback, useContext, type ReactNode } from "react";
|
|
19
|
+
import { useArcNavigate } from "./router";
|
|
20
|
+
|
|
21
|
+
const AppMountContext = createContext<{ basePath: string }>({ basePath: "" });
|
|
22
|
+
|
|
23
|
+
export function AppMountProvider({
|
|
24
|
+
basePath,
|
|
25
|
+
children,
|
|
26
|
+
}: {
|
|
27
|
+
basePath: string;
|
|
28
|
+
children: ReactNode;
|
|
29
|
+
}) {
|
|
30
|
+
return (
|
|
31
|
+
<AppMountContext.Provider value={{ basePath }}>
|
|
32
|
+
{children}
|
|
33
|
+
</AppMountContext.Provider>
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Prefiks montowania bieżącej aplikacji ("" gdy standalone). */
|
|
38
|
+
export function useAppBasePath(): string {
|
|
39
|
+
return useContext(AppMountContext).basePath;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Wersja nie-hook — do callbacków/utili z już znanym basePath. */
|
|
43
|
+
export function appHref(basePath: string, path: string): string {
|
|
44
|
+
if (path === "/") return basePath || "/";
|
|
45
|
+
return basePath + path;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Adres strony TEJ aplikacji (path absolutny wewnątrz aplikacji). */
|
|
49
|
+
export function useAppHref(path: string): string {
|
|
50
|
+
return appHref(useAppBasePath(), path);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Nawigacja wewnątrz TEJ aplikacji (redirecty po akcjach, linki). */
|
|
54
|
+
export function useAppNavigate(): (path: string) => void {
|
|
55
|
+
const basePath = useAppBasePath();
|
|
56
|
+
const navigate = useArcNavigate();
|
|
57
|
+
return useCallback(
|
|
58
|
+
(path: string) => navigate(appHref(basePath, path)),
|
|
59
|
+
[basePath, navigate],
|
|
60
|
+
);
|
|
61
|
+
}
|
package/src/arc.ts
CHANGED
|
@@ -9,6 +9,7 @@ import type {
|
|
|
9
9
|
ContextElementFragment,
|
|
10
10
|
ExtractPages,
|
|
11
11
|
ModuleAccessRule,
|
|
12
|
+
ModuleExposure,
|
|
12
13
|
PageFragment,
|
|
13
14
|
PageOptions,
|
|
14
15
|
SlotFragment,
|
|
@@ -154,9 +155,41 @@ class ModuleBuilder<
|
|
|
154
155
|
private _ctx: ModuleContext | (() => ModuleContext) | undefined;
|
|
155
156
|
private _scope: string | undefined;
|
|
156
157
|
private _protectedBy: ModuleAccessRule[] = [];
|
|
158
|
+
private _expose: ModuleExposure | undefined;
|
|
157
159
|
|
|
158
160
|
constructor(readonly name: string) {}
|
|
159
161
|
|
|
162
|
+
/**
|
|
163
|
+
* Wystaw ten moduł do ZEWNĘTRZNYCH hostów federacji
|
|
164
|
+
* (spec/module-federation.md). Domyślnie moduł jest lokalny (tylko własna
|
|
165
|
+
* platforma). Oznaczenie steruje BUILDEM: moduł `external`/`both` dostaje
|
|
166
|
+
* dodatkowy browser build federowany (framework peers external → import
|
|
167
|
+
* mapa hosta), a `local`/`both` normalny build (bundled) dla własnej
|
|
168
|
+
* platformy. Wykrywane build-time przez access-extractor (który i tak
|
|
169
|
+
* wykonuje moduły), więc deklaracja żyje TU, przy module.
|
|
170
|
+
*
|
|
171
|
+
* - `local` — tylko własna platforma (domyślnie, gdy nie wołasz metody),
|
|
172
|
+
* - `external` — tylko zewnętrzny host (ukryty w macierzystej platformie),
|
|
173
|
+
* - `both` — i tu, i tam.
|
|
174
|
+
*
|
|
175
|
+
* `permissions`/`slots` to żądania per-moduł względem hosta (zgoda przy
|
|
176
|
+
* instalacji); `slug`/`auth` aplikacji idą z root package.json arc.federation.
|
|
177
|
+
*/
|
|
178
|
+
exposeToHosts(
|
|
179
|
+
options: {
|
|
180
|
+
exposure?: "local" | "external" | "both";
|
|
181
|
+
permissions?: string[];
|
|
182
|
+
slots?: string[];
|
|
183
|
+
} = {},
|
|
184
|
+
): this {
|
|
185
|
+
this._expose = {
|
|
186
|
+
exposure: options.exposure ?? "external",
|
|
187
|
+
permissions: options.permissions,
|
|
188
|
+
slots: options.slots,
|
|
189
|
+
};
|
|
190
|
+
return this;
|
|
191
|
+
}
|
|
192
|
+
|
|
160
193
|
/**
|
|
161
194
|
* Restrict module loading to users holding the given token.
|
|
162
195
|
* Optional check function for granular access control.
|
|
@@ -247,6 +280,7 @@ class ModuleBuilder<
|
|
|
247
280
|
};
|
|
248
281
|
if (this._scope !== undefined) mod.scope = this._scope;
|
|
249
282
|
if (this._protectedBy.length > 0) mod.access = { rules: this._protectedBy };
|
|
283
|
+
if (this._expose !== undefined) mod.expose = this._expose;
|
|
250
284
|
if (domain) Object.assign(mod, domain);
|
|
251
285
|
|
|
252
286
|
const finalize = (resolvedCtx: ModuleContext | undefined) => {
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-aplikacyjny model dla modułów FEDEROWANYCH (spec/module-federation.md).
|
|
3
|
+
*
|
|
4
|
+
* Fragmenty modułu partnera renderują się wewnątrz `FederatedAppProvider`:
|
|
5
|
+
* provider trzyma (per slug, globalnie — nie per mount; StrictMode/podwójny
|
|
6
|
+
* WS) własną instancję `reactModel` wskazującą serwer PARTNERA
|
|
7
|
+
* (`remoteUrl = binding.baseUrl`) z izolowaną persystencją tokenów
|
|
8
|
+
* (`tokenStorageNamespace = slug`). Dzięki temu `usePlatformScope` w kodzie
|
|
9
|
+
* aplikacji — NIEZMIENIONYM względem standalone — trafia w model partnera,
|
|
10
|
+
* a nie hosta (patrz platform-context.tsx).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { createContext, useContext, type ReactNode } from "react";
|
|
14
|
+
import { reactModel } from "@arcote.tech/arc-react";
|
|
15
|
+
import type { ScopeAPI } from "@arcote.tech/arc-react";
|
|
16
|
+
import { getFederatedContext } from "./federation";
|
|
17
|
+
import { getFederatedWrapperFragments } from "./registry";
|
|
18
|
+
import { AppMountProvider } from "./app-mount";
|
|
19
|
+
import type { FederatedBinding } from "./types";
|
|
20
|
+
|
|
21
|
+
interface FederatedModelHooks {
|
|
22
|
+
useModel: any;
|
|
23
|
+
createScope: (name: string) => ScopeAPI;
|
|
24
|
+
resetModel: () => void;
|
|
25
|
+
ModelProvider: (props: { children: ReactNode }) => ReactNode;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Model bieżącej aplikacji federowanej (null = renderujemy kod hosta). */
|
|
29
|
+
export const FederatedAppContext = createContext<FederatedModelHooks | null>(
|
|
30
|
+
null,
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
export function useFederatedAppModel(): FederatedModelHooks | null {
|
|
34
|
+
return useContext(FederatedAppContext);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Cache modeli per slug — instancja modelu (wire'y, WS, StreamingQueryCache,
|
|
38
|
+
// AuthAdapter) MUSI przeżyć remounty (StrictMode podwaja mount) i być jedna
|
|
39
|
+
// dla wszystkich punktów montowania aplikacji (strony + sloty + node'y).
|
|
40
|
+
const models = new Map<string, FederatedModelHooks>();
|
|
41
|
+
|
|
42
|
+
function getOrCreateModel(binding: FederatedBinding): FederatedModelHooks {
|
|
43
|
+
const cached = models.get(binding.slug);
|
|
44
|
+
if (cached) return cached;
|
|
45
|
+
const ctx = getFederatedContext(binding.slug);
|
|
46
|
+
if (!ctx) {
|
|
47
|
+
throw new Error(
|
|
48
|
+
`[arc] FederatedAppProvider("${binding.slug}"): no federated context — ` +
|
|
49
|
+
`load the app's modules first (federation loader) before rendering ` +
|
|
50
|
+
`its fragments.`,
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
const model = reactModel(ctx, {
|
|
54
|
+
remoteUrl: binding.baseUrl,
|
|
55
|
+
tokenStorageNamespace: binding.slug,
|
|
56
|
+
});
|
|
57
|
+
const hooks: FederatedModelHooks = {
|
|
58
|
+
useModel: model.useModel,
|
|
59
|
+
createScope: model.createScope,
|
|
60
|
+
resetModel: model.resetModel,
|
|
61
|
+
ModelProvider: model.ModelProvider as unknown as FederatedModelHooks["ModelProvider"],
|
|
62
|
+
};
|
|
63
|
+
models.set(binding.slug, hooks);
|
|
64
|
+
return hooks;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Usuwa model aplikacji (odinstalowanie / dev reload z nowym adresem). */
|
|
68
|
+
export function resetFederatedAppModel(slug: string): void {
|
|
69
|
+
const hooks = models.get(slug);
|
|
70
|
+
models.delete(slug);
|
|
71
|
+
try {
|
|
72
|
+
hooks?.resetModel();
|
|
73
|
+
} catch {
|
|
74
|
+
// Model mógł nie być zainicjalizowany — nic do sprzątania.
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function FederatedAppProvider({
|
|
79
|
+
binding,
|
|
80
|
+
children,
|
|
81
|
+
}: {
|
|
82
|
+
binding: FederatedBinding;
|
|
83
|
+
children: ReactNode;
|
|
84
|
+
}) {
|
|
85
|
+
const hooks = getOrCreateModel(binding);
|
|
86
|
+
const { ModelProvider } = hooks;
|
|
87
|
+
// Wrappery APLIKACJI (np. jej AuthProvider) — wyłączone z globalnego
|
|
88
|
+
// renderu hosta (registry.getWrapperFragments), owijają tylko jej obszar.
|
|
89
|
+
let content: ReactNode = children;
|
|
90
|
+
const appWrappers = getFederatedWrapperFragments(binding.slug);
|
|
91
|
+
for (let i = appWrappers.length - 1; i >= 0; i--) {
|
|
92
|
+
const Wrapper = appWrappers[i].component;
|
|
93
|
+
content = <Wrapper>{content}</Wrapper>;
|
|
94
|
+
}
|
|
95
|
+
// Wrapper sesji hosta (wymiana asercji tożsamości → token aplikacji) —
|
|
96
|
+
// NA ZEWNĄTRZ wrapperów aplikacji (sesja gotowa zanim jej ProtectedRoute
|
|
97
|
+
// sprawdzi token), WEWNĄTRZ FederatedAppContext (scope'y modelu aplikacji).
|
|
98
|
+
const Session = binding.sessionWrapper;
|
|
99
|
+
content = Session ? (
|
|
100
|
+
<Session binding={binding}>{content}</Session>
|
|
101
|
+
) : (
|
|
102
|
+
content
|
|
103
|
+
);
|
|
104
|
+
return (
|
|
105
|
+
<ModelProvider>
|
|
106
|
+
<FederatedAppContext.Provider value={hooks}>
|
|
107
|
+
<AppMountProvider basePath={binding.basePath}>
|
|
108
|
+
{content}
|
|
109
|
+
</AppMountProvider>
|
|
110
|
+
</FederatedAppContext.Provider>
|
|
111
|
+
</ModelProvider>
|
|
112
|
+
);
|
|
113
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Federacja modułów — rejestracja modułów aplikacji PARTNERSKIEJ w hoście
|
|
3
|
+
* (spec/module-federation.md).
|
|
4
|
+
*
|
|
5
|
+
* Chunki aplikacji importowane z originu partnera wykonują przy imporcie
|
|
6
|
+
* zwykłe side-effecty `module().build()` → `registerModule`/`setContext`.
|
|
7
|
+
* Żeby moduł partnera nie zderzył się z modułami hosta (rejestr deduplikuje
|
|
8
|
+
* po NAZWIE, cicho — registry.ts) ani nie zmergował swoich elementów do
|
|
9
|
+
* globalnego kontekstu hosta, loader hosta otacza import oknem:
|
|
10
|
+
*
|
|
11
|
+
* beginFederatedRegistration(binding)
|
|
12
|
+
* await import(chunkUrl)
|
|
13
|
+
* await tick() // module().build() bywa odroczone do mikrotaska (arc.ts)
|
|
14
|
+
* endFederatedRegistration()
|
|
15
|
+
*
|
|
16
|
+
* W oknie interceptor (wywoływany z registry.ts):
|
|
17
|
+
* - prefiksuje nazwę modułu: `<slug>:<name>`,
|
|
18
|
+
* - przepisuje ścieżki stron pod `basePath` (rekurencyjnie children),
|
|
19
|
+
* - filtruje sloty do `allowedSlots` (grant z instalacji),
|
|
20
|
+
* - stempluje `federation` na module i fragmentach (PageRouter/SlotRenderer
|
|
21
|
+
* owijają je w FederatedAppProvider),
|
|
22
|
+
* - kieruje elementy kontekstu do KONTEKSTU PER-APLIKACJA (globalny model
|
|
23
|
+
* hosta nigdy nie widzi elementów partnera).
|
|
24
|
+
*
|
|
25
|
+
* KONTRAKT dla loadera: `endFederatedRegistration()` wolno wywołać dopiero po
|
|
26
|
+
* tiku (`await new Promise(r => setTimeout(r))`) — inaczej odroczona
|
|
27
|
+
* rejestracja wykona się POZA oknem i moduł wpadnie do hosta bez namespace.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import type { ArcFragment, ArcModule, FederatedBinding, PageFragment } from "./types";
|
|
31
|
+
|
|
32
|
+
let activeBinding: FederatedBinding | null = null;
|
|
33
|
+
|
|
34
|
+
/** Konteksty per aplikacja (slug → ArcContext) — patrz FederatedAppProvider. */
|
|
35
|
+
const federatedContexts = new Map<string, any>();
|
|
36
|
+
|
|
37
|
+
/** Zarejestrowane wiązania (slug → binding) — dla UI hosta i providera. */
|
|
38
|
+
const federatedApps = new Map<string, FederatedBinding>();
|
|
39
|
+
const appListeners = new Set<() => void>();
|
|
40
|
+
|
|
41
|
+
export function beginFederatedRegistration(binding: FederatedBinding): void {
|
|
42
|
+
if (activeBinding) {
|
|
43
|
+
throw new Error(
|
|
44
|
+
`[arc] beginFederatedRegistration("${binding.slug}") while ` +
|
|
45
|
+
`"${activeBinding.slug}" is still open — federated imports must not ` +
|
|
46
|
+
`overlap (await the tick + endFederatedRegistration() first).`,
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
activeBinding = binding;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function endFederatedRegistration(): void {
|
|
53
|
+
activeBinding = null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Aktywne wiązanie okna rejestracji (dla registry.ts). */
|
|
57
|
+
export function getActiveFederatedBinding(): FederatedBinding | null {
|
|
58
|
+
return activeBinding;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Nadaje modułowi tożsamość federowaną — wołane z registerModule/
|
|
63
|
+
* forceRegisterModule PRZED wstawieniem do rejestru, tylko w otwartym oknie.
|
|
64
|
+
* Mutuje obiekt modułu/fragmentów in-place (fragmenty są współdzielone przez
|
|
65
|
+
* referencję z komponentami — kopiowanie zrywałoby tożsamość).
|
|
66
|
+
*
|
|
67
|
+
* Zwraca `false`, gdy moduł NIE MOŻE trafić do rejestru hosta — wtedy
|
|
68
|
+
* `registerModule` musi go pominąć.
|
|
69
|
+
*/
|
|
70
|
+
export function applyFederationToModule(mod: ArcModule): boolean {
|
|
71
|
+
const binding = activeBinding;
|
|
72
|
+
if (!binding) return true;
|
|
73
|
+
if (mod.federation) return true; // już przepisany (re-import w dev)
|
|
74
|
+
|
|
75
|
+
// KONTRAKT `exposeToHosts`: host przyjmuje WYŁĄCZNIE moduły, które partner
|
|
76
|
+
// jawnie wystawił (`manifest.app.modules`). Chunk wystawionego modułu wciąga
|
|
77
|
+
// transitywnie inne pakiety aplikacji, a ich `module().build()` odpala się
|
|
78
|
+
// przy imporcie — czyli WEWNĄTRZ tego okna. Bez tego filtra cała nawigacja
|
|
79
|
+
// partnera (strategia, treści, inspiracje…) wjeżdżałaby do hosta, mimo że
|
|
80
|
+
// wystawiony był jeden moduł.
|
|
81
|
+
if (binding.allowedModules && !binding.allowedModules.includes(mod.name)) {
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
(mod as { name: string }).name = `${binding.slug}:${mod.name}`;
|
|
86
|
+
mod.federation = binding;
|
|
87
|
+
|
|
88
|
+
const seenPages = new Set<PageFragment>();
|
|
89
|
+
const rewritePage = (page: PageFragment): void => {
|
|
90
|
+
if (seenPages.has(page)) return;
|
|
91
|
+
seenPages.add(page);
|
|
92
|
+
// basePath + "/" → basePath (root aplikacji), reszta prefiksowana wprost.
|
|
93
|
+
const mounted =
|
|
94
|
+
page.path === "/" ? binding.basePath || "/" : binding.basePath + page.path;
|
|
95
|
+
(page as { path: string }).path = mounted;
|
|
96
|
+
page.federation = binding;
|
|
97
|
+
for (const child of page.children ?? []) rewritePage(child);
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const applyToFragments = (fragments: readonly ArcFragment[]): ArcFragment[] => {
|
|
101
|
+
const kept: ArcFragment[] = [];
|
|
102
|
+
for (const fragment of fragments) {
|
|
103
|
+
fragment.federation = binding;
|
|
104
|
+
if (fragment.is("page")) {
|
|
105
|
+
rewritePage(fragment as PageFragment);
|
|
106
|
+
} else if (fragment.is("slot")) {
|
|
107
|
+
const slotId = (fragment as { slotId?: string }).slotId ?? "";
|
|
108
|
+
// Sloty hosta tylko z grantu instalacji — reszta cicho odpada
|
|
109
|
+
// (deweloper widzi to w discovery/instalacji, nie w runtime hosta).
|
|
110
|
+
if (!binding.allowedSlots?.includes(slotId)) continue;
|
|
111
|
+
}
|
|
112
|
+
kept.push(fragment);
|
|
113
|
+
}
|
|
114
|
+
return kept;
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
(mod as { fragments: readonly ArcFragment[] }).fragments = applyToFragments(
|
|
118
|
+
mod.fragments,
|
|
119
|
+
);
|
|
120
|
+
(mod as { publicFragments: readonly ArcFragment[] }).publicFragments =
|
|
121
|
+
applyToFragments(mod.publicFragments);
|
|
122
|
+
return true;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Kierowanie elementów kontekstu w oknie federacji: elementy partnera trafiają
|
|
127
|
+
* do kontekstu per-aplikacja zamiast do globalnego kontekstu hosta. Zwraca
|
|
128
|
+
* true, gdy przejęło rejestrację (registry.setContext ma nie mergować).
|
|
129
|
+
*/
|
|
130
|
+
export function interceptSetContext(ctx: any): boolean {
|
|
131
|
+
const binding = activeBinding;
|
|
132
|
+
if (!binding || !ctx) return false;
|
|
133
|
+
const existing = federatedContexts.get(binding.slug);
|
|
134
|
+
if (!existing || existing === ctx) {
|
|
135
|
+
federatedContexts.set(binding.slug, ctx);
|
|
136
|
+
return true;
|
|
137
|
+
}
|
|
138
|
+
// Merge elementów kolejnych modułów tej samej aplikacji (dedup po name —
|
|
139
|
+
// lustro registry.setContext, ale w obrębie kontekstu aplikacji).
|
|
140
|
+
const names = new Set(existing.elements.map((e: any) => e.name ?? e.id ?? e));
|
|
141
|
+
for (const el of ctx.elements) {
|
|
142
|
+
const key = el.name ?? el.id ?? el;
|
|
143
|
+
if (names.has(key)) continue;
|
|
144
|
+
names.add(key);
|
|
145
|
+
existing.elements.push(el);
|
|
146
|
+
if (typeof el.name === "string" && existing.elementMap) {
|
|
147
|
+
existing.elementMap.set(el.name, el);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return true;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Kontekst aplikacji (elementy jej modułów) — dla FederatedAppProvider. */
|
|
154
|
+
export function getFederatedContext(slug: string): any {
|
|
155
|
+
return federatedContexts.get(slug) ?? null;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Rejestruje aplikację po udanym załadowaniu jej modułów. */
|
|
159
|
+
export function registerFederatedApp(binding: FederatedBinding): void {
|
|
160
|
+
federatedApps.set(binding.slug, binding);
|
|
161
|
+
for (const l of appListeners) l();
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function unregisterFederatedApp(slug: string): void {
|
|
165
|
+
federatedApps.delete(slug);
|
|
166
|
+
federatedContexts.delete(slug);
|
|
167
|
+
for (const l of appListeners) l();
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function getFederatedApp(slug: string): FederatedBinding | undefined {
|
|
171
|
+
return federatedApps.get(slug);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function getFederatedApps(): FederatedBinding[] {
|
|
175
|
+
return [...federatedApps.values()];
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function subscribeFederatedApps(listener: () => void): () => void {
|
|
179
|
+
appListeners.add(listener);
|
|
180
|
+
return () => appListeners.delete(listener);
|
|
181
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -10,6 +10,7 @@ export {
|
|
|
10
10
|
forceRegisterModule,
|
|
11
11
|
getAllFragments,
|
|
12
12
|
getAllModuleAccess,
|
|
13
|
+
getAllModuleExposure,
|
|
13
14
|
getAllModules,
|
|
14
15
|
getAllRegisteredModules,
|
|
15
16
|
getContext,
|
|
@@ -58,6 +59,34 @@ export type {
|
|
|
58
59
|
UseExpandableReturn,
|
|
59
60
|
} from "@arcote.tech/arc-ds";
|
|
60
61
|
|
|
62
|
+
// Module federation (spec/module-federation.md) — rejestracja modułów
|
|
63
|
+
// aplikacji partnerskich pod namespace + per-app model + standard routingu.
|
|
64
|
+
export {
|
|
65
|
+
beginFederatedRegistration,
|
|
66
|
+
endFederatedRegistration,
|
|
67
|
+
registerFederatedApp,
|
|
68
|
+
unregisterFederatedApp,
|
|
69
|
+
getFederatedApp,
|
|
70
|
+
getFederatedApps,
|
|
71
|
+
getFederatedContext,
|
|
72
|
+
subscribeFederatedApps,
|
|
73
|
+
} from "./federation";
|
|
74
|
+
export {
|
|
75
|
+
FederatedAppProvider,
|
|
76
|
+
FederatedAppContext,
|
|
77
|
+
useFederatedAppModel,
|
|
78
|
+
resetFederatedAppModel,
|
|
79
|
+
} from "./federated-app";
|
|
80
|
+
export {
|
|
81
|
+
AppMountProvider,
|
|
82
|
+
useAppBasePath,
|
|
83
|
+
useAppHref,
|
|
84
|
+
useAppNavigate,
|
|
85
|
+
appHref,
|
|
86
|
+
} from "./app-mount";
|
|
87
|
+
export { usePlatformHostScope } from "./platform-context";
|
|
88
|
+
export type { FederatedBinding, FederationConfig } from "./types";
|
|
89
|
+
|
|
61
90
|
// Layout (platform-specific — depends on registry/router)
|
|
62
91
|
export { BlankLayout } from "./layout/blank-layout";
|
|
63
92
|
export { ArcLayoutProvider } from "./layout/layout-provider";
|
|
@@ -1,11 +1,24 @@
|
|
|
1
1
|
import { AnimatePresence, motion } from "framer-motion";
|
|
2
|
-
import { Suspense, useEffect, useRef } from "react";
|
|
2
|
+
import { Suspense, useEffect, useRef, type ReactNode } from "react";
|
|
3
3
|
import { getDefaultLayout } from "../registry";
|
|
4
|
+
import { useModulesReady } from "../module-loader";
|
|
4
5
|
import { usePageFragments } from "./use-page-fragments";
|
|
5
6
|
import { useArcNavigate, useArcRoute, matchRoutePath, hasRouteParams } from "../router";
|
|
6
7
|
import { useArcRouter } from "@arcote.tech/arc-ds";
|
|
8
|
+
import { FederatedAppProvider } from "../federated-app";
|
|
7
9
|
import type { PageFragment } from "../types";
|
|
8
10
|
|
|
11
|
+
/** Strony modułów federowanych renderują się w modelu SWOJEJ aplikacji. */
|
|
12
|
+
function withFederation(page: PageFragment, content: ReactNode): ReactNode {
|
|
13
|
+
return page.federation ? (
|
|
14
|
+
<FederatedAppProvider binding={page.federation}>
|
|
15
|
+
{content}
|
|
16
|
+
</FederatedAppProvider>
|
|
17
|
+
) : (
|
|
18
|
+
content
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
|
|
9
22
|
type MatchResult = {
|
|
10
23
|
page: PageFragment;
|
|
11
24
|
child?: PageFragment;
|
|
@@ -95,6 +108,7 @@ export function PageRouter() {
|
|
|
95
108
|
const pages = usePageFragments();
|
|
96
109
|
const match = matchPage(route, pages);
|
|
97
110
|
const DefaultLayout = getDefaultLayout();
|
|
111
|
+
const modulesReady = useModulesReady();
|
|
98
112
|
|
|
99
113
|
// Per-route count of fallback redirects we've issued from an unmatched path.
|
|
100
114
|
// Persists across the redirect ping-pong (cleared per route once it matches).
|
|
@@ -122,25 +136,45 @@ export function PageRouter() {
|
|
|
122
136
|
redirectCounts.current.delete(route);
|
|
123
137
|
return;
|
|
124
138
|
}
|
|
139
|
+
// Modules still arriving → the page for this route may simply not be
|
|
140
|
+
// registered YET. `modulesReady` defaults to true, so apps without a
|
|
141
|
+
// module loader are unaffected.
|
|
142
|
+
if (!modulesReady) return;
|
|
125
143
|
if (pages.length === 0 || route === "/") return;
|
|
126
144
|
if (loopedRoutes.current.has(route)) return; // already gave up on this route
|
|
127
145
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
146
|
+
// DEFER the fallback by one macrotask. On a cold deep link the first
|
|
147
|
+
// render happens before the app is warm: initial chunks are in (so
|
|
148
|
+
// `pages.length > 0` and `modulesReady` is already true), but the auth
|
|
149
|
+
// provider has not applied the persisted token yet — and React runs child
|
|
150
|
+
// effects BEFORE parent ones, so this effect sees a state that is about to
|
|
151
|
+
// change. Redirecting synchronously would `pushState` over the URL and no
|
|
152
|
+
// later registration could bring the user back (token-gated chunks,
|
|
153
|
+
// federated `/a/<slug>` routes). Setting the token flips `modulesReady`
|
|
154
|
+
// to false within the same commit, which re-runs this effect and cancels
|
|
155
|
+
// the timer below. A route that is genuinely absent still redirects — just
|
|
156
|
+
// one tick later, once everything has settled.
|
|
157
|
+
const id = setTimeout(() => {
|
|
158
|
+
const count = redirectCounts.current.get(route) ?? 0;
|
|
159
|
+
if (count >= MAX_REDIRECTS_PER_ROUTE) {
|
|
160
|
+
loopedRoutes.current.add(route);
|
|
161
|
+
console.error(
|
|
162
|
+
`[arc] PageRouter: route "${route}" has no registered page and the ` +
|
|
163
|
+
`fallback redirect is looping — an app guard keeps sending the user ` +
|
|
164
|
+
`back here. Stopping on a blank screen. Likely cause: the page for ` +
|
|
165
|
+
`this route lives in a token-gated chunk not loaded for the current ` +
|
|
166
|
+
`token (check protectedBy / module chunk grouping).`,
|
|
167
|
+
);
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
redirectCounts.current.set(route, count + 1);
|
|
171
|
+
const firstNav = pages.find(
|
|
172
|
+
(p) => p.icon && p.label && !p.path.includes("/:"),
|
|
137
173
|
);
|
|
138
|
-
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
navigate(firstNav?.path ?? "/");
|
|
143
|
-
}, [match, pages, route, navigate]);
|
|
174
|
+
navigate(firstNav?.path ?? "/");
|
|
175
|
+
}, 0);
|
|
176
|
+
return () => clearTimeout(id);
|
|
177
|
+
}, [match, pages, route, navigate, modulesReady]);
|
|
144
178
|
|
|
145
179
|
if (!match) {
|
|
146
180
|
if (DefaultLayout) return <DefaultLayout>{null}</DefaultLayout>;
|
|
@@ -161,14 +195,15 @@ export function PageRouter() {
|
|
|
161
195
|
const OuterShell = page.component;
|
|
162
196
|
const InnerShell = child.component;
|
|
163
197
|
const GrandchildComponent = grandchild.component;
|
|
164
|
-
const content = (
|
|
198
|
+
const content = withFederation(
|
|
199
|
+
page,
|
|
165
200
|
<OuterShell tabs={page.children}>
|
|
166
201
|
<InnerShell tabs={child.children}>
|
|
167
202
|
<Suspense fallback={null}>
|
|
168
203
|
<GrandchildComponent />
|
|
169
204
|
</Suspense>
|
|
170
205
|
</InnerShell>
|
|
171
|
-
</OuterShell
|
|
206
|
+
</OuterShell>,
|
|
172
207
|
);
|
|
173
208
|
return Layout ? <Layout>{content}</Layout> : content;
|
|
174
209
|
}
|
|
@@ -183,19 +218,21 @@ export function PageRouter() {
|
|
|
183
218
|
if (page.children?.length && child) {
|
|
184
219
|
const Shell = page.component;
|
|
185
220
|
const ChildComponent = child.component;
|
|
186
|
-
const content = (
|
|
221
|
+
const content = withFederation(
|
|
222
|
+
page,
|
|
187
223
|
<Shell tabs={page.children}>
|
|
188
224
|
<Suspense fallback={null}>
|
|
189
225
|
<ChildComponent />
|
|
190
226
|
</Suspense>
|
|
191
|
-
</Shell
|
|
227
|
+
</Shell>,
|
|
192
228
|
);
|
|
193
229
|
return Layout ? <Layout>{content}</Layout> : content;
|
|
194
230
|
}
|
|
195
231
|
|
|
196
232
|
// Regular page (no children)
|
|
197
233
|
const PageComponent = page.component;
|
|
198
|
-
const pageContent = (
|
|
234
|
+
const pageContent = withFederation(
|
|
235
|
+
page,
|
|
199
236
|
<AnimatePresence mode="wait">
|
|
200
237
|
<motion.div
|
|
201
238
|
key={page.path}
|
|
@@ -208,7 +245,7 @@ export function PageRouter() {
|
|
|
208
245
|
<PageComponent />
|
|
209
246
|
</Suspense>
|
|
210
247
|
</motion.div>
|
|
211
|
-
</AnimatePresence
|
|
248
|
+
</AnimatePresence>,
|
|
212
249
|
);
|
|
213
250
|
|
|
214
251
|
return Layout ? <Layout>{pageContent}</Layout> : pageContent;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { DisplayMode } from "@arcote.tech/arc-ds";
|
|
2
2
|
import { DisplayModeProvider, useDynamicSlotContent } from "@arcote.tech/arc-ds";
|
|
3
3
|
import { Suspense } from "react";
|
|
4
|
+
import { FederatedAppProvider } from "../federated-app";
|
|
4
5
|
import type { SlotId } from "../types";
|
|
5
6
|
import { useSlotFragments } from "./use-slot-fragments";
|
|
6
7
|
|
|
@@ -44,6 +45,14 @@ export function SlotRenderer({
|
|
|
44
45
|
<div className={className}>
|
|
45
46
|
{fragments.map((fragment, index) => {
|
|
46
47
|
const Component = fragment.component;
|
|
48
|
+
// Slot modułu federowanego renderuje się w modelu SWOJEJ aplikacji.
|
|
49
|
+
const rendered = fragment.federation ? (
|
|
50
|
+
<FederatedAppProvider binding={fragment.federation}>
|
|
51
|
+
<Component />
|
|
52
|
+
</FederatedAppProvider>
|
|
53
|
+
) : (
|
|
54
|
+
<Component />
|
|
55
|
+
);
|
|
47
56
|
return (
|
|
48
57
|
<div key={fragment.id} className="contents">
|
|
49
58
|
{index > 0 && (
|
|
@@ -55,7 +64,7 @@ export function SlotRenderer({
|
|
|
55
64
|
/>
|
|
56
65
|
)}
|
|
57
66
|
<Suspense fallback={suspenseFallback ?? null}>
|
|
58
|
-
|
|
67
|
+
{rendered}
|
|
59
68
|
</Suspense>
|
|
60
69
|
</div>
|
|
61
70
|
);
|
package/src/module-loader.ts
CHANGED
|
@@ -81,6 +81,11 @@ function decodeJwtPayload(jwt: string): { exp?: number } | null {
|
|
|
81
81
|
* Read all persisted arc tokens from localStorage, dropping any whose `exp`
|
|
82
82
|
* claim is in the past. Stale tokens are removed from localStorage so they
|
|
83
83
|
* don't keep ghosting in subsequent reads.
|
|
84
|
+
*
|
|
85
|
+
* Reads ONLY the host's (un-namespaced) tokens: keys whose scope part
|
|
86
|
+
* contains `:` belong to a federated app's model namespace
|
|
87
|
+
* (`arc:token:<ns>:<scope>`, see AuthAdapter) and must never be sent to the
|
|
88
|
+
* host in X-Arc-Tokens.
|
|
84
89
|
*/
|
|
85
90
|
function getAllPersistedTokens(): Record<string, string> {
|
|
86
91
|
if (typeof localStorage === "undefined") return {};
|
|
@@ -90,6 +95,8 @@ function getAllPersistedTokens(): Record<string, string> {
|
|
|
90
95
|
for (let i = 0; i < localStorage.length; i++) {
|
|
91
96
|
const key = localStorage.key(i);
|
|
92
97
|
if (!key?.startsWith("arc:token:")) continue;
|
|
98
|
+
const scope = key.slice(10);
|
|
99
|
+
if (scope.includes(":")) continue; // namespaced = token cudzego modelu
|
|
93
100
|
const raw = localStorage.getItem(key);
|
|
94
101
|
if (!raw) continue;
|
|
95
102
|
const payload = decodeJwtPayload(raw);
|
|
@@ -97,7 +104,7 @@ function getAllPersistedTokens(): Record<string, string> {
|
|
|
97
104
|
stale.push(key);
|
|
98
105
|
continue;
|
|
99
106
|
}
|
|
100
|
-
tokens[
|
|
107
|
+
tokens[scope] = raw;
|
|
101
108
|
}
|
|
102
109
|
for (const key of stale) localStorage.removeItem(key);
|
|
103
110
|
return tokens;
|
|
@@ -434,9 +441,11 @@ export function useModuleLoader(
|
|
|
434
441
|
if (typeof window === "undefined") return;
|
|
435
442
|
|
|
436
443
|
const onStorage = (e: StorageEvent) => {
|
|
437
|
-
if (e.key?.startsWith("arc:token:"))
|
|
438
|
-
|
|
439
|
-
|
|
444
|
+
if (!e.key?.startsWith("arc:token:")) return;
|
|
445
|
+
// Namespaced keys (`arc:token:<ns>:<scope>`) belong to federated app
|
|
446
|
+
// models — host module sync must not react to them.
|
|
447
|
+
if (e.key.slice(10).includes(":")) return;
|
|
448
|
+
setTokenVersion((v) => v + 1);
|
|
440
449
|
};
|
|
441
450
|
window.addEventListener("storage", onStorage);
|
|
442
451
|
|
package/src/platform-context.tsx
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { ArcContextAny } from "@arcote.tech/arc";
|
|
2
2
|
import type { ScopeAPI } from "@arcote.tech/arc-react";
|
|
3
3
|
import { createContext, useContext, type ReactNode } from "react";
|
|
4
|
+
import { FederatedAppContext } from "./federated-app";
|
|
4
5
|
|
|
5
6
|
interface PlatformModelHooks {
|
|
6
7
|
useModel: any;
|
|
@@ -37,9 +38,31 @@ export function usePlatformScope<C extends ArcContextAny>(
|
|
|
37
38
|
_ctx: C,
|
|
38
39
|
name: string,
|
|
39
40
|
): ScopeAPI<C> {
|
|
40
|
-
|
|
41
|
+
// Wewnątrz modułu FEDEROWANEGO scope trafia w model PARTNERA (osobne
|
|
42
|
+
// wire'y na jego serwer) — kod aplikacji jest identyczny standalone i w
|
|
43
|
+
// hoście. Oba useContext wołane bezwarunkowo (rules-of-hooks).
|
|
44
|
+
const federated = useContext(FederatedAppContext);
|
|
45
|
+
const platform = useContext(PlatformModelContext);
|
|
46
|
+
const hooks = federated ?? platform;
|
|
47
|
+
if (!hooks)
|
|
48
|
+
throw new Error(
|
|
49
|
+
"usePlatformScope must be used within PlatformModelProvider",
|
|
50
|
+
);
|
|
51
|
+
return hooks.createScope(name) as unknown as ScopeAPI<C>;
|
|
41
52
|
}
|
|
42
53
|
|
|
43
54
|
export function usePlatformResetModel() {
|
|
44
55
|
return usePlatformModel().resetModel;
|
|
45
56
|
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Scope modelu HOSTA — z pominięciem kontekstu federowanego. Dla kodu hosta
|
|
60
|
+
* renderowanego wewnątrz FederatedAppProvider (np. wrapper sesji, który musi
|
|
61
|
+
* zawołać agregaty hosta o asercję tożsamości, będąc w drzewie aplikacji).
|
|
62
|
+
*/
|
|
63
|
+
export function usePlatformHostScope<C extends ArcContextAny>(
|
|
64
|
+
_ctx: C,
|
|
65
|
+
name: string,
|
|
66
|
+
): ScopeAPI<C> {
|
|
67
|
+
return usePlatformModel().createScope(name) as unknown as ScopeAPI<C>;
|
|
68
|
+
}
|
package/src/registry.ts
CHANGED
|
@@ -4,11 +4,16 @@ import type {
|
|
|
4
4
|
ArcModule,
|
|
5
5
|
ContextElementFragment,
|
|
6
6
|
ModuleAccess,
|
|
7
|
+
ModuleExposure,
|
|
7
8
|
PageFragment,
|
|
8
9
|
SlotFragment,
|
|
9
10
|
SlotId,
|
|
10
11
|
WrapperFragment,
|
|
11
12
|
} from "./types";
|
|
13
|
+
import {
|
|
14
|
+
applyFederationToModule,
|
|
15
|
+
interceptSetContext,
|
|
16
|
+
} from "./federation";
|
|
12
17
|
|
|
13
18
|
// ---------------------------------------------------------------------------
|
|
14
19
|
// Registry — globalny rejestr modułów i ich fragmentów
|
|
@@ -34,6 +39,10 @@ export function subscribe(listener: () => void): () => void {
|
|
|
34
39
|
* Use `forceRegisterModule` for dev reloads where code has actually changed.
|
|
35
40
|
*/
|
|
36
41
|
export function registerModule(mod: ArcModule): void {
|
|
42
|
+
// W oknie federacji moduł partnera dostaje namespace `slug:` + prefiks
|
|
43
|
+
// ścieżek PRZED dedupem po nazwie (federation.ts). `false` = moduł spoza
|
|
44
|
+
// `exposeToHosts` partnera — nie wpuszczamy go do rejestru hosta.
|
|
45
|
+
if (!applyFederationToModule(mod)) return;
|
|
37
46
|
for (const existing of modules.values()) {
|
|
38
47
|
if (existing.name === mod.name) return;
|
|
39
48
|
}
|
|
@@ -44,6 +53,7 @@ export function registerModule(mod: ArcModule): void {
|
|
|
44
53
|
/** Force-register module, replacing any existing module with the same name.
|
|
45
54
|
* Used for dev reloads where code has changed on disk. */
|
|
46
55
|
export function forceRegisterModule(mod: ArcModule): void {
|
|
56
|
+
if (!applyFederationToModule(mod)) return;
|
|
47
57
|
for (const [id, existing] of modules) {
|
|
48
58
|
if (existing.name === mod.name && id !== mod.id) {
|
|
49
59
|
modules.delete(id);
|
|
@@ -59,12 +69,35 @@ export function unregisterModule(moduleId: string): void {
|
|
|
59
69
|
notify();
|
|
60
70
|
}
|
|
61
71
|
|
|
72
|
+
/**
|
|
73
|
+
* Dopasowanie ścieżki do wzorca trasy publicznej. Reguły:
|
|
74
|
+
* - dokładne dopasowanie,
|
|
75
|
+
* - segmenty `:param` we wzorcu to wildcard (jeden segment) — dzięki temu
|
|
76
|
+
* parametryzowane trasy publiczne (`/invite/:token`) pokrywają `/invite/abc`,
|
|
77
|
+
* - prefiks segmentowy — wzorzec krótszy niż ścieżka pokrywa podścieżki
|
|
78
|
+
* (`/checkout` pokrywa `/checkout/success`).
|
|
79
|
+
* Wzorzec `/` (root) dopasowuje się WYŁĄCZNIE dokładnie (nie robi wszystkiego
|
|
80
|
+
* publicznym).
|
|
81
|
+
*/
|
|
82
|
+
function pathMatchesPublicPattern(clean: string, pattern: string): boolean {
|
|
83
|
+
if (clean === pattern) return true;
|
|
84
|
+
const ps = pattern.split("/").filter(Boolean);
|
|
85
|
+
if (ps.length === 0) return false; // "/" — tylko dokładne (obsłużone wyżej)
|
|
86
|
+
const cs = clean.split("/").filter(Boolean);
|
|
87
|
+
if (ps.length > cs.length) return false;
|
|
88
|
+
for (let i = 0; i < ps.length; i++) {
|
|
89
|
+
if (ps[i].startsWith(":")) continue; // param = dowolny jeden segment
|
|
90
|
+
if (ps[i] !== cs[i]) return false;
|
|
91
|
+
}
|
|
92
|
+
return true;
|
|
93
|
+
}
|
|
94
|
+
|
|
62
95
|
/**
|
|
63
96
|
* Czy ścieżka należy do strony zadeklarowanej w `.public({...})` któregokolwiek
|
|
64
97
|
* zarejestrowanego modułu. Źródłem prawdy jest rejestr — wrappery ochronne
|
|
65
98
|
* (np. ProtectedRoute aplikacji) NIE muszą hardcodować listy publicznych
|
|
66
|
-
* ścieżek. Dopasowanie: dokładne lub prefiks
|
|
67
|
-
* (`/checkout` pokrywa `/checkout/success`); query/hash ignorowane.
|
|
99
|
+
* ścieżek. Dopasowanie: dokładne, po parametrach (`:token`) lub prefiks
|
|
100
|
+
* segmentowy (`/checkout` pokrywa `/checkout/success`); query/hash ignorowane.
|
|
68
101
|
*/
|
|
69
102
|
export function isPublicPagePath(path: string): boolean {
|
|
70
103
|
const clean = path.split("?")[0].split("#")[0] || "/";
|
|
@@ -73,8 +106,7 @@ export function isPublicPagePath(path: string): boolean {
|
|
|
73
106
|
if (!fragment?.is?.("page")) continue;
|
|
74
107
|
const pagePath = (fragment as PageFragment).path;
|
|
75
108
|
if (!pagePath) continue;
|
|
76
|
-
if (clean
|
|
77
|
-
if (pagePath !== "/" && clean.startsWith(`${pagePath}/`)) return true;
|
|
109
|
+
if (pathMatchesPublicPattern(clean, pagePath)) return true;
|
|
78
110
|
}
|
|
79
111
|
}
|
|
80
112
|
return false;
|
|
@@ -86,7 +118,15 @@ export function getModule(moduleId: string): ArcModule | undefined {
|
|
|
86
118
|
|
|
87
119
|
export function getAllModules(): ArcModule[] {
|
|
88
120
|
if (!activeModuleNames) return [...modules.values()];
|
|
89
|
-
|
|
121
|
+
// Moduły FEDEROWANE są poza filtrem aktywności: ta lista powstaje z manifestu
|
|
122
|
+
// HOSTA (module-loader → setActiveModules), a moduł partnera z definicji się
|
|
123
|
+
// w nim nie znajdzie. Bez tego wyjątku każdy sync chunków po zmianie tokena
|
|
124
|
+
// wymiatał `slug:moduł` tuż po tym, jak loader federacji go zarejestrował —
|
|
125
|
+
// trasy `/a/<slug>/*` znikały, a router odsyłał użytkownika na stronę
|
|
126
|
+
// startową. Ich cyklem życia zarządza loader federacji, nie sync hosta.
|
|
127
|
+
return [...modules.values()].filter(
|
|
128
|
+
(m) => m.federation || activeModuleNames!.has(m.name),
|
|
129
|
+
);
|
|
90
130
|
}
|
|
91
131
|
|
|
92
132
|
/** Get ALL registered modules (ignoring active filter). */
|
|
@@ -124,6 +164,9 @@ function notifyContext(): void {
|
|
|
124
164
|
* like `model.context.get("strategyConsultation")` would yield undefined
|
|
125
165
|
* for any module registered after the model was created. */
|
|
126
166
|
export function setContext(ctx: any): void {
|
|
167
|
+
// W oknie federacji elementy partnera idą do kontekstu PER-APLIKACJA —
|
|
168
|
+
// globalny kontekst/model hosta nigdy ich nie widzi (federation.ts).
|
|
169
|
+
if (interceptSetContext(ctx)) return;
|
|
127
170
|
if (currentContext && ctx && currentContext !== ctx) {
|
|
128
171
|
const existingNames = new Set(
|
|
129
172
|
currentContext.elements.map((e: any) => e.name ?? e.id ?? e),
|
|
@@ -260,7 +303,21 @@ export function getPageByPath(path: string): PageFragment | undefined {
|
|
|
260
303
|
|
|
261
304
|
export function getWrapperFragments(): WrapperFragment[] {
|
|
262
305
|
const fragments = getAllFragments().filter(
|
|
263
|
-
(f): f is WrapperFragment =>
|
|
306
|
+
(f): f is WrapperFragment =>
|
|
307
|
+
f.is("wrapper") &&
|
|
308
|
+
// Wrappery modułów FEDEROWANYCH nie owijają hosta globalnie (np.
|
|
309
|
+
// ProtectedRoute aplikacji przekierowywałby userów hosta!) — renderuje
|
|
310
|
+
// je FederatedAppProvider wokół obszaru SWOJEJ aplikacji.
|
|
311
|
+
!f.federation,
|
|
312
|
+
);
|
|
313
|
+
return sortByOrdering(fragments);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** Wrappery modułów danej aplikacji federowanej (dla FederatedAppProvider). */
|
|
317
|
+
export function getFederatedWrapperFragments(slug: string): WrapperFragment[] {
|
|
318
|
+
const fragments = getAllFragments().filter(
|
|
319
|
+
(f): f is WrapperFragment =>
|
|
320
|
+
f.is("wrapper") && f.federation?.slug === slug,
|
|
264
321
|
);
|
|
265
322
|
return sortByOrdering(fragments);
|
|
266
323
|
}
|
|
@@ -282,6 +339,19 @@ export function getAllModuleAccess(): Map<string, ModuleAccess> {
|
|
|
282
339
|
return result;
|
|
283
340
|
}
|
|
284
341
|
|
|
342
|
+
/**
|
|
343
|
+
* Deklaracje `exposeToHosts()` wszystkich modułów (federacja per-moduł) —
|
|
344
|
+
* czytane build-time przez access-extractor, żeby build wiedział, które
|
|
345
|
+
* moduły federować (spec/module-federation.md).
|
|
346
|
+
*/
|
|
347
|
+
export function getAllModuleExposure(): Map<string, ModuleExposure> {
|
|
348
|
+
const result = new Map<string, ModuleExposure>();
|
|
349
|
+
for (const mod of modules.values()) {
|
|
350
|
+
if (mod.expose) result.set(mod.name, mod.expose);
|
|
351
|
+
}
|
|
352
|
+
return result;
|
|
353
|
+
}
|
|
354
|
+
|
|
285
355
|
/** Clear all modules (but keep context). Used for reload. */
|
|
286
356
|
export function clearModules(): void {
|
|
287
357
|
modules.clear();
|
package/src/types.ts
CHANGED
|
@@ -26,11 +26,52 @@ export interface FragmentOrdering {
|
|
|
26
26
|
|
|
27
27
|
export type ArcComponent = ComponentType<any> | LazyExoticComponent<ComponentType<any>>;
|
|
28
28
|
|
|
29
|
+
/**
|
|
30
|
+
* Wiązanie modułu FEDEROWANEGO — modułu aplikacji partnerskiej załadowanego
|
|
31
|
+
* do hosta z innego originu (spec/module-federation.md). Ustawiane przez
|
|
32
|
+
* interceptor rejestracji (federation.ts) na module i jego fragmentach.
|
|
33
|
+
*/
|
|
34
|
+
export interface FederatedBinding {
|
|
35
|
+
/** Id aplikacji w hoście (np. wiersz agregatu aplikacji). */
|
|
36
|
+
readonly appId: string;
|
|
37
|
+
/** Slug aplikacji — namespace nazw modułów i tokenów ([a-z0-9-]+). */
|
|
38
|
+
readonly slug: string;
|
|
39
|
+
/** Origin serwera partnera (komendy/query/WS + chunki). */
|
|
40
|
+
readonly baseUrl: string;
|
|
41
|
+
/** Prefiks montowania stron w hoście (np. "/a/ndt"); "" = standalone. */
|
|
42
|
+
readonly basePath: string;
|
|
43
|
+
/** Dozwolone sloty hosta (grant z instalacji); undefined = żadne. */
|
|
44
|
+
readonly allowedSlots?: readonly string[];
|
|
45
|
+
/**
|
|
46
|
+
* Moduły, które partner WYSTAWIŁ (`exposeToHosts` → `manifest.app.modules`).
|
|
47
|
+
* Rejestracja przyjmuje wyłącznie te — chunk wystawionego modułu wciąga
|
|
48
|
+
* transitywnie inne pakiety aplikacji, których `module().build()` odpala się
|
|
49
|
+
* przy imporcie, więc bez tej listy cała nawigacja partnera wjeżdża do hosta.
|
|
50
|
+
* `undefined` = brak filtra (kompatybilność; host POWINIEN ją podawać).
|
|
51
|
+
*/
|
|
52
|
+
readonly allowedModules?: readonly string[];
|
|
53
|
+
/** Id instalacji w hoście (kotwica tożsamości — spec/app-identity.md). */
|
|
54
|
+
readonly installId?: string;
|
|
55
|
+
/** Wymiana tożsamości aplikacji (z manifestu federacji). */
|
|
56
|
+
readonly auth?: { readonly exchangeCommand: string; readonly scope: string };
|
|
57
|
+
/**
|
|
58
|
+
* Wrapper sesji wstrzykiwany przez HOSTA — renderowany WEWNĄTRZ
|
|
59
|
+
* FederatedAppProvider (ma dostęp do modelu aplikacji), wokół fragmentów.
|
|
60
|
+
* Host realizuje tu wymianę asercji tożsamości na token aplikacji.
|
|
61
|
+
*/
|
|
62
|
+
readonly sessionWrapper?: ComponentType<{
|
|
63
|
+
binding: FederatedBinding;
|
|
64
|
+
children?: ReactNode;
|
|
65
|
+
}>;
|
|
66
|
+
}
|
|
67
|
+
|
|
29
68
|
interface FragmentBase {
|
|
30
69
|
readonly id: string;
|
|
31
70
|
readonly types: readonly string[];
|
|
32
71
|
moduleId: string;
|
|
33
72
|
is(type: string): boolean;
|
|
73
|
+
/** Obecne, gdy fragment pochodzi z modułu federowanego (federation.ts). */
|
|
74
|
+
federation?: FederatedBinding;
|
|
34
75
|
}
|
|
35
76
|
|
|
36
77
|
// ---------------------------------------------------------------------------
|
|
@@ -149,18 +190,80 @@ export interface BuildManifest {
|
|
|
149
190
|
/** sha256 hex over styles.css (+ theme.css if present). */
|
|
150
191
|
readonly stylesHash: string;
|
|
151
192
|
readonly buildTime: string;
|
|
193
|
+
/**
|
|
194
|
+
* Peer-shell (spec/module-federation.md): mapa specifier →
|
|
195
|
+
* `/shell/<short>.<hash>.js` (import mapa w shell HTML) + wersje peers.
|
|
196
|
+
* Obecne, gdy aplikacja jest HOSTEM (`arc.federation.host`) lub eksportuje
|
|
197
|
+
* moduły (`module().exposeToHosts()`).
|
|
198
|
+
*/
|
|
199
|
+
readonly shell?: {
|
|
200
|
+
readonly imports: Readonly<Record<string, string>>;
|
|
201
|
+
readonly peers: Readonly<Record<string, string>>;
|
|
202
|
+
};
|
|
203
|
+
/**
|
|
204
|
+
* Obecne, gdy aplikacja eksportuje moduły do zewnętrznych hostów (jakiś
|
|
205
|
+
* moduł ma `exposeToHosts()` external/both). Zawiera OSOBNY build
|
|
206
|
+
* federowany (external peers, w `browser-fed/`) wystawiany przez
|
|
207
|
+
* `/api/federation/manifest`.
|
|
208
|
+
*/
|
|
209
|
+
readonly federation?: {
|
|
210
|
+
/** slug + auth aplikacji z `arc.federation` (spec/app-identity.md). */
|
|
211
|
+
readonly config: FederationConfig;
|
|
212
|
+
/** Nazwy modułów wystawionych (exposeToHosts external/both). */
|
|
213
|
+
readonly modules: readonly string[];
|
|
214
|
+
/** Uprawnienia/sloty żądane przez moduły (suma). */
|
|
215
|
+
readonly permissions: readonly string[];
|
|
216
|
+
readonly slots: readonly string[];
|
|
217
|
+
/** Artefakty buildu federowanego (browser-fed/). */
|
|
218
|
+
readonly build: {
|
|
219
|
+
readonly initial: { readonly file: string; readonly hash: string };
|
|
220
|
+
readonly groups: Readonly<Record<string, BuildManifestGroup>>;
|
|
221
|
+
readonly sharedChunks: readonly string[];
|
|
222
|
+
};
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* `arc.federation` w root package.json (spec/module-federation.md) — tożsamość
|
|
228
|
+
* aplikacji jako całości. Które MODUŁY są wystawione, decyduje
|
|
229
|
+
* `module().exposeToHosts()` (per-moduł), nie ten obiekt.
|
|
230
|
+
*/
|
|
231
|
+
export interface FederationConfig {
|
|
232
|
+
/** Host federacji (np. BMF): build z external peers + import mapa. */
|
|
233
|
+
readonly host?: boolean;
|
|
234
|
+
/** Slug aplikacji ([a-z0-9-]+) — namespace modułów i tokenów w hoście. */
|
|
235
|
+
readonly slug?: string;
|
|
236
|
+
/** Wymiana tożsamości: publiczna komenda + scope (spec/app-identity.md). */
|
|
237
|
+
readonly auth?: { readonly exchangeCommand: string; readonly scope: string };
|
|
152
238
|
}
|
|
153
239
|
|
|
154
240
|
// ---------------------------------------------------------------------------
|
|
155
241
|
// Moduł
|
|
156
242
|
// ---------------------------------------------------------------------------
|
|
157
243
|
|
|
244
|
+
/**
|
|
245
|
+
* Deklaracja `module().exposeToHosts(...)` — czy i jak moduł jest wystawiany
|
|
246
|
+
* zewnętrznym hostom federacji (spec/module-federation.md). Odczytywana
|
|
247
|
+
* build-time przez access-extractor.
|
|
248
|
+
*/
|
|
249
|
+
export interface ModuleExposure {
|
|
250
|
+
readonly exposure: "local" | "external" | "both";
|
|
251
|
+
/** Uprawnienia żądane od hosta (zgoda przy instalacji). */
|
|
252
|
+
readonly permissions?: readonly string[];
|
|
253
|
+
/** Sloty hosta, o które moduł prosi. */
|
|
254
|
+
readonly slots?: readonly string[];
|
|
255
|
+
}
|
|
256
|
+
|
|
158
257
|
export interface ArcModule {
|
|
159
258
|
readonly id: string;
|
|
160
259
|
readonly name: string;
|
|
161
260
|
readonly fragments: readonly ArcFragment[];
|
|
162
261
|
readonly publicFragments: readonly ArcFragment[];
|
|
163
262
|
readonly access?: ModuleAccess;
|
|
263
|
+
/** Deklaracja wystawienia modułu zewnętrznym hostom (exposeToHosts). */
|
|
264
|
+
readonly expose?: ModuleExposure;
|
|
265
|
+
/** Obecne, gdy moduł został zarejestrowany jako federowany (federation.ts). */
|
|
266
|
+
federation?: FederatedBinding;
|
|
164
267
|
}
|
|
165
268
|
|
|
166
269
|
// ---------------------------------------------------------------------------
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
beginFederatedRegistration,
|
|
4
|
+
endFederatedRegistration,
|
|
5
|
+
getFederatedContext,
|
|
6
|
+
unregisterFederatedApp,
|
|
7
|
+
} from "../src/federation";
|
|
8
|
+
import {
|
|
9
|
+
clearRegistry,
|
|
10
|
+
getAllRegisteredModules,
|
|
11
|
+
getContext,
|
|
12
|
+
registerModule,
|
|
13
|
+
setContext,
|
|
14
|
+
} from "../src/registry";
|
|
15
|
+
import { appHref } from "../src/app-mount";
|
|
16
|
+
import type { ArcModule, FederatedBinding, PageFragment } from "../src/types";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Testy okna rejestracji federowanej (spec/module-federation.md): namespacing
|
|
20
|
+
* nazw modułów, przepisywanie ścieżek stron pod basePath, filtr slotów po
|
|
21
|
+
* grantach, kierowanie elementów do kontekstu per-aplikacja oraz kontrakt
|
|
22
|
+
* ticku (odroczona rejestracja przez queueMicrotask — arc.ts).
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
const BINDING: FederatedBinding = {
|
|
26
|
+
appId: "app-1",
|
|
27
|
+
slug: "ndt",
|
|
28
|
+
baseUrl: "http://localhost:5007",
|
|
29
|
+
basePath: "/a/ndt",
|
|
30
|
+
allowedSlots: ["bmf:document-panel"],
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
function page(path: string, children?: PageFragment[]): PageFragment {
|
|
34
|
+
return {
|
|
35
|
+
id: `pg:${path}`,
|
|
36
|
+
types: ["page"] as const,
|
|
37
|
+
moduleId: "m",
|
|
38
|
+
is: (t: string) => t === "page",
|
|
39
|
+
path,
|
|
40
|
+
component: () => null,
|
|
41
|
+
children,
|
|
42
|
+
ordering: {},
|
|
43
|
+
} as unknown as PageFragment;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function slot(slotId: string) {
|
|
47
|
+
return {
|
|
48
|
+
id: `sl:${slotId}`,
|
|
49
|
+
types: ["slot"] as const,
|
|
50
|
+
moduleId: "m",
|
|
51
|
+
is: (t: string) => t === "slot",
|
|
52
|
+
slotId,
|
|
53
|
+
component: () => null,
|
|
54
|
+
ordering: {},
|
|
55
|
+
} as unknown as ArcModule["fragments"][number];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function mod(name: string, fragments: ArcModule["fragments"]): ArcModule {
|
|
59
|
+
return {
|
|
60
|
+
id: `${name}-${Math.random().toString(36).slice(2)}`,
|
|
61
|
+
name,
|
|
62
|
+
fragments,
|
|
63
|
+
publicFragments: fragments,
|
|
64
|
+
} as ArcModule;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function fakeCtx(names: string[]) {
|
|
68
|
+
const elements = names.map((n) => ({ name: n }));
|
|
69
|
+
return { elements, elementMap: new Map(elements.map((e) => [e.name, e])) };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
afterEach(() => {
|
|
73
|
+
endFederatedRegistration();
|
|
74
|
+
unregisterFederatedApp("ndt");
|
|
75
|
+
clearRegistry();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe("kontrakt exposeToHosts", () => {
|
|
79
|
+
// Regresja: chunk wystawionego modułu wciąga transitywnie inne pakiety
|
|
80
|
+
// aplikacji, a ich `module().build()` odpala się przy imporcie — czyli w
|
|
81
|
+
// otwartym oknie. Bez `allowedModules` cała nawigacja partnera (strategia,
|
|
82
|
+
// treści, inspiracje) wjeżdżała do hosta, mimo że wystawiony był `admin`.
|
|
83
|
+
const ONLY_ADMIN: FederatedBinding = { ...BINDING, allowedModules: ["admin"] };
|
|
84
|
+
|
|
85
|
+
test("rejestruje TYLKO moduły wystawione przez partnera", () => {
|
|
86
|
+
const admin = mod("admin", [page("/admin")]);
|
|
87
|
+
const strategia = mod("strategy", [page("/strategy")]);
|
|
88
|
+
const tresci = mod("content", [page("/content")]);
|
|
89
|
+
|
|
90
|
+
beginFederatedRegistration(ONLY_ADMIN);
|
|
91
|
+
registerModule(admin);
|
|
92
|
+
registerModule(strategia);
|
|
93
|
+
registerModule(tresci);
|
|
94
|
+
endFederatedRegistration();
|
|
95
|
+
|
|
96
|
+
const nazwy = getAllRegisteredModules().map((m) => m.name);
|
|
97
|
+
expect(nazwy).toEqual(["ndt:admin"]);
|
|
98
|
+
// Niewystawione moduły zostają NIETKNIĘTE — bez namespace'u i bez bindingu,
|
|
99
|
+
// więc nie da się ich pomylić z modułem federowanym.
|
|
100
|
+
expect(strategia.name).toBe("strategy");
|
|
101
|
+
expect(strategia.federation).toBeUndefined();
|
|
102
|
+
expect(tresci.federation).toBeUndefined();
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("bez allowedModules filtr nie obowiązuje (kompatybilność)", () => {
|
|
106
|
+
const cokolwiek = mod("strategy", [page("/strategy")]);
|
|
107
|
+
beginFederatedRegistration(BINDING);
|
|
108
|
+
registerModule(cokolwiek);
|
|
109
|
+
endFederatedRegistration();
|
|
110
|
+
expect(getAllRegisteredModules().map((m) => m.name)).toEqual([
|
|
111
|
+
"ndt:strategy",
|
|
112
|
+
]);
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
describe("okno rejestracji federowanej", () => {
|
|
117
|
+
test("namespacing nazwy + prefiks ścieżek (rekurencyjnie) + stamp federation", () => {
|
|
118
|
+
const tasks = page("/tasks", [page("/tasks/:id")]);
|
|
119
|
+
const root = page("/");
|
|
120
|
+
const m = mod("tasks", [tasks, root]);
|
|
121
|
+
|
|
122
|
+
beginFederatedRegistration(BINDING);
|
|
123
|
+
registerModule(m);
|
|
124
|
+
endFederatedRegistration();
|
|
125
|
+
|
|
126
|
+
expect(m.name).toBe("ndt:tasks");
|
|
127
|
+
expect(m.federation).toBe(BINDING);
|
|
128
|
+
expect(tasks.path).toBe("/a/ndt/tasks");
|
|
129
|
+
expect(tasks.children?.[0].path).toBe("/a/ndt/tasks/:id");
|
|
130
|
+
expect(root.path).toBe("/a/ndt");
|
|
131
|
+
expect(tasks.federation).toBe(BINDING);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("kolizja nazwy z modułem hosta znika (dedup widzi już ndt:...)", () => {
|
|
135
|
+
const host = mod("tasks", [page("/tasks")]);
|
|
136
|
+
registerModule(host);
|
|
137
|
+
|
|
138
|
+
const partner = mod("tasks", [page("/tasks")]);
|
|
139
|
+
beginFederatedRegistration(BINDING);
|
|
140
|
+
registerModule(partner);
|
|
141
|
+
endFederatedRegistration();
|
|
142
|
+
|
|
143
|
+
const names = getAllRegisteredModules().map((m) => m.name);
|
|
144
|
+
expect(names).toContain("tasks");
|
|
145
|
+
expect(names).toContain("ndt:tasks");
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("sloty spoza grantu odpadają; z grantu zostają", () => {
|
|
149
|
+
const allowed = slot("bmf:document-panel");
|
|
150
|
+
const denied = slot("toolbar-right");
|
|
151
|
+
const m = mod("widgets", [allowed, denied]);
|
|
152
|
+
|
|
153
|
+
beginFederatedRegistration(BINDING);
|
|
154
|
+
registerModule(m);
|
|
155
|
+
endFederatedRegistration();
|
|
156
|
+
|
|
157
|
+
const ids = m.fragments.map((f) => f.id);
|
|
158
|
+
expect(ids).toContain("sl:bmf:document-panel");
|
|
159
|
+
expect(ids).not.toContain("sl:toolbar-right");
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test("setContext w oknie idzie do kontekstu per-app, nie do hosta", () => {
|
|
163
|
+
setContext(fakeCtx(["hostEl"]));
|
|
164
|
+
|
|
165
|
+
beginFederatedRegistration(BINDING);
|
|
166
|
+
setContext(fakeCtx(["order", "placeOrder"]));
|
|
167
|
+
setContext(fakeCtx(["placeOrder", "orderPlaced"])); // drugi moduł tej samej app
|
|
168
|
+
endFederatedRegistration();
|
|
169
|
+
|
|
170
|
+
const hostCtx = getContext<{ elements: { name: string }[] }>();
|
|
171
|
+
expect(hostCtx.elements.map((e) => e.name)).toEqual(["hostEl"]);
|
|
172
|
+
|
|
173
|
+
const fedCtx = getFederatedContext("ndt");
|
|
174
|
+
expect(fedCtx.elements.map((e: { name: string }) => e.name).sort()).toEqual([
|
|
175
|
+
"order",
|
|
176
|
+
"orderPlaced",
|
|
177
|
+
"placeOrder",
|
|
178
|
+
]);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test("kontrakt ticku: rejestracja odroczona mikrotaskiem łapie się w oknie", async () => {
|
|
182
|
+
const m = mod("deferred", [page("/x")]);
|
|
183
|
+
beginFederatedRegistration(BINDING);
|
|
184
|
+
queueMicrotask(() => registerModule(m)); // jak module().build() przy cyklu
|
|
185
|
+
await new Promise((r) => setTimeout(r)); // tick loadera
|
|
186
|
+
endFederatedRegistration();
|
|
187
|
+
|
|
188
|
+
expect(m.name).toBe("ndt:deferred");
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test("bez ticku moduł wpada bez namespace (dokumentacja pułapki)", async () => {
|
|
192
|
+
const m = mod("tooEarly", [page("/x")]);
|
|
193
|
+
beginFederatedRegistration(BINDING);
|
|
194
|
+
queueMicrotask(() => registerModule(m));
|
|
195
|
+
endFederatedRegistration(); // za wcześnie!
|
|
196
|
+
await new Promise((r) => setTimeout(r));
|
|
197
|
+
|
|
198
|
+
expect(m.name).toBe("tooEarly");
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test("nakładające się okna rzucają", () => {
|
|
202
|
+
beginFederatedRegistration(BINDING);
|
|
203
|
+
expect(() =>
|
|
204
|
+
beginFederatedRegistration({ ...BINDING, slug: "inna" }),
|
|
205
|
+
).toThrow();
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
describe("appHref (standard routingu)", () => {
|
|
210
|
+
test("prefiksowanie + root", () => {
|
|
211
|
+
expect(appHref("/a/ndt", "/tasks")).toBe("/a/ndt/tasks");
|
|
212
|
+
expect(appHref("/a/ndt", "/")).toBe("/a/ndt");
|
|
213
|
+
expect(appHref("", "/tasks")).toBe("/tasks");
|
|
214
|
+
expect(appHref("", "/")).toBe("/");
|
|
215
|
+
});
|
|
216
|
+
});
|