@arcote.tech/platform 0.4.7 → 0.4.9

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.4.7",
4
+ "version": "0.4.9",
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.7",
22
- "@arcote.tech/arc-react": "^0.4.7"
21
+ "@arcote.tech/arc-ds": "^0.4.9",
22
+ "@arcote.tech/arc-react": "^0.4.9"
23
23
  },
24
24
  "peerDependencies": {
25
- "@arcote.tech/arc": "^0.4.7",
25
+ "@arcote.tech/arc": "^0.4.9",
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
@@ -67,6 +67,8 @@ export function page<TPath extends string>(
67
67
  label: options.label,
68
68
  tooltip: options.tooltip,
69
69
  children: options.children,
70
+ ordering: { order: options.order ?? 0, after: options.after, before: options.before },
71
+ nav: options.nav,
70
72
  };
71
73
  }
72
74
 
package/src/index.ts CHANGED
@@ -7,9 +7,11 @@ export { arc } from "./arc";
7
7
  export {
8
8
  clearModules,
9
9
  clearRegistry,
10
+ forceRegisterModule,
10
11
  getAllFragments,
11
12
  getAllModuleAccess,
12
13
  getAllModules,
14
+ getAllRegisteredModules,
13
15
  getContext,
14
16
  getModuleAccess,
15
17
  getContextElementFragments,
@@ -62,6 +64,7 @@ export { PageRouter } from "./layout/page-router";
62
64
  export { PageSubNavShell } from "./layout/page-sub-nav-shell";
63
65
  export { SlotRenderer } from "./layout/slot-renderer";
64
66
  export type { SlotRendererProps } from "./layout/slot-renderer";
67
+ export { usePageFragments } from "./layout/use-page-fragments";
65
68
  export { useSlotFragments } from "./layout/use-slot-fragments";
66
69
 
67
70
  // Router
@@ -88,7 +91,7 @@ export type { LocaleProviderProps } from "./locale";
88
91
  export { useTitle } from "./hooks/use-title";
89
92
 
90
93
  // Platform
91
- export { loadModules, reloadModules, useModuleLoader } from "./module-loader";
94
+ export { loadModules, reloadModules, syncModules, useModuleLoader } from "./module-loader";
92
95
  export type { ModuleLoaderState, ModuleManifest } from "./module-loader";
93
96
  export { PlatformApp } from "./platform-app";
94
97
  export type { PlatformAppProps } from "./platform-app";
@@ -1,6 +1,7 @@
1
1
  import { AnimatePresence, motion } from "framer-motion";
2
2
  import { Suspense, useEffect } from "react";
3
- import { getDefaultLayout, getPageFragments } from "../registry";
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 match = matchPage(route);
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
+ }
@@ -1,4 +1,4 @@
1
- import { clearModules, getContext } from "./registry";
1
+ import { clearModules, getAllRegisteredModules, getContext, setActiveModules } from "./registry";
2
2
  import type { ModuleDescriptor } from "./types";
3
3
  import { useCallback, useEffect, useRef, useState } from "react";
4
4
 
@@ -14,34 +14,58 @@ function moduleUrl(baseUrl: string, mod: ModuleDescriptor, bust?: string): strin
14
14
  return bust ? `${base}${base.includes("?") ? "&" : "?"}t=${bust}` : base;
15
15
  }
16
16
 
17
- /** Read first available arc token from localStorage */
18
- function getPersistedToken(): string | null {
19
- if (typeof localStorage === "undefined") return null;
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> = {};
20
21
  for (let i = 0; i < localStorage.length; i++) {
21
22
  const key = localStorage.key(i);
22
23
  if (key?.startsWith("arc:token:")) {
23
- return localStorage.getItem(key);
24
+ const raw = localStorage.getItem(key);
25
+ if (raw) tokens[key.slice(10)] = raw;
24
26
  }
25
27
  }
26
- return null;
28
+ return tokens;
27
29
  }
28
30
 
