akanjs 2.1.1 → 2.1.2-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/base/baseEnv.ts CHANGED
@@ -85,7 +85,8 @@ export const getEnv = (): ClientEnv => {
85
85
  if (repoName === "unknown") throw new Error("environment variable AKAN_PUBLIC_REPO_NAME is required");
86
86
  if (serveDomain === "unknown") throw new Error("environment variable AKAN_PUBLIC_SERVE_DOMAIN is required");
87
87
  const environment = (process.env.AKAN_PUBLIC_ENV ?? "debug") as BaseEnv["environment"];
88
- const operationMode = (process.env.AKAN_PUBLIC_OPERATION_MODE ?? "cloud") as BaseEnv["operationMode"];
88
+ const operationMode = (process.env.AKAN_PUBLIC_OPERATION_MODE ??
89
+ (environment === "local" ? "local" : "cloud")) as BaseEnv["operationMode"];
89
90
  const tunnelUsername = process.env.SSH_TUNNEL_USERNAME ?? "root";
90
91
  const tunnelPassword = process.env.SSH_TUNNEL_PASSWORD ?? repoName;
91
92
  const baseEnv: BaseEnv = {
@@ -51,6 +51,13 @@ export interface PageLoadingProps {
51
51
  export interface LayoutLoadingProps extends PageLoadingProps {
52
52
  children: ReactNode;
53
53
  }
54
+ export interface LayoutNotFoundProps extends PageProps {
55
+ pathname: string;
56
+ }
57
+ export interface LayoutErrorProps extends LayoutNotFoundProps {
58
+ error?: unknown;
59
+ digest?: string;
60
+ }
54
61
  export type Head = ReactNode;
55
62
  export type GenerateHead = (props: PageProps) => PromiseOrObject<Head | null | undefined>;
56
63
  export type ResolveHead = (props: PageProps) => PromiseOrObject<Head | null | undefined>;
@@ -59,9 +66,15 @@ export type PageRender = (props: PageProps) => PromiseOrObject<ReactNode>;
59
66
  export type LayoutRender = (props: LayoutProps) => PromiseOrObject<ReactNode>;
60
67
  export type PageLoadingRender = (props: PageLoadingProps) => PromiseOrObject<ReactNode>;
61
68
  export type LayoutLoadingRender = (props: LayoutLoadingProps) => PromiseOrObject<ReactNode>;
69
+ export type LayoutNotFoundRender = (props: LayoutNotFoundProps) => PromiseOrObject<ReactNode>;
70
+ export type LayoutErrorRender = (props: LayoutErrorProps) => PromiseOrObject<ReactNode>;
62
71
  export interface RouteRender {
63
72
  render: LayoutRender | PageRender;
64
73
  Loading?: LayoutLoadingRender | PageLoadingRender;
74
+ NotFound?: LayoutNotFoundRender;
75
+ Error?: LayoutErrorRender;
76
+ resolveNotFound?: () => PromiseOrObject<LayoutNotFoundRender | undefined>;
77
+ resolveError?: () => PromiseOrObject<LayoutErrorRender | undefined>;
65
78
  resolveHead?: ResolveHead;
66
79
  getPageConfig?: () => PromiseOrObject<PageConfig | undefined>;
67
80
  }
@@ -108,6 +121,8 @@ export interface LayoutModule {
108
121
  layoutStyle?: "mobile" | "web";
109
122
  gaTrackingId?: string;
110
123
  Loading?: LayoutLoadingRender;
124
+ NotFound?: LayoutNotFoundRender;
125
+ Error?: LayoutErrorRender;
111
126
  }
112
127
  export type RouteModule = PageModule | LayoutModule;
113
128
  export interface Route {
@@ -252,6 +267,13 @@ export interface PathRoute {
252
267
  isSpecialRoute?: boolean;
253
268
  }
254
269
 
270
+ export interface LayoutFallbackRoute {
271
+ path: string;
272
+ pathSegments: string[];
273
+ renderRootLayouts: RouteRender[];
274
+ renderLayouts: RouteRender[];
275
+ }
276
+
255
277
  export interface RouteGuide {
256
278
  pathSegment: string;
257
279
  pathRoute?: PathRoute;
@@ -11,7 +11,9 @@ export interface AllDictionary {
11
11
 
12
12
  export class Translator {
13
13
  static #langDictionaryMap = new Map<string, Dictionary>();
14
- constructor(dictionary: Record<string, Record<string, Record<string, unknown>>>) {
14
+ constructor(
15
+ dictionary: Record<string, Record<string, Record<string, unknown>>>,
16
+ ) {
15
17
  Object.entries(dictionary).forEach(([lang, dictionary]) => {
16
18
  this.#setDictionary(lang, dictionary);
17
19
  });
@@ -19,25 +21,37 @@ export class Translator {
19
21
  hasDictionary(lang: string) {
20
22
  return Translator.#langDictionaryMap.has(lang);
21
23
  }
22
- #setDictionary(lang: string, dict: Dictionary) {
23
- const existingDictionary = Translator.#langDictionaryMap.get(lang);
24
- const dictionary = existingDictionary ?? {};
24
+
25
+ static seed(lang: string, dict: Dictionary | undefined) {
26
+ if (!dict) return;
27
+ const existingDictionary = Translator.#langDictionaryMap.get(lang) ?? {};
25
28
  Object.entries(dict).forEach(([key, modelDict]) => {
26
- if (dictionary[key]) Object.assign(dictionary[key], modelDict);
27
- else dictionary[key] = modelDict;
29
+ if (existingDictionary[key])
30
+ Object.assign(existingDictionary[key], modelDict);
31
+ else existingDictionary[key] = modelDict as Dictionary[string];
28
32
  });
29
- Translator.#langDictionaryMap.set(lang, dictionary);
30
- return dictionary;
33
+ Translator.#langDictionaryMap.set(lang, existingDictionary);
34
+ }
35
+ #setDictionary(lang: string, dict: Dictionary) {
36
+ Translator.seed(lang, dict);
37
+ return Translator.#langDictionaryMap.get(lang) as Dictionary;
31
38
  }
32
- translate(lang: string, key: string, param?: Record<string, string | number>): string {
39
+ translate(
40
+ lang: string,
41
+ key: string,
42
+ param?: Record<string, string | number>,
43
+ ): string {
33
44
  const dictionary = Translator.#langDictionaryMap.get(lang);
34
45
  if (!dictionary) return key;
35
46
  const msg = (pathGet(key, dictionary, ".", { t: key }) as { t: string }).t;
36
- return param ? msg.replace(/{([^}]+)}/g, (_, key: string) => param[key] as string) : msg;
47
+ return param
48
+ ? msg.replace(/{([^}]+)}/g, (_, key: string) => param[key] as string)
49
+ : msg;
37
50
  }
38
51
  async getDictionary(lang: string) {
39
52
  const dictionary = Translator.#langDictionaryMap.get(lang);
40
- if (!dictionary) throw new Error(`Dictionary for language ${lang} not found`);
53
+ if (!dictionary)
54
+ throw new Error(`Dictionary for language ${lang} not found`);
41
55
  return dictionary;
42
56
  }
43
57
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akanjs",
3
- "version": "2.1.1",
3
+ "version": "2.1.2-rc.1",
4
4
  "sourceType": "module",
5
5
  "type": "module",
6
6
  "publishConfig": {
package/server/akanApp.ts CHANGED
@@ -395,7 +395,7 @@ export class AkanApp {
395
395
  data: {} as GatewayWsData,
396
396
  },
397
397
  });
398
- this.logger.info(`AkanApp gateway is running on port ${this.#port}`);
398
+ this.logger.info(`AkanApp gateway is running on port http://localhost:${this.#port}`);
399
399
  }
400
400
 
401
401
  async #handleFetch(req: Request, server: Bun.Server<GatewayWsData>): Promise<Response | undefined> {
@@ -82,6 +82,40 @@ export class RouteSeedIndexStore {
82
82
  return null;
83
83
  }
84
84
 
85
+ static matchPrefix(pathname: string, entries: RouteSeedEntry[]): MatchedRoute | null {
86
+ const candidates = entries
87
+ .map((entry) => ({
88
+ entry,
89
+ params: RouteSeedIndexStore.#matchRoutePrefix(entry.pattern ?? entry.routeId, pathname),
90
+ }))
91
+ .filter((entry): entry is { entry: RouteSeedEntry; params: Record<string, string> } => Boolean(entry.params))
92
+ .sort((a, b) => {
93
+ const lengthDelta =
94
+ b.entry.pattern.split("/").filter(Boolean).length - a.entry.pattern.split("/").filter(Boolean).length;
95
+ if (lengthDelta !== 0) return lengthDelta;
96
+ return a.entry.pattern < b.entry.pattern ? -1 : a.entry.pattern > b.entry.pattern ? 1 : 0;
97
+ });
98
+ return candidates[0] ?? null;
99
+ }
100
+
101
+ static #matchRoutePrefix(pattern: string, pathname: string): Record<string, string> | null {
102
+ const patternParts = pattern.split("/").filter(Boolean);
103
+ const pathParts = pathname.split("/").filter(Boolean);
104
+ if (patternParts.length > pathParts.length) return null;
105
+ const params: Record<string, string> = {};
106
+ for (let index = 0; index < patternParts.length; index++) {
107
+ const patternPart = patternParts[index];
108
+ const pathPart = pathParts[index];
109
+ if (!patternPart || !pathPart) return null;
110
+ if (patternPart.startsWith(":")) {
111
+ params[patternPart.slice(1)] = decodeURIComponent(pathPart);
112
+ continue;
113
+ }
114
+ if (patternPart !== pathPart) return null;
115
+ }
116
+ return params;
117
+ }
118
+
85
119
  static #normalizeArtifactPath(artifactPath: string, artifactDir: string): string {
86
120
  if (path.isAbsolute(artifactPath)) return artifactPath;
87
121
  return path.resolve(artifactDir, artifactPath);
@@ -1,4 +1,11 @@
1
- import type { Head, PathRoute, RouteRender } from "akanjs/client";
1
+ import type {
2
+ Head,
3
+ LayoutErrorRender,
4
+ LayoutFallbackRoute,
5
+ LayoutNotFoundRender,
6
+ PathRoute,
7
+ RouteRender,
8
+ } from "akanjs/client";
2
9
  import { Children, cloneElement, isValidElement, type ReactElement, type ReactNode, Suspense } from "react";
3
10
 
4
11
  export class RouteElementComposer {
@@ -39,6 +46,71 @@ export class RouteElementComposer {
39
46
  return pathRoute.resolveHead?.({ params, searchParams });
40
47
  }
41
48
 
49
+ static async composeFallback({
50
+ kind,
51
+ route,
52
+ params,
53
+ searchParams,
54
+ pathname,
55
+ error,
56
+ digest,
57
+ }: {
58
+ kind: "not-found" | "error";
59
+ route: PathRoute | LayoutFallbackRoute;
60
+ params: Record<string, string>;
61
+ searchParams: Record<string, string | string[]>;
62
+ pathname: string;
63
+ error?: unknown;
64
+ digest?: string;
65
+ }): Promise<ReactNode | null> {
66
+ const layoutStack = [...route.renderRootLayouts, ...route.renderLayouts];
67
+ for (let index = layoutStack.length - 1; index >= 0; index--) {
68
+ const layoutRender = layoutStack[index];
69
+ if (!layoutRender) continue;
70
+ const fallback =
71
+ kind === "not-found" ? await layoutRender.resolveNotFound?.() : await layoutRender.resolveError?.();
72
+ if (!fallback) continue;
73
+ const renders = [
74
+ ...layoutStack.slice(0, index + 1),
75
+ RouteElementComposer.#makeFallbackRouteRender({
76
+ kind,
77
+ fallback,
78
+ params,
79
+ searchParams,
80
+ pathname,
81
+ error,
82
+ digest,
83
+ }),
84
+ ];
85
+ return RouteElementComposer.composeRenders({ renders, params, searchParams });
86
+ }
87
+ return null;
88
+ }
89
+
90
+ static composeRenders({
91
+ renders,
92
+ params,
93
+ searchParams,
94
+ }: {
95
+ renders: RouteRender[];
96
+ params: Record<string, string>;
97
+ searchParams: Record<string, string | string[]>;
98
+ }): ReactNode {
99
+ let element: ReactNode = null;
100
+ for (let i = renders.length - 1; i >= 0; i--) {
101
+ const routeRender = renders[i];
102
+ if (!routeRender) continue;
103
+ element = (
104
+ <Suspense fallback={RouteElementComposer.#composeLoadingFallback(renders.slice(i), params)}>
105
+ <RouteElementComposer.AsyncRender routeRender={routeRender} params={params} searchParams={searchParams}>
106
+ {element}
107
+ </RouteElementComposer.AsyncRender>
108
+ </Suspense>
109
+ );
110
+ }
111
+ return element;
112
+ }
113
+
42
114
  static async renderAsync({
43
115
  routeRender,
44
116
  children,
@@ -61,6 +133,34 @@ export class RouteElementComposer {
61
133
  searchParams: Record<string, string | string[]>;
62
134
  }) => RouteElementComposer.renderAsync(props);
63
135
 
136
+ static #makeFallbackRouteRender({
137
+ kind,
138
+ fallback,
139
+ pathname,
140
+ error,
141
+ digest,
142
+ }: {
143
+ kind: "not-found" | "error";
144
+ fallback: LayoutNotFoundRender | LayoutErrorRender;
145
+ params: Record<string, string>;
146
+ searchParams: Record<string, string | string[]>;
147
+ pathname: string;
148
+ error?: unknown;
149
+ digest?: string;
150
+ }): RouteRender {
151
+ return {
152
+ render: (props: { params: Record<string, string>; searchParams: Record<string, string | string[]> }) => {
153
+ const { params, searchParams } = props as {
154
+ params: Record<string, string>;
155
+ searchParams: Record<string, string | string[]>;
156
+ };
157
+ return kind === "not-found"
158
+ ? (fallback as LayoutNotFoundRender)({ params, searchParams, pathname })
159
+ : (fallback as LayoutErrorRender)({ params, searchParams, pathname, error, digest });
160
+ },
161
+ };
162
+ }
163
+
64
164
  static #normalizeReactNode(node: ReactNode): ReactNode {
65
165
  if (Array.isArray(node)) return Children.toArray(node).map(RouteElementComposer.#normalizeReactNode);
66
166
  if (!isValidElement(node)) return node;
@@ -1,5 +1,7 @@
1
1
  import type {
2
2
  Head,
3
+ LayoutFallbackRoute,
4
+ LayoutModule,
3
5
  LayoutProps,
4
6
  PageProps,
5
7
  PageState,
@@ -54,8 +56,10 @@ export class RouteTreeBuilder {
54
56
  "layoutStyle",
55
57
  "gaTrackingId",
56
58
  "Loading",
59
+ "NotFound",
60
+ "Error",
57
61
  ]);
58
- static readonly #layoutRouteExports = new Set(["default", "head", "generateHead", "Loading"]);
62
+ static readonly #layoutRouteExports = new Set(["default", "head", "generateHead", "Loading", "NotFound", "Error"]);
59
63
  static readonly #moduleCacheStats: RouteModuleCacheStats = {
60
64
  moduleCount: 0,
61
65
  loadedModuleCount: 0,
@@ -69,6 +73,7 @@ export class RouteTreeBuilder {
69
73
  readonly #baseLayoutPaths: string[];
70
74
  readonly #routeMap = new Map<string, Route>();
71
75
  readonly #pagePatterns: { key: string; pattern: string }[] = [];
76
+ readonly #fallbackRoutes: LayoutFallbackRoute[] = [];
72
77
 
73
78
  constructor(context: PagesContext) {
74
79
  this.#context = context;
@@ -79,6 +84,7 @@ export class RouteTreeBuilder {
79
84
 
80
85
  build(): PathRoute[] {
81
86
  RouteTreeBuilder.resetCacheStats();
87
+ this.#fallbackRoutes.length = 0;
82
88
  for (const [filePath, loader] of Object.entries(this.#context)) this.#addRouteModule(filePath, loader);
83
89
  assertUniqueRoutePatterns(this.#pagePatterns);
84
90
 
@@ -87,6 +93,10 @@ export class RouteTreeBuilder {
87
93
  return this.#getPathRoutes(rootRoute).sort((a, b) => compareRouteSpecificity(a.path, b.path));
88
94
  }
89
95
 
96
+ getFallbackRoutes(): LayoutFallbackRoute[] {
97
+ return [...this.#fallbackRoutes].sort((a, b) => compareRouteSpecificity(a.path, b.path));
98
+ }
99
+
90
100
  static getCacheStats(): RouteModuleCacheStats {
91
101
  return {
92
102
  ...RouteTreeBuilder.#moduleCacheStats,
@@ -115,6 +125,28 @@ export class RouteTreeBuilder {
115
125
  return null;
116
126
  }
117
127
 
128
+ static matchFallback(
129
+ pathname: string,
130
+ fallbackRoutes: LayoutFallbackRoute[],
131
+ ): { fallbackRoute: LayoutFallbackRoute; params: Record<string, string> } | null {
132
+ const candidates = fallbackRoutes
133
+ .map((fallbackRoute) => ({
134
+ fallbackRoute,
135
+ params: RouteTreeBuilder.#matchRoutePrefix(fallbackRoute.path, pathname),
136
+ }))
137
+ .filter((entry): entry is { fallbackRoute: LayoutFallbackRoute; params: Record<string, string> } =>
138
+ Boolean(entry.params),
139
+ )
140
+ .sort((a, b) => {
141
+ const lengthDelta =
142
+ b.fallbackRoute.path.split("/").filter(Boolean).length -
143
+ a.fallbackRoute.path.split("/").filter(Boolean).length;
144
+ if (lengthDelta !== 0) return lengthDelta;
145
+ return compareRouteSpecificity(a.fallbackRoute.path, b.fallbackRoute.path);
146
+ });
147
+ return candidates[0] ?? null;
148
+ }
149
+
118
150
  static parseSearchParams(search: string): Record<string, string | string[]> {
119
151
  const result: Record<string, string | string[]> = {};
120
152
  const urlSearchParams = new URLSearchParams(search);
@@ -171,6 +203,14 @@ export class RouteTreeBuilder {
171
203
  const currentLayout = !isRoot && route.renderLayout ? route.renderLayout : null;
172
204
  const renderRootLayouts = [...parentRootLayouts, ...(currentRootLayout ? [currentRootLayout] : [])];
173
205
  const renderLayouts = [...parentLayouts, ...(currentLayout ? [currentLayout] : [])];
206
+ if (route.renderLayout) {
207
+ this.#fallbackRoutes.push({
208
+ path: routePath,
209
+ pathSegments,
210
+ renderRootLayouts,
211
+ renderLayouts,
212
+ });
213
+ }
174
214
  const routeHead = RouteTreeBuilder.#composeHeadResolvers(route.renderLayout?.resolveHead, parentHead);
175
215
  const pageRenderRootLayouts =
176
216
  route.pageIncludesOwnLayout === false && currentRootLayout ? parentRootLayouts : renderRootLayouts;
@@ -246,12 +286,22 @@ export class RouteTreeBuilder {
246
286
  render: async (props: LayoutProps | PageProps) => {
247
287
  const mod = await loadModule();
248
288
  routeRender.Loading = mod.Loading as never;
289
+ if (kind === "layout") {
290
+ const layoutMod = mod as LayoutModule;
291
+ routeRender.NotFound = layoutMod.NotFound;
292
+ routeRender.Error = layoutMod.Error;
293
+ }
249
294
  if (!mod.default) throw new Error(`[route-convention] ${key} has no default export`);
250
295
  return mod.default(props as never);
251
296
  },
252
297
  resolveHead: async (props: PageProps) => {
253
298
  const mod = await loadModule();
254
299
  routeRender.Loading = mod.Loading as never;
300
+ if (kind === "layout") {
301
+ const layoutMod = mod as LayoutModule;
302
+ routeRender.NotFound = layoutMod.NotFound;
303
+ routeRender.Error = layoutMod.Error;
304
+ }
255
305
  if (mod.generateHead) return mod.generateHead(props);
256
306
  return mod.head as Head | null | undefined;
257
307
  },
@@ -261,6 +311,19 @@ export class RouteTreeBuilder {
261
311
  const mod = await loadModule();
262
312
  return "pageConfig" in mod ? mod.pageConfig : undefined;
263
313
  };
314
+ } else {
315
+ routeRender.resolveNotFound = async () => {
316
+ const mod = (await loadModule()) as LayoutModule;
317
+ routeRender.NotFound = mod.NotFound;
318
+ routeRender.Error = mod.Error;
319
+ return mod.NotFound;
320
+ };
321
+ routeRender.resolveError = async () => {
322
+ const mod = (await loadModule()) as LayoutModule;
323
+ routeRender.NotFound = mod.NotFound;
324
+ routeRender.Error = mod.Error;
325
+ return mod.Error;
326
+ };
264
327
  }
265
328
  return routeRender;
266
329
  }
@@ -276,4 +339,22 @@ export class RouteTreeBuilder {
276
339
  return undefined;
277
340
  };
278
341
  }
342
+
343
+ static #matchRoutePrefix(pattern: string, pathname: string): Record<string, string> | null {
344
+ const patternParts = pattern.split("/").filter(Boolean);
345
+ const pathParts = pathname.split("/").filter(Boolean);
346
+ if (patternParts.length > pathParts.length) return null;
347
+ const params: Record<string, string> = {};
348
+ for (let index = 0; index < patternParts.length; index++) {
349
+ const patternPart = patternParts[index];
350
+ const pathPart = pathParts[index];
351
+ if (!patternPart || !pathPart) return null;
352
+ if (patternPart.startsWith(":")) {
353
+ params[patternPart.slice(1)] = decodeURIComponent(pathPart);
354
+ continue;
355
+ }
356
+ if (patternPart !== pathPart) return null;
357
+ }
358
+ return params;
359
+ }
279
360
  }
@@ -67,7 +67,15 @@ async function fetchRsc(href: string, options: { buildId?: number } = {}): Promi
67
67
  return { type: "redirected" };
68
68
  }
69
69
  if (!res.ok || !res.body) throw new Error(`[rscClient] RSC fetch failed ${res.status} ${res.statusText}`);
70
- return { type: "rsc", thenable: createRscThenable(res.body) };
70
+
71
+ const buffer = await res.arrayBuffer();
72
+ const completeStream = new ReadableStream<Uint8Array>({
73
+ start(controller) {
74
+ controller.enqueue(new Uint8Array(buffer));
75
+ controller.close();
76
+ },
77
+ });
78
+ return { type: "rsc", thenable: createRscThenable(completeStream) };
71
79
  }
72
80
 
73
81
  const rscCache = new Map<string, RscThenable>();