@arcote.tech/platform 0.7.31 → 0.8.0

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@arcote.tech/platform",
3
3
  "type": "module",
4
- "version": "0.7.31",
4
+ "version": "0.8.0",
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.7.31",
18
- "@arcote.tech/arc-react": "^0.7.31"
17
+ "@arcote.tech/arc-ds": "^0.8.0",
18
+ "@arcote.tech/arc-react": "^0.8.0"
19
19
  },
20
20
  "peerDependencies": {
21
- "@arcote.tech/arc": "^0.7.31",
22
- "@arcote.tech/arc-otel": "^0.7.31",
21
+ "@arcote.tech/arc": "^0.8.0",
22
+ "@arcote.tech/arc-otel": "^0.8.0",
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
- const count = redirectCounts.current.get(route) ?? 0;
129
- if (count >= MAX_REDIRECTS_PER_ROUTE) {
130
- loopedRoutes.current.add(route);
131
- console.error(
132
- `[arc] PageRouter: route "${route}" has no registered page and the ` +
133
- `fallback redirect is looping an app guard keeps sending the user ` +
134
- `back here. Stopping on a blank screen. Likely cause: the page for ` +
135
- `this route lives in a token-gated chunk not loaded for the current ` +
136
- `token (check protectedBy / module chunk grouping).`,
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
- return;
139
- }
140
- redirectCounts.current.set(route, count + 1);
141
- const firstNav = pages.find((p) => p.icon && p.label && !p.path.includes("/:"));
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
- <Component />
67
+ {rendered}
59
68
  </Suspense>
60
69
  </div>
61
70
  );
@@ -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[key.slice(10)] = raw;
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
- setTokenVersion((v) => v + 1);
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
 
@@ -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
- return usePlatformModel().createScope(name) as unknown as ScopeAPI<C>;
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 segmentowy
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 === pagePath) return true;
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
- return [...modules.values()].filter((m) => activeModuleNames!.has(m.name));
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 => f.is("wrapper"),
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
+ });