akanjs 2.2.12 → 2.2.13-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/client/csrTypes.ts +37 -6
  2. package/client/makePageProto.tsx +8 -8
  3. package/client/router.ts +5 -2
  4. package/fetch/requestStorage.ts +41 -11
  5. package/package.json +1 -1
  6. package/server/akanApp.ts +55 -0
  7. package/server/cachePolicy.ts +192 -0
  8. package/server/metadata.tsx +114 -0
  9. package/server/routeElementComposer.tsx +21 -1
  10. package/server/routeTreeBuilder.ts +44 -5
  11. package/server/rscClient.tsx +127 -50
  12. package/server/rscHttp.ts +120 -0
  13. package/server/rscNavigationState.ts +95 -0
  14. package/server/rscWorker.tsx +318 -121
  15. package/server/rscWorkerHost.ts +281 -66
  16. package/server/rscWorkerReplay.ts +40 -0
  17. package/server/ssrFromRscRenderer.tsx +462 -77
  18. package/server/ssrTypes.ts +11 -1
  19. package/server/webRouter.ts +173 -88
  20. package/service/ipcTypes.ts +1 -0
  21. package/types/client/csrTypes.d.ts +37 -6
  22. package/types/dictionary/base.dictionary.d.ts +1 -1
  23. package/types/dictionary/dictionary.d.ts +8 -8
  24. package/types/fetch/requestStorage.d.ts +16 -6
  25. package/types/server/cachePolicy.d.ts +55 -0
  26. package/types/server/metadata.d.ts +13 -0
  27. package/types/server/routeElementComposer.d.ts +6 -1
  28. package/types/server/rscHttp.d.ts +16 -0
  29. package/types/server/rscNavigationState.d.ts +35 -0
  30. package/types/server/rscWorkerHost.d.ts +38 -0
  31. package/types/server/rscWorkerReplay.d.ts +29 -0
  32. package/types/server/ssrFromRscRenderer.d.ts +20 -1
  33. package/types/server/ssrTypes.d.ts +10 -1
  34. package/types/server/webRouter.d.ts +27 -1
  35. package/types/service/ipcTypes.d.ts +1 -0
  36. package/types/webkit/useCsrValues.d.ts +1 -1
  37. package/ui/Link/SsrLink.tsx +0 -2
  38. package/webkit/bootCsr.tsx +16 -2
@@ -7,7 +7,7 @@ import type { RouterInstance } from "./router";
7
7
  import type { ReactFont } from "./types";
8
8
 
9
9
  export type TransitionType = "none" | "fade" | "bottomUp" | "stack" | "scaleOut";
