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.
Files changed (147) hide show
  1. package/base/primitiveRegistry.ts +3 -0
  2. package/client/cookie.ts +80 -35
  3. package/client/dictionaryAsset.ts +32 -0
  4. package/client/index.ts +1 -0
  5. package/client/makePageProto.tsx +9 -15
  6. package/client/native.ts +1 -22
  7. package/client/nativeHost.ts +22 -0
  8. package/client/route/LayoutDefinition.ts +3 -2
  9. package/client/route/RouteDefinition.ts +10 -5
  10. package/client/translator.ts +20 -60
  11. package/client/translatorState.ts +44 -0
  12. package/client/types.ts +1 -0
  13. package/common/IpLiteral.ts +75 -0
  14. package/common/TrustedProxy.ts +100 -71
  15. package/common/appHosts.ts +32 -0
  16. package/common/authCookie.ts +8 -5
  17. package/common/authToken.ts +24 -6
  18. package/common/clientAddress.ts +22 -11
  19. package/common/cookieJar.ts +90 -0
  20. package/common/cookieSweep.ts +77 -0
  21. package/common/index.ts +14 -1
  22. package/common/remoteAuthCode.ts +55 -0
  23. package/common/requestLocale.ts +28 -0
  24. package/common/sessionCookies.ts +43 -0
  25. package/common/types.ts +5 -0
  26. package/constant/constantRegistry.ts +33 -6
  27. package/constant/refNameClaims.ts +99 -0
  28. package/dictionary/base.dictionary.ts +5 -0
  29. package/document/databaseRegistry.ts +2 -2
  30. package/document/filterMeta.ts +2 -2
  31. package/fetch/client/fetchClient.ts +69 -25
  32. package/fetch/client/wsClient.ts +21 -4
  33. package/fetch/index.ts +1 -0
  34. package/fetch/renderScope.ts +54 -0
  35. package/fetch/requestStorage.ts +19 -9
  36. package/native/api.ts +1 -0
  37. package/native/config.ts +1 -0
  38. package/package.json +11 -1
  39. package/server/akanApp.ts +12 -11
  40. package/server/akanAppHeaders.ts +16 -14
  41. package/server/akanServer.ts +26 -4
  42. package/server/cachePolicy.ts +5 -3
  43. package/server/contentEncoding.ts +164 -26
  44. package/server/di/diLifecycle.ts +2 -0
  45. package/server/hmr/devArtifactPruner.ts +13 -3
  46. package/server/hmr/devHmrController.ts +1 -0
  47. package/server/imageOptimizer.ts +3 -1
  48. package/server/lifecycle/runtimeDir.ts +16 -1
  49. package/server/mcp/McpRouter.ts +54 -0
  50. package/server/ops/statusAccess.ts +59 -0
  51. package/server/routeState.ts +1 -0
  52. package/server/routing/apiRouter.ts +88 -4
  53. package/server/routing/appWsData.ts +11 -6
  54. package/server/routing/routeHosts.ts +31 -0
  55. package/server/routing/soloAppRoutes.ts +7 -4
  56. package/server/rscClient.tsx +31 -16
  57. package/server/rscPagePrompts.ts +7 -3
  58. package/server/rscWorker.tsx +3 -1
  59. package/server/ssrFromRscRenderer.tsx +11 -9
  60. package/server/ssrTypes.ts +2 -0
  61. package/server/types.tsx +7 -0
  62. package/server/vendor/tailwind-merge.ts +1 -0
  63. package/server/webRouter.ts +82 -22
  64. package/service/serve.ts +6 -3
  65. package/signal/endpointIdempotency.ts +3 -3
  66. package/signal/mcp/McpDocument.ts +25 -5
  67. package/signal/serializer/fetch.serializer.ts +1 -1
  68. package/signal/signalContext.ts +22 -10
  69. package/signal/slice.ts +19 -8
  70. package/store/storeInstance.ts +6 -5
  71. package/test/ServedMcpCatalogue.ts +44 -0
  72. package/test/index.ts +9 -1
  73. package/test/signalTestRuntime.ts +11 -1
  74. package/test/testServer.ts +21 -0
  75. package/types/base/primitiveRegistry.d.ts +1 -0
  76. package/types/client/cookie.d.ts +7 -7
  77. package/types/client/dictionaryAsset.d.ts +4 -0
  78. package/types/client/index.d.ts +1 -0
  79. package/types/client/native.d.ts +1 -8
  80. package/types/client/nativeHost.d.ts +8 -0
  81. package/types/client/translator.d.ts +4 -9
  82. package/types/client/translatorState.d.ts +21 -0
  83. package/types/client/types.d.ts +1 -0
  84. package/types/common/IpLiteral.d.ts +6 -0
  85. package/types/common/TrustedProxy.d.ts +6 -0
  86. package/types/common/appHosts.d.ts +7 -0
  87. package/types/common/authCookie.d.ts +3 -2
  88. package/types/common/authToken.d.ts +13 -0
  89. package/types/common/clientAddress.d.ts +6 -2
  90. package/types/common/cookieJar.d.ts +22 -0
  91. package/types/common/cookieSweep.d.ts +12 -0
  92. package/types/common/index.d.ts +7 -1
  93. package/types/common/remoteAuthCode.d.ts +9 -0
  94. package/types/common/requestLocale.d.ts +13 -0
  95. package/types/common/sessionCookies.d.ts +13 -0
  96. package/types/common/types.d.ts +5 -0
  97. package/types/constant/constantRegistry.d.ts +2 -1
  98. package/types/constant/refNameClaims.d.ts +12 -0
  99. package/types/dictionary/base.dictionary.d.ts +1 -1
  100. package/types/dictionary/dictionary.d.ts +8 -8
  101. package/types/document/filterMeta.d.ts +2 -2
  102. package/types/fetch/agentTurn.d.ts +1 -1
  103. package/types/fetch/client/fetchClient.d.ts +1 -0
  104. package/types/fetch/client/wsClient.d.ts +2 -0
  105. package/types/fetch/index.d.ts +1 -0
  106. package/types/fetch/renderScope.d.ts +18 -0
  107. package/types/fetch/requestStorage.d.ts +10 -5
  108. package/types/native/api.d.ts +1 -0
  109. package/types/native/config.d.ts +1 -0
  110. package/types/server/akanServer.d.ts +10 -1
  111. package/types/server/cachePolicy.d.ts +1 -1
  112. package/types/server/contentEncoding.d.ts +16 -4
  113. package/types/server/hmr/devArtifactPruner.d.ts +1 -0
  114. package/types/server/mcp/McpRouter.d.ts +16 -1
  115. package/types/server/ops/statusAccess.d.ts +15 -0
  116. package/types/server/routeState.d.ts +1 -0
  117. package/types/server/routing/appWsData.d.ts +4 -0
  118. package/types/server/routing/routeHosts.d.ts +4 -0
  119. package/types/server/routing/soloAppRoutes.d.ts +2 -1
  120. package/types/server/rscClient.d.ts +1 -0
  121. package/types/server/ssrTypes.d.ts +2 -0
  122. package/types/server/types.d.ts +6 -0
  123. package/types/server/vendor/tailwind-merge.d.ts +1 -0
  124. package/types/signal/mcp/McpDocument.d.ts +8 -0
  125. package/types/signal/signalContext.d.ts +6 -4
  126. package/types/signal/slice.d.ts +2 -1
  127. package/types/test/ServedMcpCatalogue.d.ts +15 -0
  128. package/types/test/index.d.ts +3 -2
  129. package/types/test/signalTestRuntime.d.ts +8 -1
  130. package/types/test/testServer.d.ts +15 -0
  131. package/types/ui/System/Client.d.ts +7 -5
  132. package/types/ui/System/Common.d.ts +1 -4
  133. package/types/ui/System/SSR.d.ts +2 -2
  134. package/types/ui/index.d.ts +2 -0
  135. package/types/ui/recipe/badgeRecipe.d.ts +2 -2
  136. package/types/ui/recipe/buttonRecipe.d.ts +2 -2
  137. package/types/ui/recipe/factory.d.ts +2 -1
  138. package/types/ui/recipe/inputRecipe.d.ts +2 -2
  139. package/ui/Clipboard.tsx +3 -1
  140. package/ui/Link/Anchor.tsx +1 -2
  141. package/ui/System/CSR.tsx +4 -2
  142. package/ui/System/Client.tsx +25 -17
  143. package/ui/System/Common.tsx +1 -5
  144. package/ui/System/SSR.tsx +4 -5
  145. package/ui/fontCss.ts +1 -1
  146. package/ui/index.ts +2 -0
  147. package/ui/recipe/factory.ts +17 -4
