@arcote.tech/platform 0.4.6 → 0.4.8
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 +4 -4
- package/src/arc.ts +20 -1
- package/src/index.ts +9 -1
- package/src/layout/page-router.tsx +13 -4
- package/src/layout/use-page-fragments.ts +20 -0
- package/src/module-loader.ts +155 -25
- package/src/platform-app.tsx +12 -6
- package/src/registry.ts +60 -2
- package/src/types.ts +30 -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.
|
|
4
|
+
"version": "0.4.8",
|
|
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",
|
|
@@ -18,11 +18,11 @@
|
|
|
18
18
|
"type-check": "tsc --noEmit"
|
|
19
19
|
},
|
|
20
20
|
"dependencies": {
|
|
21
|
-
"@arcote.tech/arc-ds": "^0.4.
|
|
22
|
-
"@arcote.tech/arc-react": "^0.4.
|
|
21
|
+
"@arcote.tech/arc-ds": "^0.4.8",
|
|
22
|
+
"@arcote.tech/arc-react": "^0.4.8"
|
|
23
23
|
},
|
|
24
24
|
"peerDependencies": {
|
|
25
|
-
"@arcote.tech/arc": "^0.4.
|
|
25
|
+
"@arcote.tech/arc": "^0.4.8",
|
|
26
26
|
"@lingui/core": "^5.0.0",
|
|
27
27
|
"@lingui/react": "^5.0.0",
|
|
28
28
|
"framer-motion": "^12.0.0",
|
package/src/arc.ts
CHANGED
|
@@ -8,6 +8,7 @@ import type {
|
|
|
8
8
|
BuiltModule,
|
|
9
9
|
ContextElementFragment,
|
|
10
10
|
ExtractPages,
|
|
11
|
+
ModuleAccessRule,
|
|
11
12
|
PageFragment,
|
|
12
13
|
PageOptions,
|
|
13
14
|
SlotFragment,
|
|
@@ -66,6 +67,8 @@ export function page<TPath extends string>(
|
|
|
66
67
|
label: options.label,
|
|
67
68
|
tooltip: options.tooltip,
|
|
68
69
|
children: options.children,
|
|
70
|
+
ordering: { order: options.order ?? 0, after: options.after, before: options.before },
|
|
71
|
+
nav: options.nav,
|
|
69
72
|
};
|
|
70
73
|
}
|
|
71
74
|
|
|
@@ -148,9 +151,22 @@ class ModuleBuilder<
|
|
|
148
151
|
private _pages: Record<string, string> | undefined;
|
|
149
152
|
private _ctx: { elements: readonly ArcContextElementAny[] } | undefined;
|
|
150
153
|
private _scope: string | undefined;
|
|
154
|
+
private _protectedBy: ModuleAccessRule[] = [];
|
|
151
155
|
|
|
152
156
|
constructor(readonly name: string) {}
|
|
153
157
|
|
|
158
|
+
/**
|
|
159
|
+
* Restrict module loading to users holding the given token.
|
|
160
|
+
* Optional check function for granular access control.
|
|
161
|
+
*/
|
|
162
|
+
protectedBy(
|
|
163
|
+
token: { name: string },
|
|
164
|
+
check?: (tokenInstance: any) => boolean | Promise<boolean>,
|
|
165
|
+
): this {
|
|
166
|
+
this._protectedBy.push({ token, check });
|
|
167
|
+
return this;
|
|
168
|
+
}
|
|
169
|
+
|
|
154
170
|
/**
|
|
155
171
|
* Register the module's ArcContext and scope name.
|
|
156
172
|
* Context element fragments are auto-injected in build().
|
|
@@ -225,12 +241,14 @@ class ModuleBuilder<
|
|
|
225
241
|
|
|
226
242
|
const mod: Record<string, unknown> = {
|
|
227
243
|
id: moduleId,
|
|
244
|
+
name: this.name,
|
|
228
245
|
fragments: allFragments,
|
|
229
246
|
publicFragments: this._public,
|
|
230
247
|
pages: Object.freeze(this._pages ?? {}),
|
|
231
248
|
};
|
|
232
249
|
if (this._ctx !== undefined) mod.context = this._ctx;
|
|
233
250
|
if (this._scope !== undefined) mod.scope = this._scope;
|
|
251
|
+
if (this._protectedBy.length > 0) mod.access = { rules: this._protectedBy };
|
|
234
252
|
if (domain) Object.assign(mod, domain);
|
|
235
253
|
|
|
236
254
|
registerModule(mod as unknown as ArcModule);
|
|
@@ -285,12 +303,13 @@ export function arc(
|
|
|
285
303
|
// Context registration: factory returned an ArcContext
|
|
286
304
|
if (isArcContext(result)) {
|
|
287
305
|
setContext(result);
|
|
288
|
-
return { id: moduleId, fragments: [], publicFragments: [] };
|
|
306
|
+
return { id: moduleId, name: moduleId, fragments: [], publicFragments: [] };
|
|
289
307
|
}
|
|
290
308
|
|
|
291
309
|
const publicFragments: readonly ArcFragment[] = Array.isArray(result) ? result : [];
|
|
292
310
|
const mod: ArcModule = {
|
|
293
311
|
id: moduleId,
|
|
312
|
+
name: moduleId,
|
|
294
313
|
fragments: allFragments,
|
|
295
314
|
publicFragments,
|
|
296
315
|
};
|
package/src/index.ts
CHANGED
|
@@ -7,9 +7,13 @@ export { arc } from "./arc";
|
|
|
7
7
|
export {
|
|
8
8
|
clearModules,
|
|
9
9
|
clearRegistry,
|
|
10
|
+
forceRegisterModule,
|
|
10
11
|
getAllFragments,
|
|
12
|
+
getAllModuleAccess,
|
|
11
13
|
getAllModules,
|
|
14
|
+
getAllRegisteredModules,
|
|
12
15
|
getContext,
|
|
16
|
+
getModuleAccess,
|
|
13
17
|
getContextElementFragments,
|
|
14
18
|
getDefaultLayout,
|
|
15
19
|
getModule,
|
|
@@ -60,6 +64,7 @@ export { PageRouter } from "./layout/page-router";
|
|
|
60
64
|
export { PageSubNavShell } from "./layout/page-sub-nav-shell";
|
|
61
65
|
export { SlotRenderer } from "./layout/slot-renderer";
|
|
62
66
|
export type { SlotRendererProps } from "./layout/slot-renderer";
|
|
67
|
+
export { usePageFragments } from "./layout/use-page-fragments";
|
|
63
68
|
export { useSlotFragments } from "./layout/use-slot-fragments";
|
|
64
69
|
|
|
65
70
|
// Router
|
|
@@ -86,7 +91,7 @@ export type { LocaleProviderProps } from "./locale";
|
|
|
86
91
|
export { useTitle } from "./hooks/use-title";
|
|
87
92
|
|
|
88
93
|
// Platform
|
|
89
|
-
export { loadModules, reloadModules, useModuleLoader } from "./module-loader";
|
|
94
|
+
export { loadModules, reloadModules, syncModules, useModuleLoader } from "./module-loader";
|
|
90
95
|
export type { ModuleLoaderState, ModuleManifest } from "./module-loader";
|
|
91
96
|
export { PlatformApp } from "./platform-app";
|
|
92
97
|
export type { PlatformAppProps } from "./platform-app";
|
|
@@ -106,6 +111,9 @@ export type {
|
|
|
106
111
|
ArcModule,
|
|
107
112
|
BuiltModule,
|
|
108
113
|
ContextElementFragment,
|
|
114
|
+
ModuleAccess,
|
|
115
|
+
ModuleAccessRule,
|
|
116
|
+
ModuleDescriptor,
|
|
109
117
|
ExtractPages,
|
|
110
118
|
FragmentOrdering,
|
|
111
119
|
PageFragment,
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { AnimatePresence, motion } from "framer-motion";
|
|
2
2
|
import { Suspense, useEffect } from "react";
|
|
3
|
-
import { getDefaultLayout
|
|
3
|
+
import { getDefaultLayout } from "../registry";
|
|
4
|
+
import { usePageFragments } from "./use-page-fragments";
|
|
4
5
|
import { useArcNavigate, useArcRoute, matchRoutePath, hasRouteParams } from "../router";
|
|
5
6
|
import { useArcRouter } from "@arcote.tech/arc-ds";
|
|
6
7
|
import type { PageFragment } from "../types";
|
|
@@ -11,8 +12,7 @@ type MatchResult = {
|
|
|
11
12
|
params: Record<string, string>;
|
|
12
13
|
};
|
|
13
14
|
|
|
14
|
-
function matchPage(path: string): MatchResult | undefined {
|
|
15
|
-
const pages = getPageFragments();
|
|
15
|
+
function matchPage(path: string, pages: PageFragment[]): MatchResult | undefined {
|
|
16
16
|
|
|
17
17
|
// 1. Exact match (non-parent pages only)
|
|
18
18
|
const exact = pages.find((p) => p.path === path && !p.children?.length);
|
|
@@ -64,7 +64,8 @@ export function PageRouter() {
|
|
|
64
64
|
const route = useArcRoute();
|
|
65
65
|
const navigate = useArcNavigate();
|
|
66
66
|
const router = useArcRouter();
|
|
67
|
-
const
|
|
67
|
+
const pages = usePageFragments();
|
|
68
|
+
const match = matchPage(route, pages);
|
|
68
69
|
const DefaultLayout = getDefaultLayout();
|
|
69
70
|
|
|
70
71
|
// Sync matched params to router context
|
|
@@ -80,6 +81,14 @@ export function PageRouter() {
|
|
|
80
81
|
}
|
|
81
82
|
}, [route, match?.params]);
|
|
82
83
|
|
|
84
|
+
// Redirect to first available page when current route is no longer registered
|
|
85
|
+
useEffect(() => {
|
|
86
|
+
if (!match && pages.length > 0 && route !== "/") {
|
|
87
|
+
const firstNav = pages.find((p) => p.icon && p.label && !p.path.includes("/:"));
|
|
88
|
+
navigate(firstNav?.path ?? "/");
|
|
89
|
+
}
|
|
90
|
+
}, [match, pages, route, navigate]);
|
|
91
|
+
|
|
83
92
|
if (!match) {
|
|
84
93
|
if (DefaultLayout) return <DefaultLayout>{null}</DefaultLayout>;
|
|
85
94
|
return null;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { useRef, useSyncExternalStore } from "react";
|
|
2
|
+
import { getPageFragments, subscribe } from "../registry";
|
|
3
|
+
import type { PageFragment } from "../types";
|
|
4
|
+
|
|
5
|
+
export function usePageFragments(): PageFragment[] {
|
|
6
|
+
const cacheRef = useRef<PageFragment[]>([]);
|
|
7
|
+
|
|
8
|
+
const getSnapshot = () => {
|
|
9
|
+
const next = getPageFragments();
|
|
10
|
+
const prev = cacheRef.current;
|
|
11
|
+
|
|
12
|
+
const changed =
|
|
13
|
+
next.length !== prev.length || next.some((f, i) => f.id !== prev[i]?.id);
|
|
14
|
+
|
|
15
|
+
if (changed) cacheRef.current = next;
|
|
16
|
+
return cacheRef.current;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
return useSyncExternalStore(subscribe, getSnapshot);
|
|
20
|
+
}
|
package/src/module-loader.ts
CHANGED
|
@@ -1,27 +1,75 @@
|
|
|
1
|
-
import { clearModules, getContext } from "./registry";
|
|
1
|
+
import { clearModules, getAllRegisteredModules, getContext, setActiveModules } from "./registry";
|
|
2
|
+
import type { ModuleDescriptor } from "./types";
|
|
2
3
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
3
4
|
|
|
4
5
|
export interface ModuleManifest {
|
|
5
|
-
modules:
|
|
6
|
+
modules: ModuleDescriptor[];
|
|
6
7
|
buildTime: string;
|
|
7
8
|
}
|
|
8
9
|
|
|
9
10
|
export type ModuleLoaderState = "loading" | "ready" | "error";
|
|
10
11
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
12
|
+
function moduleUrl(baseUrl: string, mod: ModuleDescriptor, bust?: string): string {
|
|
13
|
+
const base = mod.url ?? `${baseUrl}/modules/${mod.file}`;
|
|
14
|
+
return bust ? `${base}${base.includes("?") ? "&" : "?"}t=${bust}` : base;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Read all persisted arc tokens from localStorage */
|
|
18
|
+
function getAllPersistedTokens(): Record<string, string> {
|
|
19
|
+
if (typeof localStorage === "undefined") return {};
|
|
20
|
+
const tokens: Record<string, string> = {};
|
|
21
|
+
for (let i = 0; i < localStorage.length; i++) {
|
|
22
|
+
const key = localStorage.key(i);
|
|
23
|
+
if (key?.startsWith("arc:token:")) {
|
|
24
|
+
const raw = localStorage.getItem(key);
|
|
25
|
+
if (raw) tokens[key.slice(10)] = raw;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return tokens;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Build X-Arc-Tokens header value from token map */
|
|
32
|
+
function buildTokensHeader(tokens: Record<string, string>): string {
|
|
33
|
+
return Object.entries(tokens)
|
|
34
|
+
.map(([scope, jwt]) => `${scope}:${jwt}`)
|
|
35
|
+
.join(",");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function buildHeaders(tokens?: Record<string, string>): HeadersInit {
|
|
39
|
+
const resolved = tokens ?? getAllPersistedTokens();
|
|
40
|
+
const headers: HeadersInit = {};
|
|
41
|
+
const tokensHeader = buildTokensHeader(resolved);
|
|
42
|
+
if (tokensHeader) headers["X-Arc-Tokens"] = tokensHeader;
|
|
43
|
+
// Also send first token as Authorization for backwards compatibility
|
|
44
|
+
const firstToken = Object.values(resolved)[0];
|
|
45
|
+
if (firstToken) headers["Authorization"] = `Bearer ${firstToken}`;
|
|
46
|
+
return headers;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Fetch manifest from server */
|
|
50
|
+
async function fetchManifest(
|
|
51
|
+
baseUrl: string,
|
|
52
|
+
tokens?: Record<string, string>,
|
|
53
|
+
): Promise<ModuleManifest> {
|
|
54
|
+
const headers = buildHeaders(tokens);
|
|
55
|
+
const res = await fetch(`${baseUrl}/api/modules`, { headers });
|
|
16
56
|
if (!res.ok)
|
|
17
57
|
throw new Error(`Failed to fetch module manifest: ${res.status}`);
|
|
58
|
+
return res.json();
|
|
59
|
+
}
|
|
18
60
|
|
|
19
|
-
|
|
61
|
+
/**
|
|
62
|
+
* Load all modules from manifest. Each module auto-registers via module().build().
|
|
63
|
+
*/
|
|
64
|
+
export async function loadModules(
|
|
65
|
+
baseUrl: string,
|
|
66
|
+
tokens?: Record<string, string>,
|
|
67
|
+
): Promise<ModuleManifest> {
|
|
68
|
+
const manifest = await fetchManifest(baseUrl, tokens);
|
|
20
69
|
|
|
21
|
-
// Import all modules — each calls arc() which auto-registers
|
|
22
70
|
await Promise.all(
|
|
23
71
|
manifest.modules.map(
|
|
24
|
-
(mod) => import(/* @vite-ignore */
|
|
72
|
+
(mod) => import(/* @vite-ignore */ moduleUrl(baseUrl, mod)),
|
|
25
73
|
),
|
|
26
74
|
);
|
|
27
75
|
|
|
@@ -29,21 +77,52 @@ export async function loadModules(baseUrl: string): Promise<ModuleManifest> {
|
|
|
29
77
|
}
|
|
30
78
|
|
|
31
79
|
/**
|
|
32
|
-
*
|
|
80
|
+
* Sync modules with server — import any NEW modules, then set active filter.
|
|
81
|
+
* Modules are never unregistered (ES module cache prevents re-execution).
|
|
82
|
+
* Instead, visibility is controlled via an active set.
|
|
33
83
|
*/
|
|
34
|
-
export async function
|
|
35
|
-
|
|
84
|
+
export async function syncModules(
|
|
85
|
+
baseUrl: string,
|
|
86
|
+
tokens?: Record<string, string>,
|
|
87
|
+
): Promise<ModuleManifest> {
|
|
88
|
+
const manifest = await fetchManifest(baseUrl, tokens);
|
|
36
89
|
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
|
|
90
|
+
const manifestNames = new Set(manifest.modules.map((m) => m.name));
|
|
91
|
+
// Check against ALL registered modules (not just active ones)
|
|
92
|
+
const registeredNames = new Set(getAllRegisteredModules().map((m) => m.name));
|
|
93
|
+
|
|
94
|
+
// Import only truly new modules (never imported before)
|
|
95
|
+
const newModules = manifest.modules.filter((m) => !registeredNames.has(m.name));
|
|
96
|
+
|
|
97
|
+
if (newModules.length > 0) {
|
|
98
|
+
await Promise.all(
|
|
99
|
+
newModules.map((mod) => import(/* @vite-ignore */ moduleUrl(baseUrl, mod))),
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Set active filter — only modules in the manifest are visible
|
|
104
|
+
setActiveModules(manifestNames);
|
|
105
|
+
|
|
106
|
+
return manifest;
|
|
107
|
+
}
|
|
40
108
|
|
|
41
|
-
|
|
42
|
-
|
|
109
|
+
/**
|
|
110
|
+
* Full reload — clear all modules, re-import with cache bust.
|
|
111
|
+
* Used for dev-mode file changes (code actually changed on disk).
|
|
112
|
+
*/
|
|
113
|
+
export async function reloadModules(
|
|
114
|
+
baseUrl: string,
|
|
115
|
+
tokens?: Record<string, string>,
|
|
116
|
+
): Promise<ModuleManifest> {
|
|
117
|
+
const manifest = await fetchManifest(baseUrl, tokens);
|
|
118
|
+
const bust = String(Date.now());
|
|
119
|
+
|
|
120
|
+
// Clear all modules — fresh code will re-register them
|
|
121
|
+
clearModules();
|
|
43
122
|
|
|
44
123
|
await Promise.all(
|
|
45
124
|
manifest.modules.map(
|
|
46
|
-
(mod) => import(/* @vite-ignore */
|
|
125
|
+
(mod) => import(/* @vite-ignore */ moduleUrl(baseUrl, mod, bust)),
|
|
47
126
|
),
|
|
48
127
|
);
|
|
49
128
|
|
|
@@ -52,18 +131,21 @@ export async function reloadModules(baseUrl: string): Promise<ModuleManifest> {
|
|
|
52
131
|
|
|
53
132
|
/**
|
|
54
133
|
* Hook: loads modules on mount, provides state + context.
|
|
134
|
+
* Syncs modules when tokens change (login/logout/workspace switch).
|
|
55
135
|
*/
|
|
56
136
|
export function useModuleLoader(
|
|
57
137
|
baseUrl: string,
|
|
58
|
-
options: { skip?: boolean } = {},
|
|
138
|
+
options: { skip?: boolean; token?: string | null } = {},
|
|
59
139
|
) {
|
|
60
140
|
const [state, setState] = useState<ModuleLoaderState>(
|
|
61
141
|
options.skip ? "ready" : "loading",
|
|
62
142
|
);
|
|
63
143
|
const [error, setError] = useState<Error | null>(null);
|
|
64
144
|
const loaded = useRef(false);
|
|
145
|
+
const [tokenVersion, setTokenVersion] = useState(0);
|
|
65
146
|
|
|
66
|
-
|
|
147
|
+
// Full reload — for dev-mode file changes (re-import all JS)
|
|
148
|
+
const fullReload = useCallback(async () => {
|
|
67
149
|
try {
|
|
68
150
|
await reloadModules(baseUrl);
|
|
69
151
|
setState("ready");
|
|
@@ -73,6 +155,18 @@ export function useModuleLoader(
|
|
|
73
155
|
}
|
|
74
156
|
}, [baseUrl]);
|
|
75
157
|
|
|
158
|
+
// Sync — for token changes (only fetch manifest, import new, remove stale)
|
|
159
|
+
const sync = useCallback(async () => {
|
|
160
|
+
try {
|
|
161
|
+
await syncModules(baseUrl);
|
|
162
|
+
setState("ready");
|
|
163
|
+
} catch (e) {
|
|
164
|
+
setError(e instanceof Error ? e : new Error(String(e)));
|
|
165
|
+
setState("error");
|
|
166
|
+
}
|
|
167
|
+
}, [baseUrl]);
|
|
168
|
+
|
|
169
|
+
// Initial load
|
|
76
170
|
useEffect(() => {
|
|
77
171
|
if (options.skip || loaded.current) return;
|
|
78
172
|
loaded.current = true;
|
|
@@ -84,19 +178,55 @@ export function useModuleLoader(
|
|
|
84
178
|
setState("error");
|
|
85
179
|
});
|
|
86
180
|
|
|
87
|
-
// Listen for SSE reload events from CLI
|
|
181
|
+
// Listen for SSE reload events from CLI (code changed on disk → full reload)
|
|
88
182
|
const evtSource = new EventSource(`${baseUrl}/api/reload-stream`);
|
|
89
183
|
evtSource.onmessage = (event) => {
|
|
90
184
|
if (event.data === "connected") return;
|
|
91
185
|
console.log("[arc] Modules changed, reloading...");
|
|
92
|
-
|
|
186
|
+
fullReload();
|
|
93
187
|
};
|
|
94
188
|
evtSource.onerror = () => {
|
|
95
189
|
// SSE disconnected — will auto-reconnect
|
|
96
190
|
};
|
|
97
191
|
|
|
98
192
|
return () => evtSource.close();
|
|
99
|
-
}, [baseUrl,
|
|
193
|
+
}, [baseUrl, fullReload, options.skip]);
|
|
194
|
+
|
|
195
|
+
// Listen for token changes → sync (not full reload)
|
|
196
|
+
useEffect(() => {
|
|
197
|
+
if (typeof window === "undefined") return;
|
|
198
|
+
|
|
199
|
+
const onTokenChange = () => setTokenVersion((v) => v + 1);
|
|
200
|
+
window.addEventListener("arc:token-change", onTokenChange);
|
|
201
|
+
|
|
202
|
+
const onStorage = (e: StorageEvent) => {
|
|
203
|
+
if (e.key?.startsWith("arc:token:")) {
|
|
204
|
+
setTokenVersion((v) => v + 1);
|
|
205
|
+
}
|
|
206
|
+
};
|
|
207
|
+
window.addEventListener("storage", onStorage);
|
|
208
|
+
|
|
209
|
+
return () => {
|
|
210
|
+
window.removeEventListener("arc:token-change", onTokenChange);
|
|
211
|
+
window.removeEventListener("storage", onStorage);
|
|
212
|
+
};
|
|
213
|
+
}, []);
|
|
214
|
+
|
|
215
|
+
// Token changed → sync modules (import new, remove stale, don't re-import existing)
|
|
216
|
+
useEffect(() => {
|
|
217
|
+
if (!loaded.current) return;
|
|
218
|
+
if (tokenVersion === 0) return;
|
|
219
|
+
sync();
|
|
220
|
+
}, [tokenVersion, sync]);
|
|
221
|
+
|
|
222
|
+
// Legacy: sync when explicit token prop changes
|
|
223
|
+
const prevToken = useRef(options.token);
|
|
224
|
+
useEffect(() => {
|
|
225
|
+
if (!loaded.current) return;
|
|
226
|
+
if (prevToken.current === options.token) return;
|
|
227
|
+
prevToken.current = options.token;
|
|
228
|
+
sync();
|
|
229
|
+
}, [options.token, sync]);
|
|
100
230
|
|
|
101
|
-
return { state, error, context: getContext(), reload };
|
|
231
|
+
return { state, error, context: getContext(), reload: fullReload };
|
|
102
232
|
}
|
package/src/platform-app.tsx
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { ArcContextAny } from "@arcote.tech/arc";
|
|
2
2
|
import type { DSVariantOverrides } from "@arcote.tech/arc-ds";
|
|
3
|
-
import { DesignSystemProvider } from "@arcote.tech/arc-ds";
|
|
3
|
+
import { DesignSystemProvider, TooltipProvider } from "@arcote.tech/arc-ds";
|
|
4
4
|
import { reactModel } from "@arcote.tech/arc-react";
|
|
5
5
|
import { Layout } from "@arcote.tech/arc-ds";
|
|
6
6
|
import { ArcLayoutProvider } from "./layout/layout-provider";
|
|
@@ -13,13 +13,10 @@ import {
|
|
|
13
13
|
setDefaultLayout,
|
|
14
14
|
} from "./registry";
|
|
15
15
|
import { ThemeProvider } from "./theme";
|
|
16
|
-
import {
|
|
17
|
-
import { useEffect, useMemo, useState } from "react";
|
|
16
|
+
import { useEffect, useMemo, useRef, useState } from "react";
|
|
18
17
|
import { useModuleLoader } from "./module-loader";
|
|
19
18
|
import { PlatformModelProvider } from "./platform-context";
|
|
20
19
|
|
|
21
|
-
const TooltipProvider = RadixTooltip.Provider;
|
|
22
|
-
|
|
23
20
|
export interface PlatformAppProps {
|
|
24
21
|
apiUrl?: string;
|
|
25
22
|
wsUrl?: string;
|
|
@@ -179,7 +176,16 @@ export function PlatformApp({
|
|
|
179
176
|
// If context is pre-loaded (modules imported statically in main.tsx),
|
|
180
177
|
// skip dynamic module loading entirely.
|
|
181
178
|
const loader = useModuleLoader(apiUrl, { skip: !!preloadedContext });
|
|
182
|
-
|
|
179
|
+
|
|
180
|
+
// Stabilize context reference — once set, don't change it.
|
|
181
|
+
// New context elements from synced modules merge into the global registry
|
|
182
|
+
// but we must NOT recreate the model (would remount the entire tree).
|
|
183
|
+
const contextRef = useRef<ArcContextAny | null>(preloadedContext ?? null);
|
|
184
|
+
if (!contextRef.current) {
|
|
185
|
+
const resolved = loader.context ?? getContext();
|
|
186
|
+
if (resolved) contextRef.current = resolved;
|
|
187
|
+
}
|
|
188
|
+
const context = contextRef.current;
|
|
183
189
|
|
|
184
190
|
const resolvedWsUrl = wsUrl ?? `${location.protocol}//${location.host}`;
|
|
185
191
|
|
package/src/registry.ts
CHANGED
|
@@ -3,6 +3,7 @@ import type {
|
|
|
3
3
|
ArcLayoutComponent,
|
|
4
4
|
ArcModule,
|
|
5
5
|
ContextElementFragment,
|
|
6
|
+
ModuleAccess,
|
|
6
7
|
PageFragment,
|
|
7
8
|
SlotFragment,
|
|
8
9
|
SlotId,
|
|
@@ -14,6 +15,8 @@ import type {
|
|
|
14
15
|
// ---------------------------------------------------------------------------
|
|
15
16
|
|
|
16
17
|
const modules: Map<string, ArcModule> = new Map();
|
|
18
|
+
/** Set of module names currently active (visible). Managed by syncModules. */
|
|
19
|
+
let activeModuleNames: Set<string> | null = null; // null = all active (no filtering)
|
|
17
20
|
const listeners: Set<() => void> = new Set();
|
|
18
21
|
|
|
19
22
|
function notify(): void {
|
|
@@ -25,8 +28,32 @@ export function subscribe(listener: () => void): () => void {
|
|
|
25
28
|
return () => listeners.delete(listener);
|
|
26
29
|
}
|
|
27
30
|
|
|
28
|
-
/**
|
|
31
|
+
/**
|
|
32
|
+
* Register module. Skips if a module with the same name already exists
|
|
33
|
+
* (prevents duplicates from bundled dependencies importing the same module).
|
|
34
|
+
* Use `forceRegisterModule` for dev reloads where code has actually changed.
|
|
35
|
+
*/
|
|
29
36
|
export function registerModule(mod: ArcModule): void {
|
|
37
|
+
for (const existing of modules.values()) {
|
|
38
|
+
if (existing.name === mod.name) {
|
|
39
|
+
console.log(`[arc:registry] SKIP ${mod.name} (already registered as ${existing.id})`);
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
console.log(`[arc:registry] REGISTER ${mod.name} as ${mod.id} (fragments: ${mod.fragments.length})`);
|
|
44
|
+
modules.set(mod.id, mod);
|
|
45
|
+
notify();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Force-register module, replacing any existing module with the same name.
|
|
49
|
+
* Used for dev reloads where code has changed on disk. */
|
|
50
|
+
export function forceRegisterModule(mod: ArcModule): void {
|
|
51
|
+
for (const [id, existing] of modules) {
|
|
52
|
+
if (existing.name === mod.name && id !== mod.id) {
|
|
53
|
+
modules.delete(id);
|
|
54
|
+
break;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
30
57
|
modules.set(mod.id, mod);
|
|
31
58
|
notify();
|
|
32
59
|
}
|
|
@@ -41,9 +68,22 @@ export function getModule(moduleId: string): ArcModule | undefined {
|
|
|
41
68
|
}
|
|
42
69
|
|
|
43
70
|
export function getAllModules(): ArcModule[] {
|
|
71
|
+
if (!activeModuleNames) return [...modules.values()];
|
|
72
|
+
return [...modules.values()].filter((m) => activeModuleNames!.has(m.name));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Get ALL registered modules (ignoring active filter). */
|
|
76
|
+
export function getAllRegisteredModules(): ArcModule[] {
|
|
44
77
|
return [...modules.values()];
|
|
45
78
|
}
|
|
46
79
|
|
|
80
|
+
/** Set which modules are currently active (visible in queries).
|
|
81
|
+
* Pass null to show all. Triggers re-render of slot/page subscribers. */
|
|
82
|
+
export function setActiveModules(names: Set<string> | null): void {
|
|
83
|
+
activeModuleNames = names;
|
|
84
|
+
notify();
|
|
85
|
+
}
|
|
86
|
+
|
|
47
87
|
// ---------------------------------------------------------------------------
|
|
48
88
|
// Context store — dynamiczny context rejestrowany przez pakiety
|
|
49
89
|
// ---------------------------------------------------------------------------
|
|
@@ -166,7 +206,8 @@ export function getSlotFragments(slotId: SlotId | string): SlotFragment[] {
|
|
|
166
206
|
}
|
|
167
207
|
|
|
168
208
|
export function getPageFragments(): PageFragment[] {
|
|
169
|
-
|
|
209
|
+
const fragments = getAllFragments().filter((f): f is PageFragment => f.is("page"));
|
|
210
|
+
return sortByOrdering(fragments);
|
|
170
211
|
}
|
|
171
212
|
|
|
172
213
|
export function getContextElementFragments(): ContextElementFragment[] {
|
|
@@ -186,6 +227,23 @@ export function getWrapperFragments(): WrapperFragment[] {
|
|
|
186
227
|
return sortByOrdering(fragments);
|
|
187
228
|
}
|
|
188
229
|
|
|
230
|
+
/** Get access rules for a module by name. */
|
|
231
|
+
export function getModuleAccess(name: string): ModuleAccess | undefined {
|
|
232
|
+
for (const mod of modules.values()) {
|
|
233
|
+
if (mod.name === name && mod.access) return mod.access;
|
|
234
|
+
}
|
|
235
|
+
return undefined;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Get access rules for all protected modules. */
|
|
239
|
+
export function getAllModuleAccess(): Map<string, ModuleAccess> {
|
|
240
|
+
const result = new Map<string, ModuleAccess>();
|
|
241
|
+
for (const mod of modules.values()) {
|
|
242
|
+
if (mod.access) result.set(mod.name, mod.access);
|
|
243
|
+
}
|
|
244
|
+
return result;
|
|
245
|
+
}
|
|
246
|
+
|
|
189
247
|
/** Clear all modules (but keep context). Used for reload. */
|
|
190
248
|
export function clearModules(): void {
|
|
191
249
|
modules.clear();
|
package/src/types.ts
CHANGED
|
@@ -57,6 +57,9 @@ export interface PageFragment<
|
|
|
57
57
|
readonly label?: string | ReactNode;
|
|
58
58
|
readonly tooltip?: string | ReactNode;
|
|
59
59
|
readonly children?: readonly PageFragment[];
|
|
60
|
+
readonly ordering: FragmentOrdering;
|
|
61
|
+
/** Show in main navigation. Defaults to auto-detect (has icon + label). */
|
|
62
|
+
readonly nav?: boolean;
|
|
60
63
|
}
|
|
61
64
|
|
|
62
65
|
export type PageShellProps = {
|
|
@@ -83,14 +86,35 @@ export type ArcFragment =
|
|
|
83
86
|
|
|
84
87
|
export type PublicArcFragment = ArcFragment;
|
|
85
88
|
|
|
89
|
+
// ---------------------------------------------------------------------------
|
|
90
|
+
// Module access control
|
|
91
|
+
// ---------------------------------------------------------------------------
|
|
92
|
+
|
|
93
|
+
export interface ModuleAccessRule {
|
|
94
|
+
readonly token: { name: string; [key: string]: any };
|
|
95
|
+
readonly check?: (tokenInstance: any) => boolean | Promise<boolean>;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export interface ModuleAccess {
|
|
99
|
+
readonly rules: readonly ModuleAccessRule[];
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export interface ModuleDescriptor {
|
|
103
|
+
readonly file: string;
|
|
104
|
+
readonly name: string;
|
|
105
|
+
readonly url?: string;
|
|
106
|
+
}
|
|
107
|
+
|
|
86
108
|
// ---------------------------------------------------------------------------
|
|
87
109
|
// Moduł
|
|
88
110
|
// ---------------------------------------------------------------------------
|
|
89
111
|
|
|
90
112
|
export interface ArcModule {
|
|
91
113
|
readonly id: string;
|
|
114
|
+
readonly name: string;
|
|
92
115
|
readonly fragments: readonly ArcFragment[];
|
|
93
116
|
readonly publicFragments: readonly ArcFragment[];
|
|
117
|
+
readonly access?: ModuleAccess;
|
|
94
118
|
}
|
|
95
119
|
|
|
96
120
|
// ---------------------------------------------------------------------------
|
|
@@ -137,6 +161,12 @@ export interface PageOptions {
|
|
|
137
161
|
label?: string | ReactNode;
|
|
138
162
|
tooltip?: string | ReactNode;
|
|
139
163
|
children?: PageFragment[];
|
|
164
|
+
/** Navigation ordering. Lower = earlier. Default 0. */
|
|
165
|
+
order?: number;
|
|
166
|
+
after?: string;
|
|
167
|
+
before?: string;
|
|
168
|
+
/** Explicitly include/exclude from main navigation. Default: auto (has icon + label). */
|
|
169
|
+
nav?: boolean;
|
|
140
170
|
}
|
|
141
171
|
|
|
142
172
|
export interface WrapperOptions extends FragmentOrdering {
|