10
- /** Per-page CSR configuration for transition, safe-area, gesture, and cache behavior. */
10
+ /** Per-page CSR configuration for transition, safe-area, and gesture behavior. */
11
11
  export interface PageConfig {
12
12
  transition?: TransitionType;
13
13
  safeArea?: boolean | "top" | "bottom";
@@ -18,8 +18,6 @@ export interface PageConfig {
18
18
  bottomInset?: boolean | number;
19
19
  gesture?: boolean;
20
20
  cache?: boolean;
21
- rscCache?: "public" | false;
22
- rscCacheTtl?: number;
23
21
  topSafeAreaColor?: string;
24
22
  bottomSafeAreaColor?: string;
25
23
  }
@@ -60,7 +58,12 @@ export interface LayoutErrorProps extends LayoutNotFoundProps {
60
58
  }
61
59
  export type Head = ReactNode;
62
60
  export type GenerateHead = (props: PageProps) => PromiseOrObject<Head | null | undefined>;
63
- export type ResolveHead = (props: PageProps) => PromiseOrObject<Head | null | undefined>;
61
+ export interface ResolvedHead {
62
+ node: Head | null | undefined;
63
+ hasExplicitLanguageAlternates: boolean;
64
+ }
65
+ export type ResolveHeadResult = Head | ResolvedHead | null | undefined;
66
+ export type ResolveHead = (props: PageProps) => PromiseOrObject<ResolveHeadResult>;
64
67
  export type HeadProps = PageProps;
65
68
  export type PageRender = (props: PageProps) => PromiseOrObject<ReactNode>;
66
69
  export type LayoutRender = (props: LayoutProps) => PromiseOrObject<ReactNode>;
@@ -104,17 +107,45 @@ export interface WebAppManifest {
104
107
  screenshots?: WebAppManifestIcon[];
105
108
  [key: string]: unknown;
106
109
  }
110
+ export interface AkanMetadata {
111
+ title?: string;
112
+ description?: string;
113
+ robots?: string;
114
+ openGraph?: {
115
+ title?: string;
116
+ description?: string;
117
+ type?: string;
118
+ url?: string;
119
+ siteName?: string;
120
+ images?: string | string[];
121
+ };
122
+ twitter?: {
123
+ card?: "summary" | "summary_large_image" | "app" | "player" | (string & {});
124
+ title?: string;
125
+ description?: string;
126
+ images?: string | string[];
127
+ };
128
+ alternates?: {
129
+ canonical?: string;
130
+ languages?: Record<string, string>;
131
+ };
132
+ }
133
+ export type GenerateMetadata = (props: PageProps) => PromiseOrObject<AkanMetadata | null | undefined>;
107
134
  export interface PageModule {
108
135
  default?: PageRender;
109
136
  pageConfig?: PageConfig;
110
137
  head?: Head;
138
+ metadata?: AkanMetadata;
111
139
  generateHead?: GenerateHead;
140
+ generateMetadata?: GenerateMetadata;
112
141
  Loading?: PageLoadingRender;
113
142
  }
114
143
  export interface LayoutModule {
115
144
  default?: LayoutRender;
116
145
  head?: Head;
146
+ metadata?: AkanMetadata;
117
147
  generateHead?: GenerateHead;
148
+ generateMetadata?: GenerateMetadata;
118
149
  fonts?: ReactFont[];
119
150
  manifest?: WebAppManifest;
120
151
  theme?: string;
@@ -135,7 +166,7 @@ export interface Route {
135
166
  pageIncludesOwnLayout?: boolean;
136
167
  isSpecialRoute?: boolean;
137
168
 
138
- loader?: () => any;
169
+ loader?: () => unknown;
139
170
  pageState?: PageState;
140
171
 
141
172
  children: Map<string, Route>;
@@ -229,7 +260,7 @@ export interface RouteState {
229
260
  }
230
261
 
231
262
  export type UseCsrTransition = CsrTransitionStyles & {
232
- pageBind: (...args: any[]) => ReactDOMAttributes;
263
+ pageBind: (...args: unknown[]) => ReactDOMAttributes;
233
264
  pageClassName: string;
234
265
  transDirection: "vertical" | "horizontal" | "none";
235
266
  transUnitRange: number[];
@@ -1,6 +1,6 @@
1
1
  import { getEnv } from "akanjs/base";
2
2
  import { parseAkanI18nEnv } from "akanjs/common";
3
- import { getRequest, headers } from "akanjs/fetch";
3
+ import { untrackedHeaders, untrackedRequest } from "akanjs/fetch";
4
4
  import type { ReactNode } from "react";
5
5
  import { Translator } from "./translator";
6
6
 
@@ -31,12 +31,12 @@ const getPageInfo = (): { locale: string; path: string } => {
31
31
  const locale = activeLocale ?? (hasLocalePrefix ? firstSegment : defaultLocale);
32
32
  return { locale, path: hasLocalePrefix ? `/${rest.join("/")}` : window.location.pathname };
33
33
  }
34
- const h = headers();
34
+ const h = untrackedHeaders();
35
35
 
36
36
  const localeHeader = h.get("x-locale");
37
37
  const pathHeader = h.get("x-path");
38
38
  if (localeHeader && pathHeader) return { locale: localeHeader, path: pathHeader };
39
- const req = getRequest();
39
+ const req = untrackedRequest();
40
40
  if (req) {
41
41
  const urlPath = new URL(req.url).pathname;
42
42
  const [, firstSegment = "", ...rest] = urlPath.split("/");
@@ -62,11 +62,11 @@ const msg = {
62
62
  warning: () => null,
63
63
  loading: () => null,
64
64
  } as {
65
- info: (key: TransMessage<any>, option?: TransMessageOption) => void;
66
- success: (key: TransMessage<any>, option?: TransMessageOption) => void;
67
- error: (key: TransMessage<any>, option?: TransMessageOption) => void;
68
- warning: (key: TransMessage<any>, option?: TransMessageOption) => void;
69
- loading: (key: TransMessage<any>, option?: TransMessageOption) => void;
65
+ info: (key: TransMessage<Record<string, unknown>>, option?: TransMessageOption) => void;
66
+ success: (key: TransMessage<Record<string, unknown>>, option?: TransMessageOption) => void;
67
+ error: (key: TransMessage<Record<string, unknown>>, option?: TransMessageOption) => void;
68
+ warning: (key: TransMessage<Record<string, unknown>>, option?: TransMessageOption) => void;
69
+ loading: (key: TransMessage<Record<string, unknown>>, option?: TransMessageOption) => void;
70
70
  };
71
71
 
72
72
  export const makePageProto = <
package/client/router.ts CHANGED
@@ -62,8 +62,11 @@ export class AkanNotFoundError extends Error {
62
62
  }
63
63
 
64
64
  function getServerRequestContext() {
65
- const { getRequest, headers } = require("akanjs/fetch");
66
- return { getRequest, headers } as { getRequest: () => Request; headers: () => Map<string, string> };
65
+ const { untrackedHeaders, untrackedRequest } = require("akanjs/fetch");
66
+ return { getRequest: untrackedRequest, headers: untrackedHeaders } as {
67
+ getRequest: () => Request | undefined;
68
+ headers: () => Map<string, string>;
69
+ };
67
70
  }
68
71
 
69
72
  const getConfiguredBasePaths = () => new Set(parseBasePaths(process.env.AKAN_PUBLIC_BASE_PATHS));
@@ -2,8 +2,6 @@ export type AkanTheme = "css" | "system" | (string & {});
2
2
 
3
3
  export interface AkanRequestPolicy {
4
4
  routeId?: string;
5
- rscCache?: "public" | false;
6
- rscCacheTtl?: number;
7
5
  cacheable?: boolean;
8
6
  revalidate?: number | false;
9
7
  tags: Set<string>;
@@ -93,10 +91,10 @@ export function getRequestTheme(): AkanTheme | undefined {
93
91
  return getRequestStore()?.theme;
94
92
  }
95
93
 
96
- export function pushRequestFallback(req: Request): () => void {
94
+ export function pushRequestFallback(storeOrRequest: Request | AkanRequestStore): () => void {
97
95
  globalThis.__AKAN_REQUEST_FALLBACK_STACK__ ??= [];
98
96
  const stack = globalThis.__AKAN_REQUEST_FALLBACK_STACK__;
99
- const store = createRequestStore(req);
97
+ const store = normalizeRequestStore(storeOrRequest);
100
98
  stack.push(store);
101
99
  return () => {
102
100
  const index = stack.lastIndexOf(store);
@@ -110,21 +108,43 @@ export function getRequestStore(): AkanRequestStore | undefined {
110
108
  }
111
109
 
112
110
  /** Returns the active server request from AsyncLocalStorage or the fallback stack. */
113
- export function getRequest(): Request | undefined {
114
- return getRequestStore()?.request;
111
+ export function getRequest(options: { trackDynamic?: boolean } = {}): Request | undefined {
112
+ const store = getRequestStore();
113
+ if (!store) return undefined;
114
+ if (options.trackDynamic !== false) {
115
+ store.dynamicUsage.headers = true;
116
+ store.dynamicUsage.cookies = true;
117
+ }
118
+ return store.request;
119
+ }
120
+
121
+ /** Reads the framework's active server request without marking the user route dynamic. */
122
+ export function untrackedRequest(): Request | undefined {
123
+ return getRequest({ trackDynamic: false });
115
124
  }
116
125
 
117
126
  export function getRequestPolicy(): AkanRequestPolicy | undefined {
118
127
  return getRequestStore()?.policy;
119
128
  }
120
129
 
130
+ function combineMinPolicyRevalidate(
131
+ current: number | false | undefined,
132
+ next: number | false | undefined,
133
+ ): number | false | undefined {
134
+ if (next === undefined) return current;
135
+ if (current === false || next === false) return false;
136
+ if (current === undefined) return next;
137
+ return Math.min(current, next);
138
+ }
139
+
121
140
  export function updateRequestPolicy(
122
141
  patch: Partial<Omit<AkanRequestPolicy, "tags">> & { tags?: Iterable<string> },
123
142
  ): AkanRequestPolicy | undefined {
124
143
  const policy = getRequestPolicy();
125
144
  if (!policy) return undefined;
126
- const { tags, ...rest } = patch;
145
+ const { tags, revalidate, ...rest } = patch;
127
146
  Object.assign(policy, rest);
147
+ policy.revalidate = combineMinPolicyRevalidate(policy.revalidate, revalidate);
128
148
  if (tags) for (const tag of tags) policy.tags.add(tag);
129
149
  return policy;
130
150
  }
@@ -145,17 +165,22 @@ export function memoizeRequestQuery<T>(key: string, factory: () => Promise<T>):
145
165
  }
146
166
 
147
167
  /** Returns current request headers as a Map, or an empty Map outside a request. */
148
- export function headers(): Map<string, string> {
168
+ export function headers(options: { trackDynamic?: boolean } = {}): Map<string, string> {
149
169
  const store = getRequestStore();
150
170
  const map = new Map<string, string>();
151
171
  if (!store) return map;
152
- store.dynamicUsage.headers = true;
172
+ if (options.trackDynamic !== false) store.dynamicUsage.headers = true;
153
173
  store.request.headers.forEach((value, key) => {
154
174
  map.set(key, value);
155
175
  });
156
176
  return map;
157
177
  }
158
178
 
179
+ /** Reads headers for framework internals without marking the user route dynamic. */
180
+ export function untrackedHeaders(): Map<string, string> {
181
+ return headers({ trackDynamic: false });
182
+ }
183
+
159
184
  export interface CookieEntry {
160
185
  name: string;
161
186
  value: string;
@@ -186,9 +211,14 @@ export function parseCookieHeader(cookieHeader: string): Map<string, CookieEntry
186
211
  }
187
212
 
188
213
  /** Returns parsed cookies from the current request, or an empty Map outside a request. */
189
- export function cookies(): Map<string, CookieEntry> {
214
+ export function cookies(options: { trackDynamic?: boolean } = {}): Map<string, CookieEntry> {
190
215
  const store = getRequestStore();
191
216
  if (!store) return new Map();
192
- store.dynamicUsage.cookies = true;
217
+ if (options.trackDynamic !== false) store.dynamicUsage.cookies = true;
193
218
  return parseCookieHeader(store.request.headers.get("cookie") ?? "");
194
219
  }
220
+
221
+ /** Reads cookies for framework internals without marking the user route dynamic. */
222
+ export function untrackedCookies(): Map<string, CookieEntry> {
223
+ return cookies({ trackDynamic: false });
224
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akanjs",
3
- "version": "2.2.12",
3
+ "version": "2.2.13-rc.1",
4
4
  "sourceType": "module",
5
5
  "type": "module",
6
6
  "publishConfig": {
package/server/akanApp.ts CHANGED
@@ -84,6 +84,8 @@ export class AkanApp {
84
84
  #logWriter: RotatingLogWriter | null = null;
85
85
  #removeLogSink: (() => void) | null = null;
86
86
  readonly #childOutputBuffers = new Map<string, string>();
87
+ readonly #childStderrBlockBuffers = new Map<string, string[]>();
88
+ readonly #childStderrBlockTimers = new Map<string, ReturnType<typeof setTimeout>>();
87
89
  static readonly #ansiPattern = new RegExp(`${String.fromCharCode(27)}\\[[0-?]*[ -/]*[@-~]`, "g");
88
90
  #gatewayMetrics: AkanMetricsReport = {};
89
91
  #proxyHopCount = 0;
@@ -1004,6 +1006,7 @@ export class AkanApp {
1004
1006
  if (remaining) this.#writeChildOutput(idx, role, type, bufferKey, remaining);
1005
1007
  } finally {
1006
1008
  this.#flushChildOutput(idx, role, type, bufferKey);
1009
+ if (type === "stderr") this.#flushChildStderrBlock(idx, role, AkanApp.#childStderrBlockKey(idx, role));
1007
1010
  }
1008
1011
  }
1009
1012
 
@@ -1028,11 +1031,63 @@ export class AkanApp {
1028
1031
  }
1029
1032
 
1030
1033
  #writeChildOutputLine(idx: number, role: AkanChildRole, type: "stdout" | "stderr", line: string) {
1034
+ if (type === "stderr" && this.#bufferChildStderrLine(idx, role, line)) return;
1035
+ this.#writeChildOutputLineRaw(idx, role, type, line);
1036
+ }
1037
+
1038
+ #writeChildOutputLineRaw(idx: number, role: AkanChildRole, type: "stdout" | "stderr", line: string) {
1031
1039
  const prefixedLine = `[child:${idx} ${role}] [${type}] ${line}`;
1032
1040
  process[type].write(prefixedLine);
1033
1041
  this.#logWriter?.write(`${idx}-${role}`, AkanApp.#stripAnsi(prefixedLine));
1034
1042
  }
1035
1043
 
1044
+ #bufferChildStderrLine(idx: number, role: AkanChildRole, line: string): boolean {
1045
+ const key = AkanApp.#childStderrBlockKey(idx, role);
1046
+ const block = this.#childStderrBlockBuffers.get(key) ?? [];
1047
+ block.push(line);
1048
+ this.#childStderrBlockBuffers.set(key, block);
1049
+
1050
+ const existingTimer = this.#childStderrBlockTimers.get(key);
1051
+ if (existingTimer) clearTimeout(existingTimer);
1052
+
1053
+ if (line.trim() === "" || block.length >= 64) {
1054
+ this.#flushChildStderrBlock(idx, role, key);
1055
+ return true;
1056
+ }
1057
+
1058
+ this.#childStderrBlockTimers.set(
1059
+ key,
1060
+ setTimeout(() => this.#flushChildStderrBlock(idx, role, key), 50),
1061
+ );
1062
+ return true;
1063
+ }
1064
+
1065
+ #flushChildStderrBlock(idx: number, role: AkanChildRole, key: string) {
1066
+ const timer = this.#childStderrBlockTimers.get(key);
1067
+ if (timer) clearTimeout(timer);
1068
+ this.#childStderrBlockTimers.delete(key);
1069
+
1070
+ const block = this.#childStderrBlockBuffers.get(key);
1071
+ if (!block?.length) return;
1072
+ this.#childStderrBlockBuffers.delete(key);
1073
+
1074
+ const text = block.join("");
1075
+ if (AkanApp.#isBenignRsdwConnectionClosedBlock(text)) return;
1076
+ for (const blockLine of block) this.#writeChildOutputLineRaw(idx, role, "stderr", blockLine);
1077
+ }
1078
+
1079
+ static #childStderrBlockKey(idx: number, role: AkanChildRole): string {
1080
+ return `${idx}:${role}:stderr`;
1081
+ }
1082
+
1083
+ static #isBenignRsdwConnectionClosedBlock(text: string): boolean {
1084
+ return (
1085
+ text.includes('reportGlobalError(weakResponse, Error("Connection closed."))') &&
1086
+ text.includes("error: Connection closed.") &&
1087
+ text.includes("react-server-dom-webpack")
1088
+ );
1089
+ }
1090
+
1036
1091
  static #stripAnsi(msg: string) {
1037
1092
  return msg.replace(AkanApp.#ansiPattern, "");
1038
1093
  }
@@ -0,0 +1,192 @@
1
+ import { type AkanDynamicUsage, type AkanRequestPolicy, parseCookieHeader } from "akanjs/fetch";
2
+
3
+ export const DEFAULT_ROUTE_CACHE_TTL_SECONDS = 30;
4
+
5
+ export interface RouteCacheKeyInput {
6
+ request: Request;
7
+ url: URL;
8
+ theme?: string;
9
+ }
10
+
11
+ export interface RouteCacheRenderState {
12
+ cacheable: boolean;
13
+ revalidate?: number | false;
14
+ tags?: string[];
15
+ dynamicUsage?: AkanDynamicUsage;
16
+ reason?: string;
17
+ }
18
+
19
+ export interface RouteCacheEntry {
20
+ key: string;
21
+ ttl: number;
22
+ }
23
+
24
+ export type RouteCacheRenderControlType = "redirect" | "not-found" | "error";
25
+
26
+ export function parsePositiveInt(value: string | undefined | null): number | null {
27
+ const parsed = Number.parseInt(value ?? "", 10);
28
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
29
+ }
30
+
31
+ export function normalizeRouteCacheTtl(value: unknown, fallback = 30): number | null {
32
+ if (value === false || value === null) return null;
33
+ if (value === undefined) return fallback;
34
+ const ttl = typeof value === "number" ? value : Number.parseInt(String(value), 10);
35
+ return Number.isFinite(ttl) && ttl > 0 ? ttl : null;
36
+ }
37
+
38
+ export function resolveAutoRouteCacheTtl(input: {
39
+ enabled?: string | null;
40
+ ttl?: string | null;
41
+ defaultTtl?: number;
42
+ }): number | null {
43
+ if (input.enabled !== "1") return null;
44
+ return normalizeRouteCacheTtl(input.ttl, input.defaultTtl ?? DEFAULT_ROUTE_CACHE_TTL_SECONDS);
45
+ }
46
+
47
+ export function combineMinRevalidate(...values: Array<number | false | null | undefined>): number | false | undefined {
48
+ let out: number | undefined;
49
+ for (const value of values) {
50
+ if (value === undefined || value === null) continue;
51
+ if (value === false) return false;
52
+ out = out === undefined ? value : Math.min(out, value);
53
+ }
54
+ return out;
55
+ }
56
+
57
+ export function getClientFacingOrigin(request: Request, url = new URL(request.url)): string {
58
+ const forwardedProto = request.headers.get("x-forwarded-proto")?.split(",")[0]?.trim();
59
+ const forwardedHost = request.headers.get("x-forwarded-host")?.split(",")[0]?.trim();
60
+ const host = forwardedHost ?? request.headers.get("host")?.split(",")[0]?.trim();
61
+ const proto = forwardedProto ?? url.protocol.slice(0, -1);
62
+ if (host && proto) {
63
+ try {
64
+ return new URL(`${proto}://${host}`).origin;
65
+ } catch {
66
+ }
67
+ }
68
+ return url.origin;
69
+ }
70
+
71
+ export function isPublicRouteCacheableRequest(request: Request): boolean {
72
+ if (request.method !== "GET") return false;
73
+ if (request.headers.has("authorization")) return false;
74
+ const cookie = request.headers.get("cookie");
75
+ if (!cookie) return true;
76
+ return [...parseCookieHeader(cookie).keys()].every((name) => name === "theme");
77
+ }
78
+
79
+ export function isRouteCachePathAllowed(
80
+ pathname: string,
81
+ options: { allow?: string | null; deny?: string | null } = {},
82
+ ): boolean {
83
+ const matches = (raw: string | null | undefined) => {
84
+ const prefixes = (raw ?? "")
85
+ .split(",")
86
+ .map((prefix) => prefix.trim())
87
+ .filter(Boolean);
88
+ if (prefixes.length === 0) return false;
89
+ return prefixes.some(
90
+ (prefix) => pathname === prefix || pathname.startsWith(prefix.endsWith("/") ? prefix : `${prefix}/`),
91
+ );
92
+ };
93
+ if (matches(options.deny)) return false;
94
+ const allow = options.allow ?? "";
95
+ return matches(allow);
96
+ }
97
+
98
+ export function createRouteCacheKey({ request, url, theme = "" }: RouteCacheKeyInput): string {
99
+ return [
100
+ getClientFacingOrigin(request, url),
101
+ request.headers.get("x-base-path") ?? "",
102
+ request.headers.get("x-locale") ?? "",
103
+ request.headers.get("x-path") ?? "",
104
+ url.pathname,
105
+ url.search,
106
+ request.headers.get("accept-language") ?? "",
107
+ theme,
108
+ ].join("\n");
109
+ }
110
+
111
+ export function createRouteCacheEntry(input: RouteCacheKeyInput & { ttl: number }): RouteCacheEntry {
112
+ return { key: createRouteCacheKey(input), ttl: input.ttl };
113
+ }
114
+
115
+ export function resolveRouteCacheStoreTtl(baseTtl: number, state: RouteCacheRenderState): number | null {
116
+ if (!state.cacheable || state.revalidate === false) return null;
117
+ if (typeof state.revalidate !== "number") return baseTtl;
118
+ if (!Number.isFinite(state.revalidate) || state.revalidate <= 0) return null;
119
+ return Math.min(baseTtl, state.revalidate);
120
+ }
121
+
122
+ export function shouldStoreRouteCache(input: {
123
+ policy?: AkanRequestPolicy;
124
+ dynamicUsage?: AkanDynamicUsage;
125
+ renderControlType?: RouteCacheRenderControlType;
126
+ lateRedirect?: boolean;
127
+ }): RouteCacheRenderState {
128
+ const dynamicUsage = input.dynamicUsage ? { ...input.dynamicUsage } : undefined;
129
+ const tags = input.policy ? [...input.policy.tags] : undefined;
130
+ const revalidate = combineMinRevalidate(input.policy?.revalidate);
131
+ if (input.renderControlType) {
132
+ const reason =
133
+ input.renderControlType === "redirect" && input.lateRedirect
134
+ ? "late-redirect"
135
+ : `render-${input.renderControlType}`;
136
+ return { cacheable: false, revalidate, tags, dynamicUsage, reason };
137
+ }
138
+ if (dynamicUsage?.headers || dynamicUsage?.cookies)
139
+ return { cacheable: false, revalidate, tags, dynamicUsage, reason: "dynamic-request-api" };
140
+ return { cacheable: input.policy?.cacheable !== false, revalidate, tags, dynamicUsage };
141
+ }
142
+
143
+ export class LruTtlCache<T> {
144
+ readonly #entries = new Map<string, { value: T; expiresAt: number }>();
145
+
146
+ constructor(readonly maxEntries = 100) {}
147
+
148
+ get size(): number {
149
+ return this.#entries.size;
150
+ }
151
+
152
+ get(key: string): T | null {
153
+ const entry = this.#entries.get(key);
154
+ if (!entry) return null;
155
+ if (entry.expiresAt <= Date.now()) {
156
+ this.#entries.delete(key);
157
+ return null;
158
+ }
159
+ this.#entries.delete(key);
160
+ this.#entries.set(key, entry);
161
+ return entry.value;
162
+ }
163
+
164
+ set(key: string, value: T, ttlSeconds: number): void {
165
+ this.#entries.delete(key);
166
+ const maxEntries = this.maxEntries > 0 ? this.maxEntries : 100;
167
+ while (this.#entries.size >= maxEntries) {
168
+ const oldest = this.#entries.keys().next().value;
169
+ if (!oldest) break;
170
+ this.#entries.delete(oldest);
171
+ }
172
+ this.#entries.set(key, { value, expiresAt: Date.now() + ttlSeconds * 1000 });
173
+ }
174
+
175
+ delete(key: string): boolean {
176
+ return this.#entries.delete(key);
177
+ }
178
+
179
+ invalidate(predicate: (key: string, value: T) => boolean): number {
180
+ let count = 0;
181
+ for (const [key, entry] of this.#entries) {
182
+ if (!predicate(key, entry.value)) continue;
183
+ this.#entries.delete(key);
184
+ count += 1;
185
+ }
186
+ return count;
187
+ }
188
+
189
+ clear(): void {
190
+ this.#entries.clear();
191
+ }
192
+ }
@@ -0,0 +1,114 @@
1
+ import type { AkanMetadata, Head, ResolvedHead, ResolveHeadResult } from "akanjs/client";
2
+ import type { ReactNode } from "react";
3
+
4
+ function isRecord(value: unknown): value is Record<string, unknown> {
5
+ return Boolean(value && typeof value === "object" && !Array.isArray(value));
6
+ }
7
+
8
+ function normalizeStringArray(value: string | string[] | undefined): string[] {
9
+ if (value === undefined) return [];
10
+ return Array.isArray(value) ? value : [value];
11
+ }
12
+
13
+ function renderOpenGraph(metadata: AkanMetadata): ReactNode[] {
14
+ const openGraph = metadata.openGraph;
15
+ if (!openGraph) return [];
16
+ const nodes: ReactNode[] = [];
17
+ if (openGraph.title) nodes.push(<meta key="og:title" property="og:title" content={openGraph.title} />);
18
+ if (openGraph.description)
19
+ nodes.push(<meta key="og:description" property="og:description" content={openGraph.description} />);
20
+ if (openGraph.type) nodes.push(<meta key="og:type" property="og:type" content={openGraph.type} />);
21
+ if (openGraph.url) nodes.push(<meta key="og:url" property="og:url" content={openGraph.url} />);
22
+ if (openGraph.siteName) nodes.push(<meta key="og:site_name" property="og:site_name" content={openGraph.siteName} />);
23
+ for (const [index, image] of normalizeStringArray(openGraph.images).entries()) {
24
+ nodes.push(<meta key={`og:image:${index}`} property="og:image" content={image} />);
25
+ }
26
+ return nodes;
27
+ }
28
+
29
+ function renderTwitter(metadata: AkanMetadata): ReactNode[] {
30
+ const twitter = metadata.twitter;
31
+ if (!twitter) return [];
32
+ const nodes: ReactNode[] = [];
33
+ if (twitter.card) nodes.push(<meta key="twitter:card" name="twitter:card" content={twitter.card} />);
34
+ if (twitter.title) nodes.push(<meta key="twitter:title" name="twitter:title" content={twitter.title} />);
35
+ if (twitter.description)
36
+ nodes.push(<meta key="twitter:description" name="twitter:description" content={twitter.description} />);
37
+ for (const [index, image] of normalizeStringArray(twitter.images).entries()) {
38
+ nodes.push(<meta key={`twitter:image:${index}`} name="twitter:image" content={image} />);
39
+ }
40
+ return nodes;
41
+ }
42
+
43
+ function renderAlternates(metadata: AkanMetadata): ReactNode[] {
44
+ const alternates = metadata.alternates;
45
+ if (!alternates) return [];
46
+ const nodes: ReactNode[] = [];
47
+ if (alternates.canonical) nodes.push(<link key="canonical" rel="canonical" href={alternates.canonical} />);
48
+ if (alternates.languages) {
49
+ for (const [lang, href] of Object.entries(alternates.languages)) {
50
+ nodes.push(<link key={`metadata:alternate:${lang}`} rel="alternate" hrefLang={lang} href={href} />);
51
+ }
52
+ }
53
+ return nodes;
54
+ }
55
+
56
+ export function isAkanMetadata(value: unknown): value is AkanMetadata {
57
+ if (!isRecord(value)) return false;
58
+ return (
59
+ "title" in value ||
60
+ "description" in value ||
61
+ "robots" in value ||
62
+ "openGraph" in value ||
63
+ "twitter" in value ||
64
+ "alternates" in value
65
+ );
66
+ }
67
+
68
+ export function renderMetadata(metadata: AkanMetadata): Head {
69
+ return (
70
+ <>
71
+ {metadata.title ? <title>{metadata.title}</title> : null}
72
+ {metadata.description ? <meta name="description" content={metadata.description} /> : null}
73
+ {metadata.robots ? <meta name="robots" content={metadata.robots} /> : null}
74
+ {renderOpenGraph(metadata)}
75
+ {renderTwitter(metadata)}
76
+ {renderAlternates(metadata)}
77
+ </>
78
+ );
79
+ }
80
+
81
+ export function hasExplicitLanguageAlternates(metadata: AkanMetadata | null | undefined): boolean {
82
+ return Boolean(metadata?.alternates?.languages && Object.keys(metadata.alternates.languages).length > 0);
83
+ }
84
+
85
+ export function shouldRenderLocaleAlternates(options: {
86
+ isSpecialRoute?: boolean;
87
+ hasExplicitLanguageAlternates?: boolean;
88
+ }): boolean {
89
+ return options.isSpecialRoute !== true && options.hasExplicitLanguageAlternates !== true;
90
+ }
91
+
92
+ export function isResolvedHead(value: unknown): value is ResolvedHead {
93
+ return isRecord(value) && "node" in value && "hasExplicitLanguageAlternates" in value;
94
+ }
95
+
96
+ export function resolveMetadataHead(metadata: AkanMetadata): ResolvedHead {
97
+ return {
98
+ node: renderMetadata(metadata),
99
+ hasExplicitLanguageAlternates: hasExplicitLanguageAlternates(metadata),
100
+ };
101
+ }
102
+
103
+ export function resolveHeadExport(value: Head | AkanMetadata | null | undefined): ResolvedHead {
104
+ return isAkanMetadata(value) ? resolveMetadataHead(value) : { node: value, hasExplicitLanguageAlternates: false };
105
+ }
106
+
107
+ export function resolveHeadResult(value: ResolveHeadResult): ResolvedHead {
108
+ if (isResolvedHead(value)) return value;
109
+ return resolveHeadExport(value as Head | AkanMetadata | null | undefined);
110
+ }
111
+
112
+ export function normalizeHead(value: Head | AkanMetadata | null | undefined): Head | null | undefined {
113
+ return isAkanMetadata(value) ? renderMetadata(value) : value;
114
+ }