29
- /**
30
- * Load all modules from manifest. Each module auto-registers via module().build().
31
- */
32
- export async function loadModules(
33
- baseUrl: string,
34
- token?: string | null,
35
- ): Promise<ModuleManifest> {
36
- const resolvedToken = token ?? getPersistedToken();
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();
37
40
  const headers: HeadersInit = {};
38
- if (resolvedToken) headers["Authorization"] = `Bearer ${resolvedToken}`;
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
+ }
39
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);
40
55
  const res = await fetch(`${baseUrl}/api/modules`, { headers });
41
56
  if (!res.ok)
42
57
  throw new Error(`Failed to fetch module manifest: ${res.status}`);
58
+ return res.json();
59
+ }
43
60
 
44
- const manifest: ModuleManifest = await res.json();
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);
45
69
 
46
70
  await Promise.all(
47
71
  manifest.modules.map(
@@ -53,25 +77,49 @@ export async function loadModules(
53
77
  }
54
78
 
55
79
  /**
56
- * Reload modules — clear registry, re-import with cache bust.
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.
57
83
  */
58
- export async function reloadModules(
84
+ export async function syncModules(
59
85
  baseUrl: string,
60
- token?: string | null,
86
+ tokens?: Record<string, string>,
61
87
  ): Promise<ModuleManifest> {
62
- clearModules();
88
+ const manifest = await fetchManifest(baseUrl, tokens);
63
89
 
64
- const resolvedToken = token ?? getPersistedToken();
65
- const headers: HeadersInit = {};
66
- if (resolvedToken) headers["Authorization"] = `Bearer ${resolvedToken}`;
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));
67
93
 
68
- const res = await fetch(`${baseUrl}/api/modules`, { headers });
69
- if (!res.ok)
70
- throw new Error(`Failed to fetch module manifest: ${res.status}`);
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
+ }
71
108
 
72
- const manifest: ModuleManifest = await res.json();
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);
73
118
  const bust = String(Date.now());
74
119
 
120
+ // Clear all modules — fresh code will re-register them
121
+ clearModules();
122
+
75
123
  await Promise.all(
76
124
  manifest.modules.map(
77
125
  (mod) => import(/* @vite-ignore */ moduleUrl(baseUrl, mod, bust)),
@@ -83,7 +131,7 @@ export async function reloadModules(
83
131
 
84
132
  /**
85
133
  * Hook: loads modules on mount, provides state + context.
86
- * Re-fetches manifest when token changes (login/logout).
134
+ * Syncs modules when tokens change (login/logout/workspace switch).
87
135
  */
88
136
  export function useModuleLoader(
89
137
  baseUrl: string,
@@ -94,51 +142,91 @@ export function useModuleLoader(
94
142
  );
95
143
  const [error, setError] = useState<Error | null>(null);
96
144
  const loaded = useRef(false);
97
- const prevToken = useRef(options.token);
145
+ const [tokenVersion, setTokenVersion] = useState(0);
146
+
147
+ // Full reload — for dev-mode file changes (re-import all JS)
148
+ const fullReload = useCallback(async () => {
149
+ try {
150
+ await reloadModules(baseUrl);
151
+ setState("ready");
152
+ } catch (e) {
153
+ setError(e instanceof Error ? e : new Error(String(e)));
154
+ setState("error");
155
+ }
156
+ }, [baseUrl]);
98
157
 
99
- const reload = useCallback(async () => {
158
+ // Sync for token changes (only fetch manifest, import new, remove stale)
159
+ const sync = useCallback(async () => {
100
160
  try {
101
- await reloadModules(baseUrl, options.token);
161
+ await syncModules(baseUrl);
102
162
  setState("ready");
103
163
  } catch (e) {
104
164
  setError(e instanceof Error ? e : new Error(String(e)));
105
165
  setState("error");
106
166
  }
107
- }, [baseUrl, options.token]);
167
+ }, [baseUrl]);
108
168
 
109
169
  // Initial load
110
170
  useEffect(() => {
111
171
  if (options.skip || loaded.current) return;
112
172
  loaded.current = true;
113
173
 
114
- loadModules(baseUrl, options.token)
174
+ loadModules(baseUrl)
115
175
  .then(() => setState("ready"))
116
176
  .catch((e) => {
117
177
  setError(e instanceof Error ? e : new Error(String(e)));
118
178
  setState("error");
119
179
  });
120
180
 
121
- // Listen for SSE reload events from CLI
181
+ // Listen for SSE reload events from CLI (code changed on disk → full reload)
122
182
  const evtSource = new EventSource(`${baseUrl}/api/reload-stream`);
123
183
  evtSource.onmessage = (event) => {
124
184
  if (event.data === "connected") return;
125
185
  console.log("[arc] Modules changed, reloading...");
126
- reload();
186
+ fullReload();
127
187
  };
128
188
  evtSource.onerror = () => {
129
189
  // SSE disconnected — will auto-reconnect
130
190
  };
131
191
 
132
192
  return () => evtSource.close();
133
- }, [baseUrl, reload, options.skip, options.token]);
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
+ }, []);
134
214
 
135
- // Reload on token change (login/logout)
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);
136
224
  useEffect(() => {
137
225
  if (!loaded.current) return;
138
226
  if (prevToken.current === options.token) return;
139
227
  prevToken.current = options.token;
140
- reload();
141
- }, [options.token, reload]);
228
+ sync();
229
+ }, [options.token, sync]);
142
230
 
