akanjs 2.2.13-rc.0 → 2.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/client/csrTypes.ts +37 -6
- package/client/makePageProto.tsx +8 -8
- package/client/router.ts +5 -2
- package/fetch/requestStorage.ts +41 -11
- package/package.json +1 -1
- package/server/cachePolicy.ts +192 -0
- package/server/metadata.tsx +114 -0
- package/server/routeElementComposer.tsx +21 -1
- package/server/routeTreeBuilder.ts +44 -5
- package/server/rscClient.tsx +127 -50
- package/server/rscHttp.ts +120 -0
- package/server/rscNavigationState.ts +95 -0
- package/server/rscWorker.tsx +177 -86
- package/server/rscWorkerHost.ts +47 -1
- package/server/rscWorkerReplay.ts +5 -0
- package/server/ssrFromRscRenderer.tsx +18 -6
- package/server/ssrTypes.ts +2 -1
- package/server/webRouter.ts +114 -110
- package/types/client/csrTypes.d.ts +37 -6
- package/types/fetch/requestStorage.d.ts +16 -6
- package/types/server/cachePolicy.d.ts +55 -0
- package/types/server/metadata.d.ts +13 -0
- package/types/server/routeElementComposer.d.ts +6 -1
- package/types/server/rscHttp.d.ts +16 -0
- package/types/server/rscNavigationState.d.ts +35 -0
- package/types/server/rscWorkerHost.d.ts +9 -0
- package/types/server/rscWorkerReplay.d.ts +6 -0
- package/types/server/ssrFromRscRenderer.d.ts +2 -0
- package/types/server/ssrTypes.d.ts +2 -1
- package/types/server/webRouter.d.ts +22 -1
- package/types/ui/Button.d.ts +1 -1
- package/types/ui/ClientSide.d.ts +1 -1
- package/types/ui/Constant/Doc.d.ts +6 -6
- package/types/ui/Constant/Mermaid.d.ts +1 -1
- package/types/ui/Constant/index.d.ts +1 -1
- package/types/ui/Copy.d.ts +1 -1
- package/types/ui/CsrImage.d.ts +1 -1
- package/types/ui/Data/CardList.d.ts +1 -1
- package/types/ui/Data/Dashboard.d.ts +1 -1
- package/types/ui/Data/Insight.d.ts +1 -1
- package/types/ui/Data/Item.d.ts +6 -6
- package/types/ui/Data/ListContainer.d.ts +1 -1
- package/types/ui/Data/Pagination.d.ts +1 -1
- package/types/ui/Data/TableList.d.ts +1 -1
- package/types/ui/DatePicker.d.ts +3 -3
- package/types/ui/Dialog/Close.d.ts +1 -1
- package/types/ui/Dialog/Content.d.ts +1 -1
- package/types/ui/Dialog/Provider.d.ts +1 -1
- package/types/ui/Dialog/Trigger.d.ts +1 -1
- package/types/ui/Dialog/index.d.ts +3 -3
- package/types/ui/DragAction.d.ts +4 -4
- package/types/ui/DraggableList.d.ts +3 -3
- package/types/ui/Dropdown.d.ts +1 -1
- package/types/ui/Empty.d.ts +1 -1
- package/types/ui/Field.d.ts +22 -22
- package/types/ui/Image.d.ts +1 -1
- package/types/ui/InfiniteScroll.d.ts +1 -1
- package/types/ui/Input.d.ts +6 -6
- package/types/ui/KeyboardAvoiding.d.ts +1 -1
- package/types/ui/Layout/BottomAction.d.ts +1 -1
- package/types/ui/Layout/BottomInset.d.ts +1 -1
- package/types/ui/Layout/BottomTab.d.ts +1 -1
- package/types/ui/Layout/Header.d.ts +1 -1
- package/types/ui/Layout/LeftSider.d.ts +1 -1
- package/types/ui/Layout/Navbar.d.ts +1 -1
- package/types/ui/Layout/RightSider.d.ts +1 -1
- package/types/ui/Layout/Sider.d.ts +1 -1
- package/types/ui/Layout/Template.d.ts +1 -1
- package/types/ui/Layout/TopLeftAction.d.ts +1 -1
- package/types/ui/Layout/Unit.d.ts +1 -1
- package/types/ui/Layout/View.d.ts +1 -1
- package/types/ui/Layout/Zone.d.ts +1 -1
- package/types/ui/Layout/index.d.ts +12 -12
- package/types/ui/Link/Back.d.ts +1 -1
- package/types/ui/Link/Close.d.ts +1 -1
- package/types/ui/Link/CsrLink.d.ts +1 -1
- package/types/ui/Link/Lang.d.ts +1 -1
- package/types/ui/Link/SsrLink.d.ts +1 -1
- package/types/ui/Link/index.d.ts +1 -1
- package/types/ui/Load/Edit.d.ts +1 -1
- package/types/ui/Load/Edit_Client.d.ts +1 -1
- package/types/ui/Load/PageCSR.d.ts +1 -1
- package/types/ui/Load/Pagination.d.ts +1 -1
- package/types/ui/Load/Units.d.ts +1 -1
- package/types/ui/Load/View.d.ts +1 -1
- package/types/ui/Loading/Area.d.ts +1 -1
- package/types/ui/Loading/Button.d.ts +1 -1
- package/types/ui/Loading/Input.d.ts +1 -1
- package/types/ui/Loading/ProgressBar.d.ts +1 -1
- package/types/ui/Loading/Skeleton.d.ts +1 -1
- package/types/ui/Loading/Spin.d.ts +1 -1
- package/types/ui/Loading/index.d.ts +6 -6
- package/types/ui/Menu.d.ts +1 -1
- package/types/ui/Modal.d.ts +1 -1
- package/types/ui/Model/AdminPanel.d.ts +1 -1
- package/types/ui/Model/Edit.d.ts +1 -1
- package/types/ui/Model/EditModal.d.ts +1 -1
- package/types/ui/Model/EditWrapper.d.ts +1 -1
- package/types/ui/Model/LoadInit.d.ts +1 -1
- package/types/ui/Model/New.d.ts +1 -1
- package/types/ui/Model/NewWrapper.d.ts +1 -1
- package/types/ui/Model/NewWrapper_Client.d.ts +1 -1
- package/types/ui/Model/Remove.d.ts +1 -1
- package/types/ui/Model/RemoveWrapper.d.ts +1 -1
- package/types/ui/Model/SureToRemove.d.ts +1 -1
- package/types/ui/Model/View.d.ts +1 -1
- package/types/ui/Model/ViewEditModal.d.ts +1 -1
- package/types/ui/Model/ViewModal.d.ts +1 -1
- package/types/ui/Model/ViewWrapper.d.ts +1 -1
- package/types/ui/More.d.ts +1 -1
- package/types/ui/ObjectId.d.ts +1 -1
- package/types/ui/Popconfirm.d.ts +1 -1
- package/types/ui/Radio.d.ts +2 -2
- package/types/ui/RecentTime.d.ts +1 -1
- package/types/ui/Refresh.d.ts +1 -1
- package/types/ui/ScreenNavigator.d.ts +3 -3
- package/types/ui/Select.d.ts +1 -1
- package/types/ui/Signal/Arg.d.ts +13 -13
- package/types/ui/Signal/Doc.d.ts +6 -6
- package/types/ui/Signal/Listener.d.ts +2 -2
- package/types/ui/Signal/Message.d.ts +4 -4
- package/types/ui/Signal/Object.d.ts +4 -4
- package/types/ui/Signal/PubSub.d.ts +4 -4
- package/types/ui/Signal/Request.d.ts +2 -2
- package/types/ui/Signal/Response.d.ts +3 -3
- package/types/ui/Signal/RestApi.d.ts +5 -5
- package/types/ui/Signal/WebSocket.d.ts +2 -2
- package/types/ui/System/CSR.d.ts +5 -5
- package/types/ui/System/Client.d.ts +8 -8
- package/types/ui/System/Common.d.ts +2 -2
- package/types/ui/System/DevModeToggle.d.ts +1 -1
- package/types/ui/System/Gtag.d.ts +1 -1
- package/types/ui/System/Messages.d.ts +1 -1
- package/types/ui/System/Reconnect.d.ts +1 -1
- package/types/ui/System/Root.d.ts +1 -1
- package/types/ui/System/SSR.d.ts +4 -4
- package/types/ui/System/SelectLanguage.d.ts +1 -1
- package/types/ui/System/ThemeToggle.d.ts +1 -1
- package/types/ui/System/index.d.ts +7 -7
- package/types/ui/Tab/Menu.d.ts +1 -1
- package/types/ui/Tab/Menus.d.ts +1 -1
- package/types/ui/Tab/Panel.d.ts +1 -1
- package/types/ui/Tab/Provider.d.ts +1 -1
- package/types/ui/Tab/index.d.ts +4 -4
- package/types/ui/Table.d.ts +1 -1
- package/types/ui/ToggleSelect.d.ts +2 -2
- package/types/ui/Unauthorized.d.ts +1 -1
- package/types/webkit/useCsrValues.d.ts +1 -1
- package/webkit/bootCsr.tsx +16 -2
package/client/csrTypes.ts
CHANGED
|
@@ -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,
|
|
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
|
|
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?: () =>
|
|
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:
|
|
263
|
+
pageBind: (...args: unknown[]) => ReactDOMAttributes;
|
|
233
264
|
pageClassName: string;
|
|
234
265
|
transDirection: "vertical" | "horizontal" | "none";
|
|
235
266
|
transUnitRange: number[];
|
package/client/makePageProto.tsx
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { getEnv } from "akanjs/base";
|
|
2
2
|
import { parseAkanI18nEnv } from "akanjs/common";
|
|
3
|
-
import {
|
|
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 =
|
|
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 =
|
|
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<
|
|
66
|
-
success: (key: TransMessage<
|
|
67
|
-
error: (key: TransMessage<
|
|
68
|
-
warning: (key: TransMessage<
|
|
69
|
-
loading: (key: TransMessage<
|
|
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 {
|
|
66
|
-
return { getRequest, headers } as {
|
|
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));
|
package/fetch/requestStorage.ts
CHANGED
|
@@ -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(
|
|
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 =
|
|
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
|
-
|
|
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
|
@@ -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
|
+
}
|
|
@@ -4,9 +4,11 @@ import type {
|
|
|
4
4
|
LayoutFallbackRoute,
|
|
5
5
|
LayoutNotFoundRender,
|
|
6
6
|
PathRoute,
|
|
7
|
+
ResolvedHead,
|
|
7
8
|
RouteRender,
|
|
8
9
|
} from "akanjs/client";
|
|
9
10
|
import { Children, cloneElement, isValidElement, type ReactElement, type ReactNode, Suspense } from "react";
|
|
11
|
+
import { resolveHeadResult } from "./metadata";
|
|
10
12
|
|
|
11
13
|
export class RouteElementComposer {
|
|
12
14
|
static compose({
|
|
@@ -43,7 +45,25 @@ export class RouteElementComposer {
|
|
|
43
45
|
params: Record<string, string>;
|
|
44
46
|
searchParams: Record<string, string | string[]>;
|
|
45
47
|
}): Promise<Head | null | undefined> {
|
|
46
|
-
return
|
|
48
|
+
return (
|
|
49
|
+
await RouteElementComposer.resolveHeadWithMetadata({
|
|
50
|
+
pathRoute,
|
|
51
|
+
params,
|
|
52
|
+
searchParams,
|
|
53
|
+
})
|
|
54
|
+
).node;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
static async resolveHeadWithMetadata({
|
|
58
|
+
pathRoute,
|
|
59
|
+
params,
|
|
60
|
+
searchParams,
|
|
61
|
+
}: {
|
|
62
|
+
pathRoute: PathRoute;
|
|
63
|
+
params: Record<string, string>;
|
|
64
|
+
searchParams: Record<string, string[] | string>;
|
|
65
|
+
}): Promise<ResolvedHead> {
|
|
66
|
+
return resolveHeadResult(await pathRoute.resolveHead?.({ params, searchParams }));
|
|
47
67
|
}
|
|
48
68
|
|
|
49
69
|
static async composeFallback({
|