@arcote.tech/platform 0.8.4 → 0.8.6

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.8.4",
4
+ "version": "0.8.6",
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.8.4",
18
- "@arcote.tech/arc-react": "^0.8.4"
17
+ "@arcote.tech/arc-ds": "^0.8.6",
18
+ "@arcote.tech/arc-react": "^0.8.6"
19
19
  },
20
20
  "peerDependencies": {
21
- "@arcote.tech/arc": "^0.8.4",
22
- "@arcote.tech/arc-otel": "^0.8.4",
21
+ "@arcote.tech/arc": "^0.8.6",
22
+ "@arcote.tech/arc-otel": "^0.8.6",
23
23
  "@lingui/core": "^5.0.0",
24
24
  "@lingui/react": "^5.0.0",
25
25
  "framer-motion": "^12.0.0",
package/src/i18n.tsx CHANGED
@@ -16,6 +16,28 @@ interface I18nState {
16
16
 
17
17
  const I18nContext = createContext<I18nState | null>(null);
18
18
 
19
+ /** Server-inlined i18n from the HTML shell (`window.__ARC_I18N__`): the locale
20
+ * it picked (cookie → Accept-Language → sourceLocale) + that catalog. Present
21
+ * on first paint → app renders translated with no round-trip / no blank wait. */
22
+ interface InlinedI18n {
23
+ locale: string;
24
+ messages: Record<string, string>;
25
+ }
26
+ function getInlinedI18n(): InlinedI18n | null {
27
+ if (typeof window === "undefined") return null;
28
+ return (window as unknown as { __ARC_I18N__?: InlinedI18n }).__ARC_I18N__ ?? null;
29
+ }
30
+
31
+ /** Persist the chosen locale to a cookie so the next navigation's server-render
32
+ * inlines the right catalog. Cookie (not localStorage) because only cookies
33
+ * reach the server on `GET /`. */
34
+ function writeLocaleCookie(locale: string): void {
35
+ if (typeof document === "undefined") return;
36
+ const secure =
37
+ typeof location !== "undefined" && location.protocol === "https:" ? "; Secure" : "";
38
+ document.cookie = `arc-locale=${encodeURIComponent(locale)}; Path=/; SameSite=Lax; Max-Age=${365 * 24 * 60 * 60}${secure}`;
39
+ }
40
+
19
41
  export interface I18nProviderProps {
20
42
  children: ReactNode;
21
43
  defaultLocale: string;
@@ -29,34 +51,33 @@ export function I18nProvider({
29
51
  locales,
30
52
  loadMessages,
31
53
  }: I18nProviderProps) {
32
- const [locale, setLocaleRaw] = useState(() => {
33
- try {
34
- const saved = localStorage.getItem("arc-locale");
35
- if (saved && saved in locales) return saved;
36
- } catch {}
37
- return defaultLocale;
38
- });
39
- const [messages, setMessages] = useState<Record<string, string>>({});
40
- // Bez `ready` pierwszy paint renderuje aplikację z pustym `messages`
41
- // (bo `loadMessages` jest async w useEffect). `<Trans>foo</Trans>` w tej
42
- // klatce pokazuje surowe "foo" zamiast tłumaczenia, a po dociągnięciu
43
- // katalogu odbywa się re-render z poprawnym tekstem — widoczny flash.
44
- // Wstrzymanie renderu do pierwszego setMessages eliminuje flash.
45
- // Gdy `locales` jest puste (brak translations skonfigurowanych),
46
- // setMessages też się wywoła (z `{}`) i `ready` przejdzie na true od razu.
47
- const [ready, setReady] = useState(false);
54
+ const inlined = getInlinedI18n();
55
+ const [locale, setLocaleRaw] = useState(() => inlined?.locale ?? defaultLocale);
56
+ const [messages, setMessages] = useState<Record<string, string>>(
57
+ () => inlined?.messages ?? {},
58
+ );
59
+ // Render is gated on `ready` to avoid a flash of untranslated `<Trans>foo</Trans>`
60
+ // before the async catalog loads. The server inlines the catalog (even empty,
61
+ // for no-translation apps), so it's ALREADY here → ready immediately, zero
62
+ // round-trip. Only a missing shell (SSR / old build) falls back to fetching.
63
+ const [ready, setReady] = useState(() => !!inlined);
48
64
 
49
65
  const setLocale = useCallback(
50
66
  async (newLocale: string) => {
51
67
  const msgs = await loadMessages(newLocale);
52
68
  setMessages(msgs);
53
69
  setLocaleRaw(newLocale);
54
- try { localStorage.setItem("arc-locale", newLocale); } catch {}
70
+ writeLocaleCookie(newLocale);
55
71
  },
56
72
  [loadMessages],
57
73
  );
58
74
 
59
75
  useEffect(() => {
76
+ // Catalog already inlined for this locale → nothing to fetch.
77
+ if (ready) {
78
+ writeLocaleCookie(locale);
79
+ return;
80
+ }
60
81
  setLocale(locale).finally(() => setReady(true));
61
82
  }, []);
62
83
 
@@ -1,4 +1,9 @@
1
- import { registerModuleSyncProvider } from "@arcote.tech/arc";
1
+ import {
2
+ readAllTokenCookies,
3
+ registerModuleSyncProvider,
4
+ removeTokenCookie,
5
+ TOKEN_BUMP_KEY,
6
+ } from "@arcote.tech/arc";
2
7
  import { clearModules, getAllRegisteredModules, getContext, setActiveModules } from "./registry";
3
8
  import type { BuildManifest, BuildManifestGroup } from "./types";
4
9
  import {
@@ -88,25 +93,19 @@ function decodeJwtPayload(jwt: string): { exp?: number } | null {
88
93
  * host in X-Arc-Tokens.
89
94
  */
90
95
  function getAllPersistedTokens(): Record<string, string> {
91
- if (typeof localStorage === "undefined") return {};
92
96
  const tokens: Record<string, string> = {};
93
97
  const stale: string[] = [];
94
98
  const nowSec = Math.floor(Date.now() / 1000);
95
- for (let i = 0; i < localStorage.length; i++) {
96
- const key = localStorage.key(i);
97
- if (!key?.startsWith("arc:token:")) continue;
98
- const scope = key.slice(10);
99
- if (scope.includes(":")) continue; // namespaced = token cudzego modelu
100
- const raw = localStorage.getItem(key);
101
- if (!raw) continue;
102
- const payload = decodeJwtPayload(raw);
99
+ for (const { scopeKey, jwt } of readAllTokenCookies()) {
100
+ if (scopeKey.includes(":")) continue; // namespaced = token cudzego modelu
101
+ const payload = decodeJwtPayload(jwt);
103
102
  if (payload?.exp && payload.exp < nowSec) {
104
- stale.push(key);
103
+ stale.push(scopeKey);
105
104
  continue;
106
105
  }
107
- tokens[scope] = raw;
106
+ tokens[scopeKey] = jwt;
108
107
  }
109
- for (const key of stale) localStorage.removeItem(key);
108
+ for (const scopeKey of stale) removeTokenCookie(scopeKey);
110
109
  return tokens;
111
110
  }
112
111
 
@@ -127,10 +126,9 @@ function buildHeaders(tokens: Record<string, string>): HeadersInit {
127
126
  return headers;
128
127
  }
129
128
 
130
- /** Remove the named token scopes from localStorage (invalid/rejected tokens). */
129
+ /** Remove the named token scopes from cookies (invalid/rejected tokens). */
131
130
  function clearPersistedTokens(scopes: string[]): void {
132
- if (typeof localStorage === "undefined") return;
133
- for (const scope of scopes) localStorage.removeItem(`arc:token:${scope}`);
131
+ for (const scope of scopes) removeTokenCookie(scope);
134
132
  }
135
133
 
136
134
  /** Single manifest request with an explicit token map. `bust` forces a
@@ -262,13 +260,27 @@ async function importGroupsWithRecovery(
262
260
  console.error(
263
261
  `[arc] Group "${name}" still failing after manifest refresh; clearing token.`,
264
262
  );
265
- if (typeof localStorage !== "undefined") {
266
- localStorage.removeItem(`arc:token:${name}`);
267
- }
263
+ removeTokenCookie(name);
268
264
  }
269
265
  return fresh;
270
266
  }
271
267
 
268
+ /**
269
+ * Consume the server-inlined manifest (`window.__ARC_MANIFEST__`) exactly once.
270
+ * The server renders it into the HTML shell already filtered + signed for the
271
+ * caller's cookies, so the first load skips the `/api/modules` round-trip. We
272
+ * null it out after reading so any later sync (token change / dev reload)
273
+ * refetches fresh from `/api/modules`.
274
+ */
275
+ function consumeInlinedManifest(): BuildManifest | null {
276
+ if (typeof window === "undefined") return null;
277
+ const w = window as unknown as { __ARC_MANIFEST__?: BuildManifest };
278
+ const m = w.__ARC_MANIFEST__;
279
+ if (!m) return null;
280
+ w.__ARC_MANIFEST__ = undefined;
281
+ return m;
282
+ }
283
+
272
284
  /**
273
285
  * Load all modules from manifest. Each module auto-registers via module().build().
274
286
  */
@@ -276,7 +288,9 @@ export async function loadModules(
276
288
  baseUrl: string,
277
289
  tokens?: Record<string, string>,
278
290
  ): Promise<BuildManifest> {
279
- const manifest = await fetchManifest(baseUrl, tokens);
291
+ // First paint: use the manifest the server inlined into the HTML (already
292
+ // filtered + signed for our cookies) instead of a `/api/modules` round-trip.
293
+ const manifest = consumeInlinedManifest() ?? (await fetchManifest(baseUrl, tokens));
280
294
 
281
295
  const names = Object.keys(manifest.groups);
282
296
  for (const name of names) importedGroupHashes.set(name, manifest.groups[name].hash);
@@ -432,19 +446,18 @@ export function useModuleLoader(
432
446
  return () => evtSource.close();
433
447
  }, [baseUrl, fullReload, options.skip]);
434
448
 
435
- // Cross-tab token changes (another tab logged in/out → localStorage write)
436
- // sync. Same-tab changes are driven directly through the module-sync
437
- // coordinator (scope.setToken triggerModuleSync registered provider),
438
- // so we deliberately do NOT listen for the same-tab "arc:token-change"
439
- // CustomEvent here that would double-sync.
449
+ // Cross-tab token changes (another tab logged in/out)sync. Tokens now live
450
+ // in cookies, which don't fire `storage`, so cookie-token-store bumps a tiny
451
+ // localStorage sentinel (TOKEN_BUMP_KEY) on every write/remove. The `storage`
452
+ // event fires only in OTHER tabs (never the writer) — exactly the cross-tab
453
+ // semantics we want. Same-tab changes are driven directly through the
454
+ // module-sync coordinator (scope.setToken → triggerModuleSync), so we do NOT
455
+ // react to same-tab changes here — that would double-sync.
440
456
  useEffect(() => {
441
457
  if (typeof window === "undefined") return;
442
458
 
443
459
  const onStorage = (e: StorageEvent) => {
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;
460
+ if (e.key !== TOKEN_BUMP_KEY) return;
448
461
  setTokenVersion((v) => v + 1);
449
462
  };
450
463
  window.addEventListener("storage", onStorage);
@@ -79,22 +79,34 @@ const EMPTY_I18N_CONFIG: I18nConfig = { locales: {}, sourceLocale: "en" };
79
79
  // I18nProvider RAZ z właściwymi propsami; locale state w providerze jest
80
80
  // w useState bez sync z propsami, więc remount po zmianie propsów
81
81
  // wprowadziłby dodatkowy flash.
82
+ function toI18nConfig(data: any): I18nConfig | null {
83
+ if (data?.locales && Array.isArray(data.locales) && data.locales.length > 0) {
84
+ // ["pl-PL","en-US"] → { "pl-PL":"pl-PL", "en-US":"en-US" }
85
+ const localesMap: Record<string, string> = {};
86
+ for (const l of data.locales) localesMap[l] = l;
87
+ return { locales: localesMap, sourceLocale: data.sourceLocale };
88
+ }
89
+ return null;
90
+ }
91
+
82
92
  function useI18nConfig(apiUrl: string): I18nConfig | null {
83
- const [config, setConfig] = useState<I18nConfig | null>(null);
93
+ const [config, setConfig] = useState<I18nConfig | null>(() => {
94
+ // Server inlined the translations config into the HTML shell (always — even
95
+ // for no-translation apps) → no `/api/translations` round-trip. Presence of
96
+ // __ARC_I18N__ means "server answered"; a null config = no translations.
97
+ const shell =
98
+ typeof window !== "undefined"
99
+ ? (window as unknown as { __ARC_I18N__?: { config?: any } }).__ARC_I18N__
100
+ : undefined;
101
+ if (shell !== undefined) return toI18nConfig(shell.config) ?? EMPTY_I18N_CONFIG;
102
+ return null;
103
+ });
84
104
 
85
105
  useEffect(() => {
106
+ if (config) return; // already have it from the inlined shell
86
107
  fetch(`${apiUrl}/api/translations`)
87
108
  .then((r) => r.json())
88
- .then((data) => {
89
- if (data?.locales && Array.isArray(data.locales) && data.locales.length > 0) {
90
- // Convert ["pl-PL", "en-US"] → { "pl-PL": "pl-PL", "en-US": "en-US" }
91
- const localesMap: Record<string, string> = {};
92
- for (const l of data.locales) localesMap[l] = l;
93
- setConfig({ locales: localesMap, sourceLocale: data.sourceLocale });
94
- } else {
95
- setConfig(EMPTY_I18N_CONFIG);
96
- }
97
- })
109
+ .then((data) => setConfig(toI18nConfig(data) ?? EMPTY_I18N_CONFIG))
98
110
  .catch(() => setConfig(EMPTY_I18N_CONFIG));
99
111
  }, [apiUrl]);
100
112
 
package/src/types.ts CHANGED
@@ -165,6 +165,9 @@ export interface BuildManifestGroup {
165
165
  readonly modules: readonly string[];
166
166
  /** Signed URL — server fills this for protected groups when filtering per request. */
167
167
  readonly url?: string;
168
+ /** Shared chunks this group statically imports (transitive closure) — the
169
+ * server preloads them for accessible groups so they fetch in parallel. */
170
+ readonly staticChunks?: readonly string[];
168
171
  }
169
172
 
170
173
  /** Build manifest written to .arc/platform/manifest.json. */
@@ -176,6 +179,8 @@ export interface BuildManifest {
176
179
  readonly initial: {
177
180
  readonly file: string;
178
181
  readonly hash: string;
182
+ /** Shared chunks initial statically imports — server preloads them. */
183
+ readonly staticChunks?: readonly string[];
179
184
  };
180
185
  /**
181
186
  * Token-gated groups keyed by `token.name`. One bundle per group containing