akanjs 2.1.2-rc.1 → 2.2.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,8 @@
1
+ # akanjs
2
+
3
+ ## 2.2.0
4
+
5
+ ### Minor Changes
6
+
7
+ - cb5b07a: enable custom not found and error render on \_layout.tsx files
8
+ - 258284e: initial js bundle size is optimized as single language dictionary on ssr
@@ -1,6 +1,9 @@
1
+ import { existsSync, readdirSync, statSync } from "node:fs";
1
2
  import os from "node:os";
3
+ import path from "node:path";
2
4
  import type { CapacitorConfig } from "@capacitor/cli";
3
5
  import type { AkanMobileTargetConfig, AppScanResult } from "akanjs";
6
+ import { parseAkanI18nEnv } from "akanjs/common";
4
7
 
5
8
  const getLocalIP = () => {
6
9
  const interfaces = os.networkInterfaces();
@@ -16,6 +19,39 @@ const getLocalIP = () => {
16
19
 
17
20
  const normalizeBasePath = (basePath: string | undefined) => basePath?.replace(/^\/+|\/+$/g, "");
18
21
 
22
+ const findAppsDir = (appName: string) => {
23
+ let dir = process.cwd();
24
+ while (dir !== path.dirname(dir)) {
25
+ const appsDir = path.join(dir, "apps");
26
+ if (existsSync(path.join(appsDir, appName, "akan.config.ts"))) return appsDir;
27
+ if (path.basename(dir) === appName && existsSync(path.join(dir, "akan.config.ts"))) return path.dirname(dir);
28
+ dir = path.dirname(dir);
29
+ }
30
+ };
31
+
32
+ const getAppNames = (appsDir: string, maxDepth = 3, prefix = ""): string[] => {
33
+ const appNames: string[] = [];
34
+ for (const entry of readdirSync(path.join(appsDir, prefix))) {
35
+ if (["node_modules", "dist", "public", "webkit"].includes(entry)) continue;
36
+ const entryPath = path.join(appsDir, prefix, entry);
37
+ if (!statSync(entryPath).isDirectory()) continue;
38
+ const appName = path.join(prefix, entry).split(path.sep).join("/");
39
+ if (existsSync(path.join(entryPath, "akan.config.ts"))) appNames.push(appName);
40
+ if (maxDepth > 0) appNames.push(...getAppNames(appsDir, maxDepth - 1, appName));
41
+ }
42
+ return appNames;
43
+ };
44
+
45
+ const resolveLocalCsrPort = (appInfo: AppScanResult) => {
46
+ const explicitPort = process.env.AKAN_PUBLIC_CLIENT_PORT ?? process.env.PORT;
47
+ if (explicitPort) return explicitPort;
48
+ const appsDir = findAppsDir(appInfo.name);
49
+ const appNames = appsDir ? getAppNames(appsDir).sort((a, b) => a.localeCompare(b)) : [];
50
+ const appIndex = Math.max(appNames.indexOf(appInfo.name), 0);
51
+ const portOffset = Number.parseInt(process.env.PORT_OFFSET ?? "0");
52
+ return (8282 + appIndex + portOffset).toString();
53
+ };
54
+
19
55
  const routeBasePaths = (appInfo: AppScanResult) =>
20
56
  new Set(
21
57
  appInfo.routes
@@ -42,10 +78,14 @@ const resolveTarget = (appInfo: AppScanResult, targetName = process.env.AKAN_MOB
42
78
  return entries[0]?.[1] as AkanMobileTargetConfig;
43
79
  };
44
80
 
45
- const localCsrUrl = (ip: string, target: AkanMobileTargetConfig) => {
81
+ const localCsrUrl = (ip: string, target: AkanMobileTargetConfig, appInfo: AppScanResult) => {
46
82
  const basePath = normalizeBasePath(target.basePath);
47
- const port = process.env.AKAN_PUBLIC_CLIENT_PORT ?? process.env.PORT ?? "8282";
48
- return `http://${ip}:${port}/${basePath ? `${basePath}` : ""}?csr=true`;
83
+ const locale = parseAkanI18nEnv().defaultLocale;
84
+ const pathname = basePath ? `${locale}/${basePath}` : `${locale}/`;
85
+ const port = resolveLocalCsrPort(appInfo);
86
+ const params = new URLSearchParams({ csr: "true", akanMobileTarget: target.name });
87
+ if (basePath) params.set("akanMobileBasePath", basePath);
88
+ return `http://${ip}:${port}/${pathname}?${params}`;
49
89
  };
50
90
 
51
91
  export const withBase = (
@@ -66,7 +106,7 @@ export const withBase = (
66
106
  process.env.APP_OPERATION_MODE !== "release"
67
107
  ? {
68
108
  androidScheme: "http",
69
- url: localCsrUrl(ip, target),
109
+ url: localCsrUrl(ip, target, appInfo),
70
110
  cleartext: true,
71
111
  allowNavigation: [ip, "localhost"],
72
112
  }
@@ -169,58 +169,56 @@ const loadCapacitorModule = <K extends keyof CapacitorModuleMap>(
169
169
  return loaded;
170
170
  };
171
171
 
172
- const importNativeModule = <T>(specifier: string) => import(specifier) as Promise<T>;
173
-
174
- const capacitorPackage = (name: string) => `@capacitor/${name}`;
175
-
176
- const capacitorCommunityPackage = (name: string) => `@capacitor-community/${name}`;
172
+ const asCapacitorModule = <T>(modulePromise: Promise<unknown>) => modulePromise as Promise<T>;
177
173
 
178
174
  export const loadCapacitorApp = () =>
179
- loadCapacitorModule("app", () => importNativeModule<CapacitorAppModule>(capacitorPackage("app")));
175
+ loadCapacitorModule("app", () => asCapacitorModule<CapacitorAppModule>(import("@capacitor/app")));
180
176
 
181
177
  export const loadCapacitorBrowser = () =>
182
- loadCapacitorModule("browser", () => importNativeModule<CapacitorBrowserModule>(capacitorPackage("browser")));
178
+ loadCapacitorModule("browser", () => asCapacitorModule<CapacitorBrowserModule>(import("@capacitor/browser")));
183
179
 
184
180
  export const loadCapacitorCamera = () =>
185
- loadCapacitorModule("camera", () => importNativeModule<CapacitorCameraModule>(capacitorPackage("camera")));
181
+ loadCapacitorModule("camera", () => asCapacitorModule<CapacitorCameraModule>(import("@capacitor/camera")));
186
182
 
187
183
  export const loadCapacitorContacts = () =>
188
184
  loadCapacitorModule("contacts", () =>
189
- importNativeModule<CapacitorContactsModule>(capacitorCommunityPackage("contacts")),
185
+ asCapacitorModule<CapacitorContactsModule>(import("@capacitor-community/contacts")),
190
186
  );
191
187
 
192
188
  export const loadCapacitorCore = () =>
193
- loadCapacitorModule("core", () => importNativeModule<CapacitorCoreModule>(capacitorPackage("core")));
189
+ loadCapacitorModule("core", () => asCapacitorModule<CapacitorCoreModule>(import("@capacitor/core")));
194
190
 
195
191
  export const loadCapacitorDevice = () =>
196
- loadCapacitorModule("device", () => importNativeModule<CapacitorDeviceModule>(capacitorPackage("device")));
192
+ loadCapacitorModule("device", () => asCapacitorModule<CapacitorDeviceModule>(import("@capacitor/device")));
197
193
 
198
194
  export const loadCapacitorFcm = () =>
199
- loadCapacitorModule("fcm", () => importNativeModule<CapacitorFcmModule>(capacitorCommunityPackage("fcm")));
195
+ loadCapacitorModule("fcm", () => asCapacitorModule<CapacitorFcmModule>(import("@capacitor-community/fcm")));
200
196
 
201
197
  export const loadCapacitorGeolocation = () =>
202
198
  loadCapacitorModule("geolocation", () =>
203
- importNativeModule<CapacitorGeolocationModule>(capacitorPackage("geolocation")),
199
+ asCapacitorModule<CapacitorGeolocationModule>(import("@capacitor/geolocation")),
204
200
  );
205
201
 
206
202
  export const loadCapacitorHaptics = () =>
207
- loadCapacitorModule("haptics", () => importNativeModule<CapacitorHapticsModule>(capacitorPackage("haptics")));
203
+ loadCapacitorModule("haptics", () => asCapacitorModule<CapacitorHapticsModule>(import("@capacitor/haptics")));
208
204
 
209
205
  export const loadCapacitorKeyboard = () =>
210
- loadCapacitorModule("keyboard", () => importNativeModule<CapacitorKeyboardModule>(capacitorPackage("keyboard")));
206
+ loadCapacitorModule("keyboard", () => asCapacitorModule<CapacitorKeyboardModule>(import("@capacitor/keyboard")));
211
207
 
212
208
  export const loadCapacitorPreferences = () =>
213
209
  loadCapacitorModule("preferences", () =>
214
- importNativeModule<CapacitorPreferencesModule>(capacitorPackage("preferences")),
210
+ asCapacitorModule<CapacitorPreferencesModule>(import("@capacitor/preferences")),
215
211
  );
216
212
 
217
213
  export const loadCapacitorPushNotifications = () =>
218
214
  loadCapacitorModule("pushNotifications", () =>
219
- importNativeModule<CapacitorPushNotificationsModule>(capacitorPackage("push-notifications")),
215
+ asCapacitorModule<CapacitorPushNotificationsModule>(import("@capacitor/push-notifications")),
220
216
  );
221
217
 
222
218
  export const loadCapacitorSafeArea = () =>
223
- loadCapacitorModule("safeArea", () => importNativeModule<CapacitorSafeAreaModule>("capacitor-plugin-safe-area"));
219
+ loadCapacitorModule("safeArea", () =>
220
+ asCapacitorModule<CapacitorSafeAreaModule>(import("capacitor-plugin-safe-area")),
221
+ );
224
222
 
225
223
  export const loadCapacitorUpdater = () =>
226
- loadCapacitorModule("updater", () => importNativeModule<CapacitorUpdaterModule>("@capgo/capacitor-updater"));
224
+ loadCapacitorModule("updater", () => asCapacitorModule<CapacitorUpdaterModule>(import("@capgo/capacitor-updater")));
@@ -70,6 +70,7 @@ export type LayoutNotFoundRender = (props: LayoutNotFoundProps) => PromiseOrObje
70
70
  export type LayoutErrorRender = (props: LayoutErrorProps) => PromiseOrObject<ReactNode>;
71
71
  export interface RouteRender {
72
72
  render: LayoutRender | PageRender;
73
+ isAsync?: boolean;
73
74
  Loading?: LayoutLoadingRender | PageLoadingRender;
74
75
  NotFound?: LayoutNotFoundRender;
75
76
  Error?: LayoutErrorRender;
package/client/router.ts CHANGED
@@ -63,6 +63,7 @@ function getServerRequestContext() {
63
63
  const getConfiguredBasePaths = () => new Set(parseBasePaths(process.env.AKAN_PUBLIC_BASE_PATHS));
64
64
 
65
65
  const shouldExposeBasePath = () => getEnv().operationMode === "local";
66
+ const CSR_RUNTIME_SEARCH_PARAMS = ["csr", "akanMobileTarget", "akanMobileBasePath"] as const;
66
67
 
67
68
  const getLocaleFromPathname = (pathname: string) => {
68
69
  const [firstSegment] = pathname.split("/").filter(Boolean);
@@ -168,14 +169,14 @@ class Router {
168
169
  push: (href: string, routeOptions) => {
169
170
  const { path, pathname, hash, href: fullHref } = this.#getPathInfo(href);
170
171
  this.#postPathChange({ path, pathname, hash });
171
- options.router.push(fullHref, routeOptions);
172
+ options.router.push(this.#withCsrRuntimeSearchParams(fullHref), routeOptions);
172
173
  },
173
174
  replace: (href: string, routeOptions) => {
174
175
  const { path, pathname, hash, href: fullHref } = this.#getPathInfo(href);
175
176
  this.#postPathChange({ path, pathname, hash });
176
177
 
177
178
  setTimeout(() => {
178
- options.router.replace(fullHref, routeOptions);
179
+ options.router.replace(this.#withCsrRuntimeSearchParams(fullHref), routeOptions);
179
180
  }, 0);
180
181
  },
181
182
  back: (routeOptions) => {
@@ -200,6 +201,21 @@ class Router {
200
201
  #getNavigationPathInfo(href: string) {
201
202
  return this.#getPathInfo(href, shouldExposeBasePath() ? this.#prefix : "");
202
203
  }
204
+ #withCsrRuntimeSearchParams(href: string) {
205
+ const currentSearch = new URLSearchParams(window.location.search);
206
+ if (currentSearch.get("csr") !== "true") return href;
207
+
208
+ const [hrefWithoutHash, hash = ""] = href.split("#");
209
+ const [pathname, search = ""] = hrefWithoutHash.split("?");
210
+ const nextSearch = new URLSearchParams(search);
211
+ for (const param of CSR_RUNTIME_SEARCH_PARAMS) {
212
+ if (nextSearch.has(param)) continue;
213
+ const value = currentSearch.get(param);
214
+ if (value !== null) nextSearch.set(param, value);
215
+ }
216
+ const query = nextSearch.toString();
217
+ return `${pathname}${query ? `?${query}` : ""}${hash ? `#${hash}` : ""}`;
218
+ }
203
219
  #getVisiblePathInfo(href: string, lang = this.#lang) {
204
220
  return getPathInfo(href, lang, shouldExposeBasePath() ? this.#prefix : "");
205
221
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akanjs",
3
- "version": "2.1.2-rc.1",
3
+ "version": "2.2.0-rc.1",
4
4
  "sourceType": "module",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -283,6 +283,7 @@ export class RouteTreeBuilder {
283
283
  static #makeRouteRender(key: string, kind: "page" | "layout", loader: () => Promise<RouteModule>): RouteRender {
284
284
  const loadModule = RouteTreeBuilder.#makeLazyModule(key, kind, loader);
285
285
  const routeRender: RouteRender = {
286
+ isAsync: true,
286
287
  render: async (props: LayoutProps | PageProps) => {
287
288
  const mod = await loadModule();
288
289
  routeRender.Loading = mod.Loading as never;
@@ -73,6 +73,7 @@ export type LayoutNotFoundRender = (props: LayoutNotFoundProps) => PromiseOrObje
73
73
  export type LayoutErrorRender = (props: LayoutErrorProps) => PromiseOrObject<ReactNode>;
74
74
  export interface RouteRender {
75
75
  render: LayoutRender | PageRender;
76
+ isAsync?: boolean;
76
77
  Loading?: LayoutLoadingRender | PageLoadingRender;
77
78
  NotFound?: LayoutNotFoundRender;
78
79
  Error?: LayoutErrorRender;
@@ -1,4 +1,9 @@
1
1
  import { type RouteModule } from "akanjs/client";
2
+ type CsrRouteModuleLoader = () => Promise<RouteModule>;
3
+ type CsrRouteModuleEntry = CsrRouteModuleLoader | {
4
+ loader: CsrRouteModuleLoader;
5
+ isAsyncDefault?: boolean;
6
+ };
2
7
  declare global {
3
8
  interface Window {
4
9
  __AKAN_MOBILE_TARGET__?: {
@@ -7,4 +12,5 @@ declare global {
7
12
  };
8
13
  }
9
14
  }
10
- export declare const bootCsr: (context: Record<string, () => Promise<RouteModule>>) => Promise<void>;
15
+ export declare const bootCsr: (context: Record<string, CsrRouteModuleEntry>) => Promise<void>;
16
+ export {};
package/ui/System/CSR.tsx CHANGED
@@ -396,7 +396,7 @@ const RenderLayer = memo(({ renders, index, params, searchParams }: RenderLayerP
396
396
  <RenderLayer renders={renders} index={index + 1} params={params} searchParams={searchParams} />
397
397
  );
398
398
  const routeRender = renders[index];
399
- const isAsyncRender = routeRender?.render.constructor.name === "AsyncFunction";
399
+ const isAsyncRender = isAsyncRouteRender(routeRender);
400
400
  const resultRef = useRef<ReactNode | Promise<ReactNode> | null>(null);
401
401
  if (isAsyncRender && resultRef.current === null) {
402
402
  resultRef.current = routeRender?.render({ children, params, searchParams } as never) ?? null;
@@ -408,6 +408,10 @@ const RenderLayer = memo(({ renders, index, params, searchParams }: RenderLayerP
408
408
  return <>{Component}</>;
409
409
  });
410
410
 
411
+ function isAsyncRouteRender(routeRender?: RouteRender): boolean {
412
+ return Boolean(routeRender?.isAsync || routeRender?.render.constructor.name === "AsyncFunction");
413
+ }
414
+
411
415
  function composeLoadingFallback(renders: RouteRender[], params: Record<string, string>): ReactNode {
412
416
  let element: ReactNode = null;
413
417
  for (let i = renders.length - 1; i >= 0; i--) {
@@ -30,6 +30,8 @@ import { useCsrValues } from "./useCsrValues";
30
30
  import { useFetch } from "./useFetch";
31
31
 
32
32
  type RouteModuleWithConfig = RouteModule & { pageConfig?: PageConfig };
33
+ type CsrRouteModuleLoader = () => Promise<RouteModule>;
34
+ type CsrRouteModuleEntry = CsrRouteModuleLoader | { loader: CsrRouteModuleLoader; isAsyncDefault?: boolean };
33
35
 
34
36
  declare global {
35
37
  interface Window {
@@ -49,7 +51,7 @@ const RootRenderLayer = memo(({ renders, index, params, searchParams }: RootRend
49
51
  <RootRenderLayer renders={renders} index={index + 1} params={params} searchParams={searchParams} />
50
52
  );
51
53
  const routeRender = renders[index];
52
- const isAsyncRender = routeRender?.render.constructor.name === "AsyncFunction";
54
+ const isAsyncRender = isAsyncRouteRender(routeRender);
53
55
  const resultRef = useRef<ReactNode | Promise<ReactNode> | null>(null);
54
56
  if (isAsyncRender && resultRef.current === null) {
55
57
  resultRef.current = routeRender?.render({ children, params, searchParams } as never) ?? null;
@@ -61,6 +63,10 @@ const RootRenderLayer = memo(({ renders, index, params, searchParams }: RootRend
61
63
  return Layout;
62
64
  });
63
65
 
66
+ function isAsyncRouteRender(routeRender?: RouteRender): boolean {
67
+ return Boolean(routeRender?.isAsync || routeRender?.render.constructor.name === "AsyncFunction");
68
+ }
69
+
64
70
  function composeLoadingFallback(renders: RouteRender[], params: Record<string, string>): ReactNode {
65
71
  let element: ReactNode = null;
66
72
  for (let i = renders.length - 1; i >= 0; i--) {
@@ -71,9 +77,10 @@ function composeLoadingFallback(renders: RouteRender[], params: Record<string, s
71
77
  return element;
72
78
  }
73
79
 
74
- export const bootCsr = async (context: Record<string, () => Promise<RouteModule>>) => {
80
+ export const bootCsr = async (context: Record<string, CsrRouteModuleEntry>) => {
75
81
  const i18n = parseAkanI18nEnv();
76
82
  window.document.body.style.overflow = "hidden";
83
+ initializeMobileTargetFromSearch();
77
84
  const mobileBasePath = window.__AKAN_MOBILE_TARGET__?.basePath?.replace(/^\/+|\/+$/g, "");
78
85
  const pathname = mobileBasePath && window.location.pathname === "/" ? `/${mobileBasePath}` : window.location.pathname;
79
86
  if (pathname === "/404") return;
@@ -93,6 +100,7 @@ export const bootCsr = async (context: Record<string, () => Promise<RouteModule>
93
100
  const otherBasePaths = basePaths?.filter((path) => path !== currentBasePath) ?? [];
94
101
 
95
102
  const pages: { [key: string]: RouteModule } = {};
103
+ const asyncDefaultMap: { [key: string]: boolean | undefined } = {};
96
104
  await Promise.all(
97
105
  Object.entries(context).map(async ([key, value]) => {
98
106
  const parsed = parseRouteModuleKey(key);
@@ -100,8 +108,10 @@ export const bootCsr = async (context: Record<string, () => Promise<RouteModule>
100
108
  const pageBasePath = parsed.sourceRouteSegments.find((segment) => !/^\(.+\)$/.test(segment));
101
109
  if (pageBasePath && otherBasePaths.includes(pageBasePath)) return;
102
110
  }
103
- const pageContent = await value();
111
+ const entry = typeof value === "function" ? { loader: value } : value;
112
+ const pageContent = await entry.loader();
104
113
  validateRouteModuleExports(key, pageContent);
114
+ asyncDefaultMap[key] = entry.isAsyncDefault;
105
115
  if (pageContent.default) pages[key] = pageContent;
106
116
  }),
107
117
  );
@@ -155,6 +165,7 @@ export const bootCsr = async (context: Record<string, () => Promise<RouteModule>
155
165
  const layoutPage = parsed.kind === "layout" ? (page as LayoutModule) : null;
156
166
  const routeRender: RouteRender = {
157
167
  render: page.default as never,
168
+ isAsync: asyncDefaultMap[filePath] || page.default?.constructor.name === "AsyncFunction",
158
169
  Loading: page.Loading as never,
159
170
  NotFound: layoutPage?.NotFound,
160
171
  Error: layoutPage?.Error,
@@ -257,6 +268,17 @@ export const bootCsr = async (context: Record<string, () => Promise<RouteModule>
257
268
  root.render(<RouterProvider />);
258
269
  };
259
270
 
271
+ function initializeMobileTargetFromSearch() {
272
+ if (window.__AKAN_MOBILE_TARGET__) return;
273
+
274
+ const params = new URLSearchParams(window.location.search);
275
+ const name = params.get("akanMobileTarget");
276
+ if (!name) return;
277
+
278
+ const basePath = params.get("akanMobileBasePath")?.replace(/^\/+|\/+$/g, "") ?? "";
279
+ window.__AKAN_MOBILE_TARGET__ = { name, basePath };
280
+ }
281
+
260
282
  function validateRouteModuleExports(key: string, mod: RouteModule) {
261
283
  const parsed = parseRouteModuleKey(key);
262
284
  const allowed =