143
- return { state, error, context: getContext(), reload };
231
+ return { state, error, context: getContext(), reload: fullReload };
144
232
  }
@@ -13,7 +13,7 @@ import {
13
13
  setDefaultLayout,
14
14
  } from "./registry";
15
15
  import { ThemeProvider } from "./theme";
16
- import { useEffect, useMemo, useState } from "react";
16
+ import { useEffect, useMemo, useRef, useState } from "react";
17
17
  import { useModuleLoader } from "./module-loader";
18
18
  import { PlatformModelProvider } from "./platform-context";
19
19
 
@@ -176,7 +176,16 @@ export function PlatformApp({
176
176
  // If context is pre-loaded (modules imported statically in main.tsx),
177
177
  // skip dynamic module loading entirely.
178
178
  const loader = useModuleLoader(apiUrl, { skip: !!preloadedContext });
179
- const context = preloadedContext ?? loader.context ?? getContext();
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;
180
189
 
181
190
  const resolvedWsUrl = wsUrl ?? `${location.protocol}//${location.host}`;
182
191
 
package/src/registry.ts CHANGED
@@ -15,6 +15,8 @@ import type {
15
15
  // ---------------------------------------------------------------------------
16
16
 
17
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)
18
20
  const listeners: Set<() => void> = new Set();
19
21
 
20
22
  function notify(): void {
@@ -26,8 +28,32 @@ export function subscribe(listener: () => void): () => void {
26
28
  return () => listeners.delete(listener);
27
29
  }
28
30
 
29
- /** Register module. Overwrites if already registered (needed for reload). */
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
+ */
30
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
+ }
31
57
  modules.set(mod.id, mod);
32
58
  notify();
33
59
  }
@@ -42,9 +68,22 @@ export function getModule(moduleId: string): ArcModule | undefined {
42
68
  }
43
69
 
44
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[] {
45
77
  return [...modules.values()];
46
78
  }
47
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
+
48
87
  // ---------------------------------------------------------------------------
49
88
  // Context store — dynamiczny context rejestrowany przez pakiety
50
89
  // ---------------------------------------------------------------------------
@@ -167,7 +206,8 @@ export function getSlotFragments(slotId: SlotId | string): SlotFragment[] {
167
206
  }
168
207
 
169
208
  export function getPageFragments(): PageFragment[] {
170
- return getAllFragments().filter((f): f is PageFragment => f.is("page"));
209
+ const fragments = getAllFragments().filter((f): f is PageFragment => f.is("page"));
210
+ return sortByOrdering(fragments);
171
211
  }
172
212
 
173
213
  export function getContextElementFragments(): ContextElementFragment[] {
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 = {
@@ -158,6 +161,12 @@ export interface PageOptions {
158
161
  label?: string | ReactNode;
159
162
  tooltip?: string | ReactNode;
160
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;
161
170
  }
162
171
 
163
172
  export interface WrapperOptions extends FragmentOrdering {