akanjs 3.1.0 → 3.1.1-rc.1
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/base/primitiveRegistry.ts +3 -0
- package/client/cookie.ts +80 -35
- package/client/dictionaryAsset.ts +32 -0
- package/client/index.ts +1 -0
- package/client/makePageProto.tsx +9 -15
- package/client/native.ts +1 -22
- package/client/nativeHost.ts +22 -0
- package/client/route/LayoutDefinition.ts +3 -2
- package/client/route/RouteDefinition.ts +10 -5
- package/client/translator.ts +20 -60
- package/client/translatorState.ts +44 -0
- package/client/types.ts +1 -0
- package/common/IpLiteral.ts +75 -0
- package/common/TrustedProxy.ts +100 -71
- package/common/appHosts.ts +32 -0
- package/common/authCookie.ts +8 -5
- package/common/authToken.ts +24 -6
- package/common/clientAddress.ts +22 -11
- package/common/cookieJar.ts +90 -0
- package/common/cookieSweep.ts +77 -0
- package/common/index.ts +14 -1
- package/common/remoteAuthCode.ts +55 -0
- package/common/requestLocale.ts +28 -0
- package/common/sessionCookies.ts +43 -0
- package/common/types.ts +5 -0
- package/constant/constantRegistry.ts +33 -6
- package/constant/refNameClaims.ts +99 -0
- package/dictionary/base.dictionary.ts +5 -0
- package/document/databaseRegistry.ts +2 -2
- package/document/filterMeta.ts +2 -2
- package/fetch/client/fetchClient.ts +69 -25
- package/fetch/client/wsClient.ts +21 -4
- package/fetch/index.ts +1 -0
- package/fetch/renderScope.ts +54 -0
- package/fetch/requestStorage.ts +19 -9
- package/native/api.ts +1 -0
- package/native/config.ts +1 -0
- package/package.json +11 -1
- package/server/akanApp.ts +12 -11
- package/server/akanAppHeaders.ts +16 -14
- package/server/akanServer.ts +26 -4
- package/server/cachePolicy.ts +5 -3
- package/server/contentEncoding.ts +164 -26
- package/server/di/diLifecycle.ts +2 -0
- package/server/hmr/devArtifactPruner.ts +13 -3
- package/server/hmr/devHmrController.ts +1 -0
- package/server/imageOptimizer.ts +3 -1
- package/server/lifecycle/runtimeDir.ts +16 -1
- package/server/mcp/McpRouter.ts +54 -0
- package/server/ops/statusAccess.ts +59 -0
- package/server/routeState.ts +1 -0
- package/server/routing/apiRouter.ts +88 -4
- package/server/routing/appWsData.ts +11 -6
- package/server/routing/routeHosts.ts +31 -0
- package/server/routing/soloAppRoutes.ts +7 -4
- package/server/rscClient.tsx +31 -16
- package/server/rscPagePrompts.ts +7 -3
- package/server/rscWorker.tsx +3 -1
- package/server/ssrFromRscRenderer.tsx +11 -9
- package/server/ssrTypes.ts +2 -0
- package/server/types.tsx +7 -0
- package/server/vendor/tailwind-merge.ts +1 -0
- package/server/webRouter.ts +82 -22
- package/service/serve.ts +6 -3
- package/signal/endpointIdempotency.ts +3 -3
- package/signal/mcp/McpDocument.ts +25 -5
- package/signal/serializer/fetch.serializer.ts +1 -1
- package/signal/signalContext.ts +22 -10
- package/signal/slice.ts +19 -8
- package/store/storeInstance.ts +6 -5
- package/test/ServedMcpCatalogue.ts +44 -0
- package/test/index.ts +9 -1
- package/test/signalTestRuntime.ts +11 -1
- package/test/testServer.ts +21 -0
- package/types/base/primitiveRegistry.d.ts +1 -0
- package/types/client/cookie.d.ts +7 -7
- package/types/client/dictionaryAsset.d.ts +4 -0
- package/types/client/index.d.ts +1 -0
- package/types/client/native.d.ts +1 -8
- package/types/client/nativeHost.d.ts +8 -0
- package/types/client/translator.d.ts +4 -9
- package/types/client/translatorState.d.ts +21 -0
- package/types/client/types.d.ts +1 -0
- package/types/common/IpLiteral.d.ts +6 -0
- package/types/common/TrustedProxy.d.ts +6 -0
- package/types/common/appHosts.d.ts +7 -0
- package/types/common/authCookie.d.ts +3 -2
- package/types/common/authToken.d.ts +13 -0
- package/types/common/clientAddress.d.ts +6 -2
- package/types/common/cookieJar.d.ts +22 -0
- package/types/common/cookieSweep.d.ts +12 -0
- package/types/common/index.d.ts +7 -1
- package/types/common/remoteAuthCode.d.ts +9 -0
- package/types/common/requestLocale.d.ts +13 -0
- package/types/common/sessionCookies.d.ts +13 -0
- package/types/common/types.d.ts +5 -0
- package/types/constant/constantRegistry.d.ts +2 -1
- package/types/constant/refNameClaims.d.ts +12 -0
- package/types/dictionary/base.dictionary.d.ts +1 -1
- package/types/dictionary/dictionary.d.ts +8 -8
- package/types/document/filterMeta.d.ts +2 -2
- package/types/fetch/agentTurn.d.ts +1 -1
- package/types/fetch/client/fetchClient.d.ts +1 -0
- package/types/fetch/client/wsClient.d.ts +2 -0
- package/types/fetch/index.d.ts +1 -0
- package/types/fetch/renderScope.d.ts +18 -0
- package/types/fetch/requestStorage.d.ts +10 -5
- package/types/native/api.d.ts +1 -0
- package/types/native/config.d.ts +1 -0
- package/types/server/akanServer.d.ts +10 -1
- package/types/server/cachePolicy.d.ts +1 -1
- package/types/server/contentEncoding.d.ts +16 -4
- package/types/server/hmr/devArtifactPruner.d.ts +1 -0
- package/types/server/mcp/McpRouter.d.ts +16 -1
- package/types/server/ops/statusAccess.d.ts +15 -0
- package/types/server/routeState.d.ts +1 -0
- package/types/server/routing/appWsData.d.ts +4 -0
- package/types/server/routing/routeHosts.d.ts +4 -0
- package/types/server/routing/soloAppRoutes.d.ts +2 -1
- package/types/server/rscClient.d.ts +1 -0
- package/types/server/ssrTypes.d.ts +2 -0
- package/types/server/types.d.ts +6 -0
- package/types/server/vendor/tailwind-merge.d.ts +1 -0
- package/types/signal/mcp/McpDocument.d.ts +8 -0
- package/types/signal/signalContext.d.ts +6 -4
- package/types/signal/slice.d.ts +2 -1
- package/types/test/ServedMcpCatalogue.d.ts +15 -0
- package/types/test/index.d.ts +3 -2
- package/types/test/signalTestRuntime.d.ts +8 -1
- package/types/test/testServer.d.ts +15 -0
- package/types/ui/System/Client.d.ts +7 -5
- package/types/ui/System/Common.d.ts +1 -4
- package/types/ui/System/SSR.d.ts +2 -2
- package/types/ui/index.d.ts +2 -0
- package/types/ui/recipe/badgeRecipe.d.ts +2 -2
- package/types/ui/recipe/buttonRecipe.d.ts +2 -2
- package/types/ui/recipe/factory.d.ts +2 -1
- package/types/ui/recipe/inputRecipe.d.ts +2 -2
- package/ui/Clipboard.tsx +3 -1
- package/ui/Link/Anchor.tsx +1 -2
- package/ui/System/CSR.tsx +4 -2
- package/ui/System/Client.tsx +25 -17
- package/ui/System/Common.tsx +1 -5
- package/ui/System/SSR.tsx +4 -5
- package/ui/fontCss.ts +1 -1
- package/ui/index.ts +2 -0
- package/ui/recipe/factory.ts +17 -4
package/client/cookie.ts
CHANGED
|
@@ -2,14 +2,19 @@ import { getEnv } from "akanjs/base";
|
|
|
2
2
|
import {
|
|
3
3
|
authTokenKey,
|
|
4
4
|
bearerTokenOf,
|
|
5
|
+
type CookieEntry,
|
|
6
|
+
CookieJar,
|
|
7
|
+
CookieSweep,
|
|
5
8
|
decodeJwtPayload,
|
|
6
9
|
isOwnAuthToken,
|
|
7
10
|
Logger,
|
|
8
11
|
legacyAuthTokenKey,
|
|
9
|
-
|
|
12
|
+
readSessionToken,
|
|
13
|
+
retiredAuthTokenKeys,
|
|
14
|
+
SessionCookies,
|
|
10
15
|
} from "akanjs/common";
|
|
11
16
|
import type { Account } from "akanjs/fetch";
|
|
12
|
-
import {
|
|
17
|
+
import { cookieJar as serverCookieJar, headers as serverHeaders } from "akanjs/fetch";
|
|
13
18
|
import { secretStorage } from "./storage";
|
|
14
19
|
import { fetch } from "./useClient";
|
|
15
20
|
|
|
@@ -24,26 +29,35 @@ const isCsrClient = () => {
|
|
|
24
29
|
return env.side === "client" && env.renderMode === "csr";
|
|
25
30
|
};
|
|
26
31
|
|
|
27
|
-
const
|
|
28
|
-
const
|
|
29
|
-
const
|
|
32
|
+
const storedCookiesKey = "akan:cookies";
|
|
33
|
+
const pageLocation = () => (globalThis as { location?: Location }).location;
|
|
34
|
+
const pageKeepsCookies = () => pageLocation()?.protocol !== "app:";
|
|
35
|
+
const readStoredCookies = (): Record<string, string> => {
|
|
30
36
|
try {
|
|
31
|
-
return JSON.parse(localStorage.getItem(
|
|
37
|
+
return JSON.parse(localStorage.getItem(storedCookiesKey) ?? "{}") as Record<string, string>;
|
|
32
38
|
} catch {
|
|
33
39
|
return {};
|
|
34
40
|
}
|
|
35
41
|
};
|
|
36
|
-
const
|
|
37
|
-
localStorage.setItem(
|
|
42
|
+
const writeStoredCookies = (stored: Record<string, string>) => {
|
|
43
|
+
localStorage.setItem(storedCookiesKey, JSON.stringify(stored));
|
|
38
44
|
};
|
|
39
45
|
|
|
40
|
-
|
|
41
|
-
|
|
46
|
+
/** Every cookie this page or request holds, a name sent more than once included. */
|
|
47
|
+
export const cookieJar = (): CookieJar => {
|
|
48
|
+
if (getEnv().side === "server") return serverCookieJar();
|
|
42
49
|
if (!pageKeepsCookies())
|
|
43
|
-
return
|
|
44
|
-
|
|
50
|
+
return CookieJar.parse(
|
|
51
|
+
Object.entries(readStoredCookies())
|
|
52
|
+
.map(([name, value]) => `${name}=${value}`)
|
|
53
|
+
.join("; "),
|
|
54
|
+
);
|
|
55
|
+
return CookieJar.parse(document.cookie);
|
|
45
56
|
};
|
|
46
57
|
|
|
58
|
+
/** Names held exactly once; a duplicated name is left out. */
|
|
59
|
+
export const cookies = (): Map<string, CookieEntry> => cookieJar().toMap();
|
|
60
|
+
|
|
47
61
|
export const setCookie = (
|
|
48
62
|
key: string,
|
|
49
63
|
value: string,
|
|
@@ -51,7 +65,7 @@ export const setCookie = (
|
|
|
51
65
|
) => {
|
|
52
66
|
if (getEnv().side === "server") return;
|
|
53
67
|
if (!pageKeepsCookies()) {
|
|
54
|
-
|
|
68
|
+
writeStoredCookies({ ...readStoredCookies(), [key]: value });
|
|
55
69
|
return;
|
|
56
70
|
}
|
|
57
71
|
const pathAttr = path ? `; path=${path}` : "";
|
|
@@ -60,19 +74,21 @@ export const setCookie = (
|
|
|
60
74
|
document.cookie = `${key}=${value}${pathAttr}; SameSite=${sameSite}${secureAttr}`;
|
|
61
75
|
};
|
|
62
76
|
|
|
63
|
-
/**
|
|
64
|
-
export const getCookie = (key: string): string | undefined =>
|
|
77
|
+
/** `undefined` for a name held more than once, as for one never set. */
|
|
78
|
+
export const getCookie = (key: string): string | undefined => cookieJar().read(key).value;
|
|
65
79
|
|
|
66
80
|
export const removeCookie = (key: string, options: { path: string } = { path: "/" }) => {
|
|
67
81
|
|
|
68
82
|
if (getEnv().side === "server") return;
|
|
69
83
|
if (!pageKeepsCookies()) {
|
|
70
|
-
const { [key]: _removed, ...rest } =
|
|
71
|
-
|
|
84
|
+
const { [key]: _removed, ...rest } = readStoredCookies();
|
|
85
|
+
writeStoredCookies(rest);
|
|
72
86
|
return;
|
|
73
87
|
}
|
|
74
88
|
|
|
75
|
-
|
|
89
|
+
const secureAttr = /^__(Host|Secure)-/.test(key) ? " Secure;" : "";
|
|
90
|
+
|
|
91
|
+
document.cookie = `${key}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=${options.path};${secureAttr}`;
|
|
76
92
|
};
|
|
77
93
|
export const headers = (): Map<string, string> => {
|
|
78
94
|
if (getEnv().side !== "server") return new Map();
|
|
@@ -95,17 +111,15 @@ const sentAuthToken = (): string | undefined => {
|
|
|
95
111
|
};
|
|
96
112
|
|
|
97
113
|
/** The auth token this app holds: on a CSR client the one `fetch` sends first, since a native shell's page keeps no
|
|
98
|
-
* session cookie; then the cookie
|
|
114
|
+
* session cookie; then the app-scoped cookie, unless it is held more than once. */
|
|
99
115
|
export const getAuthToken = (): string | undefined =>
|
|
100
|
-
(isCsrClient() ? sentAuthToken() : undefined) ??
|
|
116
|
+
(isCsrClient() ? sentAuthToken() : undefined) ?? readSessionToken(cookieJar()).token;
|
|
101
117
|
|
|
102
118
|
/** The auth token this app holds in client storage — the OS credential store in a native app, localStorage elsewhere. */
|
|
103
|
-
export const getStoredAuthToken = async (): Promise<string | undefined> =>
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
return legacy && isOwnAuthToken(legacy) ? legacy : undefined;
|
|
108
|
-
};
|
|
119
|
+
export const getStoredAuthToken = async (): Promise<string | undefined> =>
|
|
120
|
+
(await secretStorage.getItem(storedAuthTokenKey())) ?? undefined;
|
|
121
|
+
|
|
122
|
+
const storedAuthTokenKey = () => `${legacyAuthTokenKey}:${getEnv().appName}`;
|
|
109
123
|
|
|
110
124
|
/** The current JWT's account when minted for this app and environment. Reads only the two credentials the server
|
|
111
125
|
* honours — the app-scoped auth cookie (a CSR client's bearer token) and `Authorization: Bearer` — so the two never
|
|
@@ -136,9 +150,9 @@ export const setAuth = ({ jwt }: SetAuthOption) => {
|
|
|
136
150
|
fetch.setJwt(jwt);
|
|
137
151
|
|
|
138
152
|
if (!isCsrClient()) setCookie(authTokenKey(), jwt);
|
|
139
|
-
void secretStorage.setItem(
|
|
153
|
+
void secretStorage.setItem(storedAuthTokenKey(), jwt);
|
|
140
154
|
|
|
141
|
-
removeCookie(
|
|
155
|
+
for (const key of retiredAuthTokenKeys()) removeCookie(key);
|
|
142
156
|
void secretStorage.removeItem(legacyAuthTokenKey);
|
|
143
157
|
announceAuth();
|
|
144
158
|
};
|
|
@@ -146,25 +160,56 @@ export const setAuth = ({ jwt }: SetAuthOption) => {
|
|
|
146
160
|
interface InitAuthOption {
|
|
147
161
|
jwt?: string;
|
|
148
162
|
}
|
|
163
|
+
|
|
164
|
+
const sweepDocumentCookies = (names: string[]) => {
|
|
165
|
+
const { hostname = "", pathname = "/" } = pageLocation() ?? {};
|
|
166
|
+
|
|
167
|
+
for (const entry of CookieSweep.setCookies({ names, host: hostname, path: pathname })) document.cookie = entry;
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
const readCookieSession = (): string | null | undefined => {
|
|
171
|
+
const first = readSessionToken(cookieJar());
|
|
172
|
+
if (!first.duplicate) return first.token;
|
|
173
|
+
SessionCookies.report([first.duplicate], pageLocation()?.host ?? "");
|
|
174
|
+
sweepDocumentCookies([first.duplicate.name]);
|
|
175
|
+
const second = readSessionToken(cookieJar());
|
|
176
|
+
return second.duplicate ? null : second.token;
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
const expireRetiredCookies = () => {
|
|
180
|
+
const jar = cookieJar();
|
|
181
|
+
for (const key of retiredAuthTokenKeys()) if (jar.has(key)) removeCookie(key);
|
|
182
|
+
};
|
|
183
|
+
|
|
149
184
|
export const initAuth = ({ jwt }: InitAuthOption = {}) => {
|
|
150
|
-
|
|
151
|
-
const
|
|
185
|
+
expireRetiredCookies();
|
|
186
|
+
const sent = jwt ?? (isCsrClient() ? sentAuthToken() : undefined);
|
|
187
|
+
const token = sent ?? readCookieSession();
|
|
188
|
+
if (token === null) {
|
|
189
|
+
fetch.setJwt(null);
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
152
192
|
if (token && !isOwnAuthToken(token)) {
|
|
153
193
|
|
|
154
194
|
Logger.warn("JWT ignored: it was minted for another app or environment");
|
|
155
|
-
if (
|
|
195
|
+
if (!jwt) resetAuth();
|
|
156
196
|
return;
|
|
157
197
|
}
|
|
158
|
-
if (token) setAuth({ jwt: token });
|
|
198
|
+
if (token && sent) setAuth({ jwt: token });
|
|
199
|
+
else if (token) {
|
|
200
|
+
|
|
201
|
+
fetch.setJwt(token);
|
|
202
|
+
announceAuth();
|
|
203
|
+
}
|
|
159
204
|
|
|
160
|
-
Logger.verbose(`JWT ${token ? "restored
|
|
205
|
+
Logger.verbose(`JWT ${token ? "restored" : "not found"}`);
|
|
161
206
|
};
|
|
162
207
|
|
|
163
208
|
export const resetAuth = () => {
|
|
164
209
|
fetch.setJwt(null);
|
|
165
210
|
removeCookie(authTokenKey());
|
|
166
|
-
removeCookie(
|
|
167
|
-
void secretStorage.removeItem(
|
|
211
|
+
for (const key of retiredAuthTokenKeys()) removeCookie(key);
|
|
212
|
+
void secretStorage.removeItem(storedAuthTokenKey());
|
|
168
213
|
void secretStorage.removeItem(legacyAuthTokenKey);
|
|
169
214
|
announceAuth();
|
|
170
215
|
};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { type Dictionary, TranslatorState } from "./translatorState";
|
|
2
|
+
|
|
3
|
+
interface DictionaryModule {
|
|
4
|
+
lang: string;
|
|
5
|
+
default: Dictionary;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export class DictionaryAsset {
|
|
9
|
+
static readonly #loads = new Map<string, Promise<DictionaryModule>>();
|
|
10
|
+
static #retrySeq = 0;
|
|
11
|
+
|
|
12
|
+
static async load(url: string | null | undefined): Promise<void> {
|
|
13
|
+
if (!url) return;
|
|
14
|
+
const loading = DictionaryAsset.#loads.get(url) ?? DictionaryAsset.#import(url);
|
|
15
|
+
DictionaryAsset.#loads.set(url, loading);
|
|
16
|
+
const { lang, default: dictionary } = await loading;
|
|
17
|
+
TranslatorState.replace(lang, dictionary);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
static async #import(url: string): Promise<DictionaryModule> {
|
|
21
|
+
try {
|
|
22
|
+
return (await import(url)) as DictionaryModule;
|
|
23
|
+
} catch {
|
|
24
|
+
try {
|
|
25
|
+
return (await import(`${url}?retry=${++DictionaryAsset.#retrySeq}`)) as DictionaryModule;
|
|
26
|
+
} catch (error) {
|
|
27
|
+
DictionaryAsset.#loads.delete(url);
|
|
28
|
+
throw error;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
package/client/index.ts
CHANGED
package/client/makePageProto.tsx
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { getEnv } from "akanjs/base";
|
|
2
|
-
import { parseAkanI18nEnv } from "akanjs/common";
|
|
2
|
+
import { parseAkanI18nEnv, RequestLocale } from "akanjs/common";
|
|
3
3
|
import { untrackedHeaders, untrackedRequest } from "akanjs/fetch";
|
|
4
4
|
import type { ReactNode } from "react";
|
|
5
5
|
import { csrLocation } from "./csrLocation";
|
|
@@ -36,21 +36,15 @@ const getPageInfo = (): { locale: string; path: string } => {
|
|
|
36
36
|
return { locale, path: hasLocalePrefix ? `/${rest.join("/")}` : pathname };
|
|
37
37
|
}
|
|
38
38
|
const h = untrackedHeaders();
|
|
39
|
-
|
|
40
|
-
const localeHeader = h.get("x-locale");
|
|
41
|
-
const pathHeader = h.get("x-path");
|
|
42
|
-
if (localeHeader && pathHeader) return { locale: localeHeader, path: pathHeader };
|
|
43
39
|
const req = untrackedRequest();
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
}
|
|
53
|
-
return { locale: localeHeader ?? defaultLocale, path: pathHeader ?? "/" };
|
|
40
|
+
return RequestLocale.resolve(
|
|
41
|
+
{
|
|
42
|
+
localeHeader: h.get("x-locale"),
|
|
43
|
+
pathHeader: h.get("x-path"),
|
|
44
|
+
pathname: req ? new URL(req.url).pathname : undefined,
|
|
45
|
+
},
|
|
46
|
+
{ defaultLocale, locales },
|
|
47
|
+
);
|
|
54
48
|
};
|
|
55
49
|
|
|
56
50
|
export interface TransMessageOption {
|
package/client/native.ts
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { isNative, platform } from "../vendor/@akanjs/native/packages/core/src/index.ts";
|
|
2
1
|
import { browser } from "../vendor/@akanjs/native/plugins/browser/src/index.ts";
|
|
3
2
|
import { opener } from "../vendor/@akanjs/native/plugins/opener/src/index.ts";
|
|
4
3
|
|
|
@@ -50,30 +49,10 @@ export { markReady, updates, useUpdateState } from "../vendor/@akanjs/native/plu
|
|
|
50
49
|
export type { VolumeState } from "../vendor/@akanjs/native/plugins/volume/src/index.ts";
|
|
51
50
|
export { volume } from "../vendor/@akanjs/native/plugins/volume/src/index.ts";
|
|
52
51
|
export { useLiveValue, usePluginEvent } from "../vendor/@akanjs/native/packages/react/src/index.ts";
|
|
52
|
+
export { desktopPlatform, isNativeApp, isNativeShell, nativePlatform, openExternalUrl } from "./nativeHost";
|
|
53
53
|
export { browser, opener };
|
|
54
54
|
|
|
55
55
|
export const loadCamera = () => import("../vendor/@akanjs/native/plugins/camera/src/index.ts");
|
|
56
56
|
export const loadContacts = () => import("../vendor/@akanjs/native/plugins/contacts/src/index.ts");
|
|
57
57
|
export const loadGeolocation = () => import("../vendor/@akanjs/native/plugins/geolocation/src/index.ts");
|
|
58
58
|
export const loadIap = () => import("../vendor/@akanjs/native/plugins/iap/src/index.ts");
|
|
59
|
-
|
|
60
|
-
/** Inside an iOS or Android shell of the native runtime; a mobile target opened in a browser is not. */
|
|
61
|
-
export const isNativeApp = () => isNative && (platform === "ios" || platform === "android");
|
|
62
|
-
|
|
63
|
-
/** Inside any shell of the native runtime, desktop included. */
|
|
64
|
-
export const isNativeShell = () => isNative;
|
|
65
|
-
|
|
66
|
-
export const nativePlatform = () => (isNativeApp() ? (platform as "ios" | "android") : null);
|
|
67
|
-
|
|
68
|
-
export const desktopPlatform = () =>
|
|
69
|
-
isNative && (platform === "macos" || platform === "windows" || platform === "linux") ? platform : null;
|
|
70
|
-
|
|
71
|
-
/** http(s) in the in-app browser, anything else (mailto:, tel:) in the app the system picks. */
|
|
72
|
-
export const openExternalUrl = async (url: string) => {
|
|
73
|
-
if (!isNativeApp()) {
|
|
74
|
-
window.open(url, "_blank", "noopener,noreferrer");
|
|
75
|
-
return;
|
|
76
|
-
}
|
|
77
|
-
if (/^https?:/i.test(url)) await browser.open({ url });
|
|
78
|
-
else await opener.openUrl({ url });
|
|
79
|
-
};
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { isNative, platform } from "../vendor/@akanjs/native/packages/core/src/index.ts";
|
|
2
|
+
|
|
3
|
+
/** Inside an iOS or Android shell of the native runtime; a mobile target opened in a browser is not. */
|
|
4
|
+
export const isNativeApp = () => isNative && (platform === "ios" || platform === "android");
|
|
5
|
+
|
|
6
|
+
/** Inside any shell of the native runtime, desktop included. */
|
|
7
|
+
export const isNativeShell = () => isNative;
|
|
8
|
+
|
|
9
|
+
export const nativePlatform = () => (isNativeApp() ? (platform as "ios" | "android") : null);
|
|
10
|
+
|
|
11
|
+
export const desktopPlatform = () =>
|
|
12
|
+
isNative && (platform === "macos" || platform === "windows" || platform === "linux") ? platform : null;
|
|
13
|
+
|
|
14
|
+
/** http(s) in the in-app browser, anything else (mailto:, tel:) in the app the system picks. */
|
|
15
|
+
export const openExternalUrl = async (url: string) => {
|
|
16
|
+
if (!isNativeApp()) {
|
|
17
|
+
window.open(url, "_blank", "noopener,noreferrer");
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
if (/^https?:/i.test(url)) await (await import("../vendor/@akanjs/native/plugins/browser/src/index.ts")).browser.open({ url });
|
|
21
|
+
else await (await import("../vendor/@akanjs/native/plugins/opener/src/index.ts")).opener.openUrl({ url });
|
|
22
|
+
};
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { RenderScope } from "akanjs/fetch";
|
|
1
2
|
import type { ReactNode } from "react";
|
|
2
3
|
import type { LayoutErrorRender, LayoutModule, LayoutNotFoundRender, PageModule } from "../csrTypes";
|
|
3
4
|
import { type RouteArgsShape, RouteDefinition, type RouteKind } from "./RouteDefinition";
|
|
@@ -27,12 +28,12 @@ export class LayoutDefinition<Args extends RouteArgsShape = Record<never, never>
|
|
|
27
28
|
}
|
|
28
29
|
|
|
29
30
|
notFound(render: LayoutNotFoundRender) {
|
|
30
|
-
this.#notFound = render;
|
|
31
|
+
this.#notFound = (props) => RenderScope.run(() => render(props));
|
|
31
32
|
return this;
|
|
32
33
|
}
|
|
33
34
|
|
|
34
35
|
error(render: LayoutErrorRender) {
|
|
35
|
-
this.#error = render;
|
|
36
|
+
this.#error = (props) => RenderScope.run(() => render(props));
|
|
36
37
|
return this;
|
|
37
38
|
}
|
|
38
39
|
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
} from "akanjs/base";
|
|
9
9
|
import { parseAkanI18nEnv } from "akanjs/common";
|
|
10
10
|
import { deserialize } from "akanjs/constant";
|
|
11
|
+
import { RenderScope } from "akanjs/fetch";
|
|
11
12
|
import type { ReactNode } from "react";
|
|
12
13
|
import type { Head, LayoutModule, PageConfig, PageModule, RouteGuardProps } from "../csrTypes";
|
|
13
14
|
import { AkanNotFoundError } from "../router";
|
|
@@ -145,12 +146,15 @@ export abstract class RouteDefinition<
|
|
|
145
146
|
...(head === undefined
|
|
146
147
|
? {}
|
|
147
148
|
: typeof head === "function"
|
|
148
|
-
? {
|
|
149
|
+
? {
|
|
150
|
+
generateHead: async (props: RouteRenderProps) =>
|
|
151
|
+
await RenderScope.run(() => head(this.#argsOf(props) as never)),
|
|
152
|
+
}
|
|
149
153
|
: { head }),
|
|
150
154
|
...(loading
|
|
151
155
|
? {
|
|
152
156
|
Loading: ((props: RouteRenderProps) =>
|
|
153
|
-
loading(this.#argsOf({ ...props, searchParams: {} }) as never)) as never,
|
|
157
|
+
RenderScope.run(() => loading(this.#argsOf({ ...props, searchParams: {} }) as never))) as never,
|
|
154
158
|
}
|
|
155
159
|
: {}),
|
|
156
160
|
};
|
|
@@ -167,7 +171,8 @@ export abstract class RouteDefinition<
|
|
|
167
171
|
}
|
|
168
172
|
|
|
169
173
|
#guardRouteOf(guard: (args: never) => void | Promise<void>) {
|
|
170
|
-
const route = async (props: RouteGuardProps) =>
|
|
174
|
+
const route = async (props: RouteGuardProps) =>
|
|
175
|
+
await RenderScope.run(() => guard(this.#guardArgsOf(props) as never));
|
|
171
176
|
Object.defineProperty(route, routeArgsMarker, { value: this.args.map(({ kind, name }) => ({ kind, name })) });
|
|
172
177
|
return route;
|
|
173
178
|
}
|
|
@@ -178,10 +183,10 @@ export abstract class RouteDefinition<
|
|
|
178
183
|
|
|
179
184
|
const route = async (props: RouteRenderProps) => {
|
|
180
185
|
const args = this.renderArgsOf(this.#argsOf(props));
|
|
181
|
-
if (!(args instanceof Promise)) return await render(args as never);
|
|
186
|
+
if (!(args instanceof Promise)) return await RenderScope.run(() => render(args as never));
|
|
182
187
|
const path = Translator.getRenderPath();
|
|
183
188
|
const resolved = await args;
|
|
184
|
-
return await Translator.renderAt(path, () => render(resolved as never));
|
|
189
|
+
return await Translator.renderAt(path, () => RenderScope.run(() => render(resolved as never)));
|
|
185
190
|
};
|
|
186
191
|
Object.defineProperty(route, routeArgsMarker, { value: this.args.map(({ kind, name }) => ({ kind, name })) });
|
|
187
192
|
this.#renderRoute = { render, argCount: this.args.length, route };
|
package/client/translator.ts
CHANGED
|
@@ -1,67 +1,38 @@
|
|
|
1
1
|
import { interpolateTranslation, parseAkanI18nEnv, pathGetLoose } from "akanjs/common";
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
[key: string]: {
|
|
5
|
-
[key: string]: unknown;
|
|
6
|
-
};
|
|
7
|
-
}
|
|
8
|
-
export interface AllDictionary {
|
|
9
|
-
[key: string]: Dictionary;
|
|
10
|
-
}
|
|
3
|
+
import { type AllDictionary, type Dictionary, TranslatorState } from "./translatorState";
|
|
11
4
|
|
|
12
|
-
|
|
13
|
-
langDictionaryMap: Map<string, Dictionary>;
|
|
14
|
-
|
|
15
|
-
seededDicts: WeakSet<object>;
|
|
16
|
-
replacedDicts: WeakSet<object>;
|
|
17
|
-
|
|
18
|
-
activeLocale?: string;
|
|
19
|
-
|
|
20
|
-
activePath?: string;
|
|
21
|
-
|
|
22
|
-
renderPath?: string;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
const TRANSLATOR_STATE_KEY = "__AKAN_TRANSLATOR_STATE__";
|
|
26
|
-
const getTranslatorState = (): TranslatorState => {
|
|
27
|
-
const globalScope = globalThis as typeof globalThis & {
|
|
28
|
-
[TRANSLATOR_STATE_KEY]?: TranslatorState;
|
|
29
|
-
};
|
|
30
|
-
globalScope[TRANSLATOR_STATE_KEY] ??= {
|
|
31
|
-
langDictionaryMap: new Map<string, Dictionary>(),
|
|
32
|
-
seededDicts: new WeakSet<object>(),
|
|
33
|
-
replacedDicts: new WeakSet<object>(),
|
|
34
|
-
};
|
|
35
|
-
return globalScope[TRANSLATOR_STATE_KEY];
|
|
36
|
-
};
|
|
5
|
+
export type { AllDictionary, Dictionary } from "./translatorState";
|
|
37
6
|
|
|
38
7
|
export class Translator {
|
|
39
|
-
static readonly #filledDicts = new WeakMap<AllDictionary, Map<string, Dictionary>>();
|
|
40
8
|
constructor(dictionary: Record<string, Record<string, Record<string, unknown>>>) {
|
|
41
9
|
Object.entries(dictionary).forEach(([lang, dict]) => {
|
|
42
10
|
Translator.seed(lang, dict as Dictionary);
|
|
43
11
|
});
|
|
44
12
|
}
|
|
45
13
|
hasDictionary(lang: string) {
|
|
46
|
-
return
|
|
14
|
+
return Translator.hasLocale(lang);
|
|
15
|
+
}
|
|
16
|
+
static hasLocale(lang: string) {
|
|
17
|
+
return TranslatorState.get().langDictionaryMap.has(lang);
|
|
47
18
|
}
|
|
48
19
|
static setActiveLocale(lang: string | undefined) {
|
|
49
|
-
if (lang)
|
|
20
|
+
if (lang) TranslatorState.get().activeLocale = lang;
|
|
50
21
|
}
|
|
51
22
|
static getActiveLocale(): string | undefined {
|
|
52
|
-
return
|
|
23
|
+
return TranslatorState.get().activeLocale;
|
|
53
24
|
}
|
|
54
25
|
static setActivePath(path: string | undefined) {
|
|
55
|
-
const state =
|
|
26
|
+
const state = TranslatorState.get();
|
|
56
27
|
if (path) state.activePath = path;
|
|
57
28
|
else delete state.activePath;
|
|
58
29
|
}
|
|
59
30
|
static getActivePath(): string | undefined {
|
|
60
|
-
return
|
|
31
|
+
return TranslatorState.get().activePath;
|
|
61
32
|
}
|
|
62
33
|
static renderAt<T>(path: string | undefined, render: () => T): T {
|
|
63
34
|
if (!path) return render();
|
|
64
|
-
const state =
|
|
35
|
+
const state = TranslatorState.get();
|
|
65
36
|
const outer = state.renderPath;
|
|
66
37
|
state.renderPath = path;
|
|
67
38
|
try {
|
|
@@ -71,17 +42,17 @@ export class Translator {
|
|
|
71
42
|
}
|
|
72
43
|
}
|
|
73
44
|
static getRenderPath(): string | undefined {
|
|
74
|
-
return
|
|
45
|
+
return TranslatorState.get().renderPath;
|
|
75
46
|
}
|
|
76
47
|
static markHydrated() {
|
|
77
|
-
delete
|
|
48
|
+
delete TranslatorState.get().activePath;
|
|
78
49
|
}
|
|
79
50
|
static translateByLocale(lang: string, key: string, param?: Record<string, string | number>): string {
|
|
80
51
|
const msg = Translator.#lookup(lang, key) ?? Translator.#lookupDefault(lang, key) ?? key;
|
|
81
52
|
return interpolateTranslation(msg, param);
|
|
82
53
|
}
|
|
83
54
|
static #lookup(lang: string, key: string) {
|
|
84
|
-
const dictionary =
|
|
55
|
+
const dictionary = TranslatorState.get().langDictionaryMap.get(lang);
|
|
85
56
|
if (!dictionary) return undefined;
|
|
86
57
|
const node = pathGetLoose(key, dictionary, ".") as { t?: unknown } | null;
|
|
87
58
|
return typeof node?.t === "string" ? node.t : undefined;
|
|
@@ -92,16 +63,10 @@ export class Translator {
|
|
|
92
63
|
return defaultLocale === lang ? undefined : Translator.#lookup(defaultLocale, key);
|
|
93
64
|
}
|
|
94
65
|
|
|
95
|
-
static
|
|
96
|
-
const { defaultLocale } = parseAkanI18nEnv();
|
|
66
|
+
static fillFromDefault(allDictionary: AllDictionary, lang: string, defaultLocale: string): Dictionary {
|
|
97
67
|
const fallback = allDictionary[defaultLocale];
|
|
98
|
-
if (lang === defaultLocale || !fallback) return allDictionary[lang];
|
|
99
|
-
|
|
100
|
-
Translator.#filledDicts.set(allDictionary, filledByLocale);
|
|
101
|
-
const filled =
|
|
102
|
-
filledByLocale.get(lang) ?? (Translator.#fillGaps(allDictionary[lang] ?? {}, fallback) as Dictionary);
|
|
103
|
-
filledByLocale.set(lang, filled);
|
|
104
|
-
return filled;
|
|
68
|
+
if (lang === defaultLocale || !fallback) return allDictionary[lang] ?? {};
|
|
69
|
+
return Translator.#fillGaps(allDictionary[lang] ?? {}, fallback) as Dictionary;
|
|
105
70
|
}
|
|
106
71
|
static #fillGaps(own: Record<string, unknown>, fallback: Record<string, unknown>): Record<string, unknown> {
|
|
107
72
|
const filled = { ...own };
|
|
@@ -119,7 +84,7 @@ export class Translator {
|
|
|
119
84
|
|
|
120
85
|
static seed(lang: string, dict: Dictionary | undefined) {
|
|
121
86
|
if (!dict) return;
|
|
122
|
-
const state =
|
|
87
|
+
const state = TranslatorState.get();
|
|
123
88
|
if (state.seededDicts.has(dict)) return;
|
|
124
89
|
state.seededDicts.add(dict);
|
|
125
90
|
const existingDictionary = state.langDictionaryMap.get(lang) ?? {};
|
|
@@ -130,18 +95,13 @@ export class Translator {
|
|
|
130
95
|
state.langDictionaryMap.set(lang, existingDictionary);
|
|
131
96
|
}
|
|
132
97
|
static replace(lang: string, dict: Dictionary | undefined) {
|
|
133
|
-
|
|
134
|
-
const state = getTranslatorState();
|
|
135
|
-
if (state.replacedDicts.has(dict)) return;
|
|
136
|
-
state.seededDicts.add(dict);
|
|
137
|
-
state.replacedDicts.add(dict);
|
|
138
|
-
state.langDictionaryMap.set(lang, { ...dict });
|
|
98
|
+
TranslatorState.replace(lang, dict);
|
|
139
99
|
}
|
|
140
100
|
translate(lang: string, key: string, param?: Record<string, string | number>): string {
|
|
141
101
|
return Translator.translateByLocale(lang, key, param);
|
|
142
102
|
}
|
|
143
103
|
async getDictionary(lang: string) {
|
|
144
|
-
const dictionary =
|
|
104
|
+
const dictionary = TranslatorState.get().langDictionaryMap.get(lang);
|
|
145
105
|
if (!dictionary) throw new Error(`Dictionary for language ${lang} not found`);
|
|
146
106
|
return dictionary;
|
|
147
107
|
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
export interface Dictionary {
|
|
2
|
+
[key: string]: {
|
|
3
|
+
[key: string]: unknown;
|
|
4
|
+
};
|
|
5
|
+
}
|
|
6
|
+
export interface AllDictionary {
|
|
7
|
+
[key: string]: Dictionary;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
interface TranslatorStateValue {
|
|
11
|
+
langDictionaryMap: Map<string, Dictionary>;
|
|
12
|
+
|
|
13
|
+
seededDicts: WeakSet<object>;
|
|
14
|
+
|
|
15
|
+
replacedByLang: Map<string, object>;
|
|
16
|
+
|
|
17
|
+
activeLocale?: string;
|
|
18
|
+
|
|
19
|
+
activePath?: string;
|
|
20
|
+
|
|
21
|
+
renderPath?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const translatorStateKey = "__AKAN_TRANSLATOR_STATE__";
|
|
25
|
+
|
|
26
|
+
export class TranslatorState {
|
|
27
|
+
static get(): TranslatorStateValue {
|
|
28
|
+
const globalScope = globalThis as typeof globalThis & { [translatorStateKey]?: TranslatorStateValue };
|
|
29
|
+
globalScope[translatorStateKey] ??= {
|
|
30
|
+
langDictionaryMap: new Map<string, Dictionary>(),
|
|
31
|
+
seededDicts: new WeakSet<object>(),
|
|
32
|
+
replacedByLang: new Map<string, object>(),
|
|
33
|
+
};
|
|
34
|
+
return globalScope[translatorStateKey];
|
|
35
|
+
}
|
|
36
|
+
static replace(lang: string, dict: Dictionary | undefined) {
|
|
37
|
+
if (!dict) return;
|
|
38
|
+
const state = TranslatorState.get();
|
|
39
|
+
if (state.replacedByLang.get(lang) === dict) return;
|
|
40
|
+
state.seededDicts.add(dict);
|
|
41
|
+
state.replacedByLang.set(lang, dict);
|
|
42
|
+
state.langDictionaryMap.set(lang, { ...dict });
|
|
43
|
+
}
|
|
44
|
+
}
|