@arcote.tech/platform 0.8.3 → 0.8.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +5 -5
- package/src/i18n.tsx +38 -17
- package/src/module-loader.ts +42 -29
- package/src/platform-app.tsx +23 -11
- package/src/types.ts +5 -0
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
|
+
"version": "0.8.5",
|
|
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.
|
|
18
|
-
"@arcote.tech/arc-react": "^0.8.
|
|
17
|
+
"@arcote.tech/arc-ds": "^0.8.5",
|
|
18
|
+
"@arcote.tech/arc-react": "^0.8.5"
|
|
19
19
|
},
|
|
20
20
|
"peerDependencies": {
|
|
21
|
-
"@arcote.tech/arc": "^0.8.
|
|
22
|
-
"@arcote.tech/arc-otel": "^0.8.
|
|
21
|
+
"@arcote.tech/arc": "^0.8.5",
|
|
22
|
+
"@arcote.tech/arc-otel": "^0.8.5",
|
|
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
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
//
|
|
41
|
-
|
|
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
|
-
|
|
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
|
|
package/src/module-loader.ts
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
import {
|
|
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 (
|
|
96
|
-
|
|
97
|
-
|
|
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(
|
|
103
|
+
stale.push(scopeKey);
|
|
105
104
|
continue;
|
|
106
105
|
}
|
|
107
|
-
tokens[
|
|
106
|
+
tokens[scopeKey] = jwt;
|
|
108
107
|
}
|
|
109
|
-
for (const
|
|
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
|
|
129
|
+
/** Remove the named token scopes from cookies (invalid/rejected tokens). */
|
|
131
130
|
function clearPersistedTokens(scopes: string[]): void {
|
|
132
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 →
|
|
436
|
-
//
|
|
437
|
-
//
|
|
438
|
-
//
|
|
439
|
-
//
|
|
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 (
|
|
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);
|
package/src/platform-app.tsx
CHANGED
|
@@ -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>(
|
|
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
|