@@ -1,3 +1,6 @@
1
+
2
+
3
+ import "dayjs/locale/ko";
1
4
  import dayjsLib, { type Dayjs } from "dayjs";
2
5
  import { CLIENT_VALUE, DEFAULT_VALUE, EXAMPLE_VALUE, PURIFIED_VALUE, SERVER_VALUE } from "./symbols";
3
6
  import type { Cls } from "./types";
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
- readAuthToken,
12
+ readSessionToken,
13
+ retiredAuthTokenKeys,
14
+ SessionCookies,
10
15
  } from "akanjs/common";
11
16
  import type { Account } from "akanjs/fetch";
12
- import { parseCookieHeader, cookies as serverCookies, headers as serverHeaders } from "akanjs/fetch";
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 cookieJarKey = "akan:cookies";
28
- const pageKeepsCookies = () => (globalThis as { location?: Location }).location?.protocol !== "app:";
29
- const readCookieJar = (): Record<string, string> => {
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(cookieJarKey) ?? "{}") as Record<string, string>;
37
+ return JSON.parse(localStorage.getItem(storedCookiesKey) ?? "{}") as Record<string, string>;
32
38
  } catch {
33
39
  return {};
34
40
  }
35
41
  };
36
- const writeCookieJar = (jar: Record<string, string>) => {
37
- localStorage.setItem(cookieJarKey, JSON.stringify(jar));
42
+ const writeStoredCookies = (stored: Record<string, string>) => {
43
+ localStorage.setItem(storedCookiesKey, JSON.stringify(stored));
38
44
  };
39
45
 
40
- export const cookies = (): Map<string, { name: string; value: string }> => {
41
- if (getEnv().side === "server") return serverCookies();
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 new Map(Object.entries(readCookieJar()).map(([name, value]) => [name, { name, value }]));
44
- return parseCookieHeader(document.cookie);
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
- writeCookieJar({ ...readCookieJar(), [key]: value });
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
- /** Reads through `cookies()` on both sides: a hand-rolled `split("=")` cuts base64 padding and skips the `j:` form. */
64
- export const getCookie = (key: string): string | undefined => cookies().get(key)?.value;
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 } = readCookieJar();
71
- writeCookieJar(rest);
84
+ const { [key]: _removed, ...rest } = readStoredCookies();
85
+ writeStoredCookies(rest);
72
86
  return;
73
87
  }
