akanjs 3.1.0 → 3.1.1-rc.0
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/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/translator.ts +20 -60
- package/client/translatorState.ts +44 -0
- package/client/types.ts +1 -0
- package/common/index.ts +1 -0
- package/common/requestLocale.ts +28 -0
- package/dictionary/base.dictionary.ts +1 -0
- package/fetch/client/fetchClient.ts +4 -0
- package/fetch/client/wsClient.ts +21 -4
- package/native/api.ts +1 -0
- package/native/config.ts +1 -0
- package/package.json +11 -1
- package/server/akanApp.ts +8 -9
- package/server/akanAppHeaders.ts +1 -1
- package/server/contentEncoding.ts +164 -26
- package/server/hmr/devArtifactPruner.ts +13 -3
- package/server/hmr/devHmrController.ts +1 -0
- package/server/imageOptimizer.ts +3 -1
- package/server/routeState.ts +1 -0
- package/server/routing/apiRouter.ts +17 -2
- package/server/rscClient.tsx +25 -12
- package/server/ssrFromRscRenderer.tsx +6 -6
- package/server/ssrTypes.ts +2 -0
- package/server/types.tsx +7 -0
- package/server/vendor/tailwind-merge.ts +1 -0
- package/server/webRouter.ts +77 -20
- package/store/storeInstance.ts +6 -5
- package/types/base/primitiveRegistry.d.ts +1 -0
- 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/index.d.ts +1 -0
- package/types/common/requestLocale.d.ts +13 -0
- package/types/dictionary/base.dictionary.d.ts +1 -1
- package/types/dictionary/dictionary.d.ts +8 -8
- package/types/fetch/client/fetchClient.d.ts +1 -0
- package/types/fetch/client/wsClient.d.ts +2 -0
- package/types/native/api.d.ts +1 -0
- package/types/native/config.d.ts +1 -0
- package/types/server/contentEncoding.d.ts +16 -4
- package/types/server/hmr/devArtifactPruner.d.ts +1 -0
- package/types/server/routeState.d.ts +1 -0
- 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/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 +23 -14
- 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
|
@@ -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
|
+
};
|
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
|
+
}
|
package/client/types.ts
CHANGED
package/common/index.ts
CHANGED
|
@@ -163,6 +163,7 @@ export {
|
|
|
163
163
|
} from "./pipelinePlan";
|
|
164
164
|
export { type RouteLayer, RouteLayering } from "./RouteLayering";
|
|
165
165
|
export { randomPick, randomPicks } from "./randomPick";
|
|
166
|
+
export { RequestLocale, type RequestLocaleInput } from "./requestLocale";
|
|
166
167
|
export { hostFromRequest, isJsonContentType, originFromRequest } from "./requestOrigin";
|
|
167
168
|
export { RestClient, type RestClientOptions, type RestRequestOptions } from "./restClient";
|
|
168
169
|
export { round } from "./round";
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { AkanI18nConfig } from "./localeConfig";
|
|
2
|
+
|
|
3
|
+
export interface RequestLocaleInput {
|
|
4
|
+
localeHeader?: string | null;
|
|
5
|
+
pathHeader?: string | null;
|
|
6
|
+
/** The request URL's pathname; absent outside a request. */
|
|
7
|
+
pathname?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export class RequestLocale {
|
|
11
|
+
static resolve(
|
|
12
|
+
{ localeHeader: rawLocaleHeader, pathHeader, pathname }: RequestLocaleInput,
|
|
13
|
+
{ defaultLocale, locales }: AkanI18nConfig,
|
|
14
|
+
): { locale: string; path: string } {
|
|
15
|
+
|
|
16
|
+
const localeHeader = rawLocaleHeader && locales.includes(rawLocaleHeader) ? rawLocaleHeader : null;
|
|
17
|
+
|
|
18
|
+
if (localeHeader && pathHeader) return { locale: localeHeader, path: pathHeader };
|
|
19
|
+
if (pathname === undefined) return { locale: localeHeader ?? defaultLocale, path: pathHeader ?? "/" };
|
|
20
|
+
const [, firstSegment = "", ...rest] = pathname.split("/");
|
|
21
|
+
if (!localeHeader && !locales.includes(firstSegment))
|
|
22
|
+
return { locale: defaultLocale, path: pathHeader ?? pathname };
|
|
23
|
+
return {
|
|
24
|
+
locale: localeHeader ?? (firstSegment || defaultLocale),
|
|
25
|
+
path: pathHeader ?? `/${rest.join("/")}`,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -99,6 +99,7 @@ export const baseDictionary = serviceDictionary(["en", "ko"])
|
|
|
99
99
|
ok: ["OK", "확인"],
|
|
100
100
|
cancel: ["Cancel", "취소"],
|
|
101
101
|
retry: ["Try again", "다시 시도"],
|
|
102
|
+
copy: ["Copy", "복사"],
|
|
102
103
|
pageLoadFailed: ["This page could not be loaded.", "이 화면을 불러오지 못했습니다."],
|
|
103
104
|
pageNotFound: ["This page does not exist.", "없는 화면입니다."],
|
|
104
105
|
goBack: ["Go back", "돌아가기"],
|
|
@@ -302,6 +302,10 @@ export class FetchClient {
|
|
|
302
302
|
if (FetchClient.bridged) return;
|
|
303
303
|
this.ws.connect();
|
|
304
304
|
}
|
|
305
|
+
connectOnUse() {
|
|
306
|
+
if (FetchClient.bridged) return;
|
|
307
|
+
this.ws.connectOnUse();
|
|
308
|
+
}
|
|
305
309
|
disconnect() {
|
|
306
310
|
this.ws.destroy();
|
|
307
311
|
for (const ws of this.#originWs.values()) ws.destroy();
|
package/fetch/client/wsClient.ts
CHANGED
|
@@ -85,6 +85,7 @@ export class WsClient {
|
|
|
85
85
|
#listenerMap = new Map<string, Set<Listener>>();
|
|
86
86
|
#destroyed = false;
|
|
87
87
|
#connectRequested = false;
|
|
88
|
+
#connectOnUse = false;
|
|
88
89
|
/** Whether this client has been connected before, so the first open is a start rather than a recovery. */
|
|
89
90
|
#hadConnection = false;
|
|
90
91
|
#outbox: string[] = [];
|
|
@@ -137,6 +138,12 @@ export class WsClient {
|
|
|
137
138
|
this.#ws?.send(JSON.stringify(websocketAuthContract.makeRequest(this.#jwt)));
|
|
138
139
|
}
|
|
139
140
|
|
|
141
|
+
/** Opens the socket at the first emit or subscribe, so a page that never uses it never opens one. */
|
|
142
|
+
connectOnUse() {
|
|
143
|
+
this.#connectOnUse = true;
|
|
144
|
+
if (this.#outbox.length > 0 || this.#roomSubscribeMap.size > 0) this.connect();
|
|
145
|
+
}
|
|
146
|
+
|
|
140
147
|
connect() {
|
|
141
148
|
this.#connectRequested = true;
|
|
142
149
|
if (this.#ws && this.#ws.readyState !== WebSocket.CLOSED) return;
|
|
@@ -182,6 +189,7 @@ export class WsClient {
|
|
|
182
189
|
const queued = this.#outbox;
|
|
183
190
|
this.#outbox = [];
|
|
184
191
|
for (const frame of queued) this.#ws?.send(frame);
|
|
192
|
+
this.#handleMessage("connect", undefined);
|
|
185
193
|
};
|
|
186
194
|
this.#ws.onmessage = (e) => {
|
|
187
195
|
this.#lastInboundAt = Date.now();
|
|
@@ -259,7 +267,10 @@ export class WsClient {
|
|
|
259
267
|
this.#stopHeartbeat();
|
|
260
268
|
|
|
261
269
|
if (openedAt && Date.now() - openedAt >= WsClient.stableMs) this.#reconnectAttempts = 0;
|
|
262
|
-
if (
|
|
270
|
+
if (this.#destroyed) return;
|
|
271
|
+
this.#closed();
|
|
272
|
+
|
|
273
|
+
this.#handleMessage("disconnect", { code: event.code, reason: event.reason });
|
|
263
274
|
};
|
|
264
275
|
}
|
|
265
276
|
|
|
@@ -401,6 +412,7 @@ export class WsClient {
|
|
|
401
412
|
this.logger.debug(`WebSocket destroying`);
|
|
402
413
|
this.#destroyed = true;
|
|
403
414
|
this.#connectRequested = false;
|
|
415
|
+
this.#connectOnUse = false;
|
|
404
416
|
this.#stopHeartbeat();
|
|
405
417
|
this.#cancelReconnect();
|
|
406
418
|
for (const timer of this.#unconnectedWarnTimers.values()) clearTimeout(timer);
|
|
@@ -444,6 +456,11 @@ export class WsClient {
|
|
|
444
456
|
hasListeners(key: string) {
|
|
445
457
|
return (this.#listenerMap.get(key)?.size ?? 0) > 0 || (this.#roomSubscribeMap.get(key)?.listener.size ?? 0) > 0;
|
|
446
458
|
}
|
|
459
|
+
#requireConnection(action: "emit" | "subscribe", key: string) {
|
|
460
|
+
if (this.#connectRequested) return;
|
|
461
|
+
if (this.#connectOnUse) this.connect();
|
|
462
|
+
else this.#warnUnconnected(action, key);
|
|
463
|
+
}
|
|
447
464
|
#warnUnconnected(action: "emit" | "subscribe", key: string) {
|
|
448
465
|
const timerKey = `${action}:${key}`;
|
|
449
466
|
if (this.#connectRequested || this.#unconnectedWarnTimers.has(timerKey)) return;
|
|
@@ -451,7 +468,7 @@ export class WsClient {
|
|
|
451
468
|
this.#unconnectedWarnTimers.delete(timerKey);
|
|
452
469
|
if (this.#connectRequested || this.#destroyed) return;
|
|
453
470
|
console.warn(
|
|
454
|
-
`[akanjs] WebSocket is not connected. Call fetch.instance.connect(), or drop the root layout
|
|
471
|
+
`[akanjs] WebSocket is not connected. Call fetch.instance.connect(), or drop the root layout's .wsConnect(false), before ${action} "${key}".`,
|
|
455
472
|
);
|
|
456
473
|
}, 0);
|
|
457
474
|
this.#unconnectedWarnTimers.set(timerKey, timer);
|
|
@@ -462,7 +479,7 @@ export class WsClient {
|
|
|
462
479
|
|
|
463
480
|
if (this.#ws?.readyState !== WebSocket.OPEN) {
|
|
464
481
|
this.#outbox.push(frame);
|
|
465
|
-
this.#
|
|
482
|
+
this.#requireConnection("emit", key);
|
|
466
483
|
return this;
|
|
467
484
|
}
|
|
468
485
|
this.#ws.send(frame);
|
|
@@ -470,7 +487,6 @@ export class WsClient {
|
|
|
470
487
|
}
|
|
471
488
|
subscribe(option: RoomSubscription) {
|
|
472
489
|
const roomId = WsClient.makeRoomId(option.key, option.data);
|
|
473
|
-
if (!this.#ws) this.#warnUnconnected("subscribe", option.key);
|
|
474
490
|
let roomSubscribe = this.#roomSubscribeMap.get(roomId);
|
|
475
491
|
if (!roomSubscribe) {
|
|
476
492
|
roomSubscribe = {
|
|
@@ -487,6 +503,7 @@ export class WsClient {
|
|
|
487
503
|
}
|
|
488
504
|
roomSubscribe.listener.add(option.handleEvent);
|
|
489
505
|
if (option.handleResync) roomSubscribe.resync.add(option.handleResync);
|
|
506
|
+
if (!this.#ws) this.#requireConnection("subscribe", option.key);
|
|
490
507
|
this.logger.verbose(`Websocket subscribe pubsub for ${roomId} - ${roomSubscribe.listener.size} listeners added`);
|
|
491
508
|
return this;
|
|
492
509
|
}
|
package/native/api.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "../vendor/@akanjs/native/packages/cli/src/api.ts";
|
package/native/config.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "../vendor/@akanjs/native/packages/cli/src/config.ts";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "akanjs",
|
|
3
|
-
"version": "3.1.0",
|
|
3
|
+
"version": "3.1.1-rc.0",
|
|
4
4
|
"sourceType": "module",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"publishConfig": {
|
|
@@ -100,6 +100,16 @@
|
|
|
100
100
|
"import": "./client/native/*.ts",
|
|
101
101
|
"default": "./client/native/*.ts"
|
|
102
102
|
},
|
|
103
|
+
"./native/api": {
|
|
104
|
+
"types": "./types/native/api.d.ts",
|
|
105
|
+
"import": "./native/api.ts",
|
|
106
|
+
"default": "./native/api.ts"
|
|
107
|
+
},
|
|
108
|
+
"./native/config": {
|
|
109
|
+
"types": "./types/native/config.d.ts",
|
|
110
|
+
"import": "./native/config.ts",
|
|
111
|
+
"default": "./native/config.ts"
|
|
112
|
+
},
|
|
103
113
|
"./native/desktop": {
|
|
104
114
|
"types": "./types/native/desktop.d.ts",
|
|
105
115
|
"import": "./native/desktop.ts",
|
package/server/akanApp.ts
CHANGED
|
@@ -9,7 +9,7 @@ import { isTraceEnabled } from "../signal/trace";
|
|
|
9
9
|
import { AKAN_CHILD_HOST, makeAkanChildProxyHeaders } from "./akanAppHeaders";
|
|
10
10
|
import type { BuilderCsrReq, BuilderCsrRes, BuilderMessage, BuilderReq, BuilderRes } from "./artifact";
|
|
11
11
|
import { type AssetHeaderOptions, assetHeaders } from "./assetHeaders";
|
|
12
|
-
import { compressResponse, encodedFileResponse } from "./contentEncoding";
|
|
12
|
+
import { compressResponse, compressStreamResponse, encodedFileResponse } from "./contentEncoding";
|
|
13
13
|
import { isPortInUseError } from "./lifecycle/portInUse";
|
|
14
14
|
import { resolveRuntimeDir } from "./lifecycle/runtimeDir";
|
|
15
15
|
import { ChildOutputReader } from "./logging/childOutputReader";
|
|
@@ -636,8 +636,9 @@ export class AkanApp {
|
|
|
636
636
|
|
|
637
637
|
static readonly #immutableArtifacts = [
|
|
638
638
|
{ prefix: "/_akan/client/", root: "client", strip: "/_akan/client/".length, type: "application/javascript" },
|
|
639
|
-
{ prefix: "/_akan/styles/", root: "", strip: "/_akan/".length, type: "text/css; charset=utf-8" },
|
|
640
|
-
{ prefix: "/_akan/fonts/", root: "", strip: "/_akan/".length, type: "font/woff2", anyOrigin: true },
|
|
639
|
+
{ prefix: "/_akan/styles/", root: "styles", strip: "/_akan/styles/".length, type: "text/css; charset=utf-8" },
|
|
640
|
+
{ prefix: "/_akan/fonts/", root: "fonts", strip: "/_akan/fonts/".length, type: "font/woff2", anyOrigin: true },
|
|
641
|
+
{ prefix: "/_akan/dict/", root: "dict", strip: "/_akan/dict/".length, type: "text/javascript; charset=utf-8" },
|
|
641
642
|
] as const;
|
|
642
643
|
|
|
643
644
|
async #serveImmutableArtifact(req: Request, url: URL): Promise<Response | null> {
|
|
@@ -787,6 +788,7 @@ export class AkanApp {
|
|
|
787
788
|
body: req.method === "GET" || req.method === "HEAD" ? undefined : req.body,
|
|
788
789
|
signal: req.signal,
|
|
789
790
|
redirect: "manual",
|
|
791
|
+
decompress: false,
|
|
790
792
|
});
|
|
791
793
|
return await this.#proxyResponse(req, upstreamRes);
|
|
792
794
|
} catch (error) {
|
|
@@ -957,11 +959,6 @@ export class AkanApp {
|
|
|
957
959
|
|
|
958
960
|
async #proxyResponse(req: Request, upstreamRes: Response): Promise<Response> {
|
|
959
961
|
const headers = new Headers(upstreamRes.headers);
|
|
960
|
-
|
|
961
|
-
if (headers.has("content-encoding")) {
|
|
962
|
-
headers.delete("content-encoding");
|
|
963
|
-
headers.delete("content-length");
|
|
964
|
-
}
|
|
965
962
|
this.#rewriteInternalLocation(headers);
|
|
966
963
|
const proxied = new Response(upstreamRes.body, {
|
|
967
964
|
status: upstreamRes.status,
|
|
@@ -969,7 +966,9 @@ export class AkanApp {
|
|
|
969
966
|
headers,
|
|
970
967
|
});
|
|
971
968
|
|
|
972
|
-
return AkanApp.#isProxiedJson(headers)
|
|
969
|
+
return AkanApp.#isProxiedJson(headers)
|
|
970
|
+
? await compressResponse(req, proxied)
|
|
971
|
+
: compressStreamResponse(req, proxied);
|
|
973
972
|
}
|
|
974
973
|
|
|
975
974
|
static #isProxiedJson(headers: Headers): boolean {
|
package/server/akanAppHeaders.ts
CHANGED
|
@@ -43,7 +43,7 @@ export function makeAkanChildProxyHeaders(req: Request, childIdx: number, peer?:
|
|
|
43
43
|
);
|
|
44
44
|
headers.set("x-akan-child-idx", String(childIdx));
|
|
45
45
|
|
|
46
|
-
headers.set("accept-encoding", "identity");
|
|
46
|
+
if (!headers.get("accept-encoding")) headers.set("accept-encoding", "identity");
|
|
47
47
|
if (!headers.has("x-request-id") && process.env.AKAN_BENCH_SKIP_REQUEST_ID !== "1") {
|
|
48
48
|
headers.set("x-request-id", crypto.randomUUID());
|
|
49
49
|
}
|