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.
Files changed (72) hide show
  1. package/base/primitiveRegistry.ts +3 -0
  2. package/client/dictionaryAsset.ts +32 -0
  3. package/client/index.ts +1 -0
  4. package/client/makePageProto.tsx +9 -15
  5. package/client/native.ts +1 -22
  6. package/client/nativeHost.ts +22 -0
  7. package/client/translator.ts +20 -60
  8. package/client/translatorState.ts +44 -0
  9. package/client/types.ts +1 -0
  10. package/common/index.ts +1 -0
  11. package/common/requestLocale.ts +28 -0
  12. package/dictionary/base.dictionary.ts +1 -0
  13. package/fetch/client/fetchClient.ts +4 -0
  14. package/fetch/client/wsClient.ts +21 -4
  15. package/native/api.ts +1 -0
  16. package/native/config.ts +1 -0
  17. package/package.json +11 -1
  18. package/server/akanApp.ts +8 -9
  19. package/server/akanAppHeaders.ts +1 -1
  20. package/server/contentEncoding.ts +164 -26
  21. package/server/hmr/devArtifactPruner.ts +13 -3
  22. package/server/hmr/devHmrController.ts +1 -0
  23. package/server/imageOptimizer.ts +3 -1
  24. package/server/routeState.ts +1 -0
  25. package/server/routing/apiRouter.ts +17 -2
  26. package/server/rscClient.tsx +25 -12
  27. package/server/ssrFromRscRenderer.tsx +6 -6
  28. package/server/ssrTypes.ts +2 -0
  29. package/server/types.tsx +7 -0
  30. package/server/vendor/tailwind-merge.ts +1 -0
  31. package/server/webRouter.ts +77 -20
  32. package/store/storeInstance.ts +6 -5
  33. package/types/base/primitiveRegistry.d.ts +1 -0
  34. package/types/client/dictionaryAsset.d.ts +4 -0
  35. package/types/client/index.d.ts +1 -0
  36. package/types/client/native.d.ts +1 -8
  37. package/types/client/nativeHost.d.ts +8 -0
  38. package/types/client/translator.d.ts +4 -9
  39. package/types/client/translatorState.d.ts +21 -0
  40. package/types/client/types.d.ts +1 -0
  41. package/types/common/index.d.ts +1 -0
  42. package/types/common/requestLocale.d.ts +13 -0
  43. package/types/dictionary/base.dictionary.d.ts +1 -1
  44. package/types/dictionary/dictionary.d.ts +8 -8
  45. package/types/fetch/client/fetchClient.d.ts +1 -0
  46. package/types/fetch/client/wsClient.d.ts +2 -0
  47. package/types/native/api.d.ts +1 -0
  48. package/types/native/config.d.ts +1 -0
  49. package/types/server/contentEncoding.d.ts +16 -4
  50. package/types/server/hmr/devArtifactPruner.d.ts +1 -0
  51. package/types/server/routeState.d.ts +1 -0
  52. package/types/server/rscClient.d.ts +1 -0
  53. package/types/server/ssrTypes.d.ts +2 -0
  54. package/types/server/types.d.ts +6 -0
  55. package/types/server/vendor/tailwind-merge.d.ts +1 -0
  56. package/types/ui/System/Client.d.ts +7 -5
  57. package/types/ui/System/Common.d.ts +1 -4
  58. package/types/ui/System/SSR.d.ts +2 -2
  59. package/types/ui/index.d.ts +2 -0
  60. package/types/ui/recipe/badgeRecipe.d.ts +2 -2
  61. package/types/ui/recipe/buttonRecipe.d.ts +2 -2
  62. package/types/ui/recipe/factory.d.ts +2 -1
  63. package/types/ui/recipe/inputRecipe.d.ts +2 -2
  64. package/ui/Clipboard.tsx +3 -1
  65. package/ui/Link/Anchor.tsx +1 -2
  66. package/ui/System/CSR.tsx +4 -2
  67. package/ui/System/Client.tsx +23 -14
  68. package/ui/System/Common.tsx +1 -5
  69. package/ui/System/SSR.tsx +4 -5
  70. package/ui/fontCss.ts +1 -1
  71. package/ui/index.ts +2 -0
  72. 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";
@@ -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,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
 
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();
@@ -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 (!this.#destroyed) this.#closed();
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 "wsConnect = false", before ${action} "${key}".`,
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.#warnUnconnected("emit", key);
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";
@@ -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) ? await compressResponse(req, proxied) : proxied;
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 {
@@ -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
  }