74
88
 
75
- document.cookie = `${key}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=${options.path};`;
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 jar, under the app-scoped key or a legacy global one. */
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) ?? readAuthToken(getCookie);
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
- const scoped = await secretStorage.getItem(authTokenKey());
105
- if (scoped) return scoped;
106
- const legacy = await secretStorage.getItem(legacyAuthTokenKey);
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(authTokenKey(), jwt);
153
+ void secretStorage.setItem(storedAuthTokenKey(), jwt);
140
154
 
141
- removeCookie(legacyAuthTokenKey);
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
- const stored = getAuthToken();
151
- const token = jwt ?? stored;
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 (token === stored) resetAuth();
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 from cookie" : "not found in cookie"}`);
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(legacyAuthTokenKey);
167
- void secretStorage.removeItem(authTokenKey());
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
@@ -11,6 +11,7 @@ export * from "./href";
11
11
  export * from "./locale";
12
12
  export * from "./makePageProto";
13
13
  export * from "./mcpView";
14
+ export * from "./nativeHost";
14
15
  export * from "./resolveServerUrl";
15
16
  export * from "./route";
16
17
  export * from "./router";
@@ -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
- if (req) {
45
- const urlPath = new URL(req.url).pathname;
46
- const [, firstSegment = "", ...rest] = urlPath.split("/");
47
- if (!localeHeader && !localeSet.has(firstSegment)) return { locale: defaultLocale, path: pathHeader ?? urlPath };
48
- return {
49
- locale: localeHeader ?? (firstSegment || defaultLocale),
50
- path: pathHeader ?? `/${rest.join("/")}`,
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
- ? { generateHead: async (props: RouteRenderProps) => await head(this.#argsOf(props) as never) }
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) => await guard(this.#guardArgsOf(props) as never);
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 };
@@ -1,67 +1,38 @@
1
1
  import { interpolateTranslation, parseAkanI18nEnv, pathGetLoose } from "akanjs/common";
2
2
 
3
- export interface Dictionary {
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
- interface TranslatorState {
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 getTranslatorState().langDictionaryMap.has(lang);
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) getTranslatorState().activeLocale = lang;
20
+ if (lang) TranslatorState.get().activeLocale = lang;
50
21
  }
51
22
  static getActiveLocale(): string | undefined {
52
- return getTranslatorState().activeLocale;
23
+ return TranslatorState.get().activeLocale;
53
24
  }
54
25
  static setActivePath(path: string | undefined) {
55
- const state = getTranslatorState();
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 getTranslatorState().activePath;
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 = getTranslatorState();
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 getTranslatorState().renderPath;
45
+ return TranslatorState.get().renderPath;
75
46
  }
76
47
  static markHydrated() {
77
- delete getTranslatorState().activePath;
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 = getTranslatorState().langDictionaryMap.get(lang);
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 withDefaultLocale(allDictionary: AllDictionary, lang: string): Dictionary | undefined {
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
- const filledByLocale = Translator.#filledDicts.get(allDictionary) ?? new Map<string, Dictionary>();
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 = getTranslatorState();
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
- if (!dict) return;
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 = getTranslatorState().langDictionaryMap.get(lang);
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
+ }
package/client/types.ts CHANGED
@@ -16,6 +16,7 @@ export interface ReactFontPath {
16
16
  src: string;
17
17
  weight: number | string;
18
18
  style?: ReactFontStyle;
19
+ preload?: boolean;
19
20
  declarations?: ReactFontDeclaration[];
20
21
  }
21
22