akanjs 3.1.0-rc.21 → 3.1.0-rc.22
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/cookie.ts +5 -0
- package/client/csrLocation.ts +17 -72
- package/client/csrTypes.ts +17 -4
- package/client/route/PageDefinition.ts +103 -10
- package/client/route/RouteDefinition.ts +44 -3
- package/client/route/RouteGuards.ts +64 -0
- package/client/route/index.ts +1 -0
- package/client/route/resolveRouteModule.ts +8 -0
- package/client/route/routeArgs.ts +9 -0
- package/client/router.ts +11 -0
- package/common/authCookie.ts +13 -0
- package/common/authToken.ts +1 -12
- package/common/index.ts +2 -8
- package/common/routeConvention.ts +2 -0
- package/constant/inputParser.ts +1 -1
- package/dictionary/base.dictionary.ts +8 -0
- package/fetch/appScope.ts +54 -0
- package/fetch/client/fetchClient.ts +46 -29
- package/fetch/index.ts +1 -0
- package/package.json +1 -1
- package/server/mcp/McpApps.ts +44 -50
- package/server/mcp/McpPages.ts +93 -19
- package/server/mcp/McpRouter.ts +14 -11
- package/server/ops/migrateJobs.ts +145 -0
- package/server/ops/opsRoute.ts +39 -7
- package/server/ops/opsToken.ts +7 -2
- package/server/routeElementComposer.tsx +33 -6
- package/server/routeTreeBuilder.ts +5 -0
- package/server/rscClient.tsx +20 -0
- package/server/rscPagePrompts.ts +66 -16
- package/server/rscWorker.tsx +33 -11
- package/server/rscWorkerHost.ts +12 -1
- package/server/webRouter.ts +31 -27
- package/service/predefinedAdaptor/sql/SqlDocumentStore.ts +12 -1
- package/service/predefinedAdaptor/sql/dialect/postgres.ts +4 -3
- package/service/predefinedAdaptor/sql/dialect/sqlite.ts +2 -2
- package/service/predefinedAdaptor/sql/types.ts +2 -0
- package/service/predefinedAdaptor/sql/values.ts +19 -0
- package/signal/mcp/mcpProtocol.ts +7 -0
- package/signal/mcp/pagePrompt.ts +18 -1
- package/types/client/csrLocation.d.ts +3 -7
- package/types/client/csrTypes.d.ts +20 -3
- package/types/client/route/PageDefinition.d.ts +21 -4
- package/types/client/route/RouteDefinition.d.ts +4 -0
- package/types/client/route/RouteGuards.d.ts +15 -0
- package/types/client/route/index.d.ts +1 -0
- package/types/client/route/resolveRouteModule.d.ts +2 -0
- package/types/client/route/routeArgs.d.ts +5 -0
- package/types/client/route/routeBuilders.d.ts +1 -1
- package/types/client/router.d.ts +3 -0
- package/types/common/authCookie.d.ts +5 -0
- package/types/common/authToken.d.ts +0 -5
- package/types/common/index.d.ts +2 -1
- package/types/dictionary/base.dictionary.d.ts +1 -1
- package/types/dictionary/dictionary.d.ts +3 -3
- package/types/fetch/appScope.d.ts +18 -0
- package/types/fetch/client/fetchClient.d.ts +1 -1
- package/types/fetch/index.d.ts +1 -0
- package/types/server/mcp/McpApps.d.ts +14 -8
- package/types/server/mcp/McpPages.d.ts +6 -3
- package/types/server/ops/migrateJobs.d.ts +36 -0
- package/types/server/ops/opsRoute.d.ts +4 -1
- package/types/server/ops/opsToken.d.ts +3 -0
- package/types/server/routeElementComposer.d.ts +10 -1
- package/types/server/rscPagePrompts.d.ts +3 -1
- package/types/server/rscWorkerHost.d.ts +2 -1
- package/types/server/webRouter.d.ts +3 -0
- package/types/service/predefinedAdaptor/sql/dialect/postgres.d.ts +1 -1
- package/types/service/predefinedAdaptor/sql/dialect/sqlite.d.ts +1 -1
- package/types/service/predefinedAdaptor/sql/types.d.ts +2 -0
- package/types/service/predefinedAdaptor/sql/values.d.ts +1 -0
- package/types/signal/mcp/mcpProtocol.d.ts +6 -0
- package/types/signal/mcp/pagePrompt.d.ts +16 -0
- package/types/ui/Layout/FrameSlotPortal.d.ts +1 -1
- package/types/ui/System/App.d.ts +17 -0
- package/types/ui/System/index.d.ts +2 -0
- package/types/ui/recipe/badgeRecipe.d.ts +2 -2
- package/types/ui/recipe/buttonRecipe.d.ts +2 -2
- package/types/vendor/@akanjs/native/packages/cli/src/lib/toolchains.d.ts +1 -1
- package/types/webkit/CsrGuards.d.ts +25 -0
- package/types/webkit/CsrStack.d.ts +6 -0
- package/types/webkit/McpAppBoot.d.ts +32 -0
- package/types/webkit/McpAppBridge.d.ts +10 -1
- package/types/webkit/McpAppFetchBridge.d.ts +3 -1
- package/types/webkit/McpAppView.d.ts +4 -1
- package/types/webkit/RenderLayer.d.ts +1 -3
- package/types/webkit/index.d.ts +1 -0
- package/ui/Layout/BottomInset.tsx +2 -11
- package/ui/Layout/FrameSlotPortal.tsx +1 -3
- package/ui/Layout/Navbar.tsx +7 -22
- package/ui/Layout/TopInset.tsx +3 -4
- package/ui/System/App.tsx +43 -0
- package/ui/System/CSR.tsx +1 -10
- package/ui/System/index.tsx +3 -0
- package/vendor/@akanjs/native/packages/cli/src/lib/toolchains.ts +1 -1
- package/webkit/CsrGuards.ts +112 -0
- package/webkit/CsrRouteTable.ts +8 -12
- package/webkit/CsrStack.ts +29 -1
- package/webkit/McpAppBoot.tsx +129 -0
- package/webkit/McpAppBridge.ts +30 -6
- package/webkit/McpAppFetchBridge.ts +6 -1
- package/webkit/McpAppView.ts +39 -28
- package/webkit/RenderLayer.tsx +98 -41
- package/webkit/bootCsr.tsx +4 -43
- package/webkit/index.ts +1 -0
- package/webkit/useCsrValues.ts +55 -6
- package/webkit/useLocation.ts +1 -2
- package/types/webkit/EmbeddedLayout.d.ts +0 -10
- package/webkit/EmbeddedLayout.tsx +0 -82
package/client/cookie.ts
CHANGED
|
@@ -128,6 +128,9 @@ export interface GetOption {
|
|
|
128
128
|
interface SetAuthOption {
|
|
129
129
|
jwt: string;
|
|
130
130
|
}
|
|
131
|
+
const announceAuth = () => {
|
|
132
|
+
if (typeof window !== "undefined") window.dispatchEvent(new Event("akan:auth"));
|
|
133
|
+
};
|
|
131
134
|
/** Sets the active auth token on fetch, the cookie jar (except on a CSR client), and client storage. */
|
|
132
135
|
export const setAuth = ({ jwt }: SetAuthOption) => {
|
|
133
136
|
fetch.setJwt(jwt);
|
|
@@ -137,6 +140,7 @@ export const setAuth = ({ jwt }: SetAuthOption) => {
|
|
|
137
140
|
|
|
138
141
|
removeCookie(legacyAuthTokenKey);
|
|
139
142
|
void secretStorage.removeItem(legacyAuthTokenKey);
|
|
143
|
+
announceAuth();
|
|
140
144
|
};
|
|
141
145
|
|
|
142
146
|
interface InitAuthOption {
|
|
@@ -162,4 +166,5 @@ export const resetAuth = () => {
|
|
|
162
166
|
removeCookie(legacyAuthTokenKey);
|
|
163
167
|
void secretStorage.removeItem(authTokenKey());
|
|
164
168
|
void secretStorage.removeItem(legacyAuthTokenKey);
|
|
169
|
+
announceAuth();
|
|
165
170
|
};
|
package/client/csrLocation.ts
CHANGED
|
@@ -1,120 +1,65 @@
|
|
|
1
|
-
interface CsrLocationEntry {
|
|
2
|
-
href: string;
|
|
3
|
-
state: unknown;
|
|
4
|
-
}
|
|
5
|
-
|
|
6
|
-
export interface CsrMemoryOwner {
|
|
7
|
-
leave: (href: string) => void;
|
|
8
|
-
reload: () => void;
|
|
9
|
-
}
|
|
10
|
-
|
|
11
1
|
declare global {
|
|
12
2
|
var __AKAN_CSR_LOCATION__: CsrLocation | undefined;
|
|
13
3
|
|
|
14
4
|
var __AKAN_EMBED__: { kind: "mcp-app"; origin?: string; account?: Record<string, unknown> | null } | undefined;
|
|
15
5
|
}
|
|
16
6
|
|
|
7
|
+
/** The document's location and history, as the CSR router reads and moves them. */
|
|
17
8
|
export class CsrLocation {
|
|
18
|
-
|
|
19
|
-
#
|
|
20
|
-
|
|
21
|
-
get isMemory() {
|
|
22
|
-
return this.#memory !== null;
|
|
23
|
-
}
|
|
9
|
+
|
|
10
|
+
#view: URL | null = null;
|
|
24
11
|
|
|
25
|
-
/**
|
|
26
|
-
|
|
27
|
-
this.#
|
|
28
|
-
this.#owner = owner;
|
|
12
|
+
/** Reads `href`, a path on the app's origin, as this document's location from now on; a view never moves. */
|
|
13
|
+
view(href: string) {
|
|
14
|
+
this.#view = new URL(href, "http://view.invalid");
|
|
29
15
|
}
|
|
30
16
|
|
|
31
17
|
/** Path, search and hash, without the origin. */
|
|
32
18
|
get href() {
|
|
33
|
-
|
|
34
|
-
if (entry) return entry.href;
|
|
35
|
-
return `${window.location.pathname}${window.location.search}${window.location.hash}`;
|
|
19
|
+
return `${this.pathname}${this.search}${this.hash}`;
|
|
36
20
|
}
|
|
37
21
|
|
|
38
22
|
get pathname() {
|
|
39
|
-
|
|
40
|
-
return this.href.split(/[?#]/)[0] ?? "/";
|
|
23
|
+
return (this.#view ?? window.location).pathname;
|
|
41
24
|
}
|
|
42
25
|
|
|
43
26
|
get search() {
|
|
44
|
-
|
|
45
|
-
const [beforeHash] = this.href.split("#");
|
|
46
|
-
const at = beforeHash?.indexOf("?") ?? -1;
|
|
47
|
-
return at >= 0 ? (beforeHash?.slice(at) ?? "") : "";
|
|
27
|
+
return (this.#view ?? window.location).search;
|
|
48
28
|
}
|
|
49
29
|
|
|
50
30
|
get hash() {
|
|
51
|
-
|
|
52
|
-
const at = this.href.indexOf("#");
|
|
53
|
-
return at >= 0 ? this.href.slice(at) : "";
|
|
31
|
+
return (this.#view ?? window.location).hash;
|
|
54
32
|
}
|
|
55
33
|
|
|
56
34
|
get state(): unknown {
|
|
57
|
-
|
|
58
|
-
return entry ? entry.state : window.history.state;
|
|
35
|
+
return window.history.state;
|
|
59
36
|
}
|
|
60
37
|
|
|
61
38
|
/** `href` as a route reads it: an absolute URL on this document's origin loses the origin, anything else is kept. */
|
|
62
39
|
strip(href: string) {
|
|
63
|
-
if (this.#memory) return href;
|
|
64
40
|
const origin = window.location.origin;
|
|
65
41
|
return origin !== "null" && href.startsWith(origin) ? href.slice(origin.length) : href;
|
|
66
42
|
}
|
|
67
43
|
|
|
68
44
|
push(state: unknown, href: string) {
|
|
69
|
-
|
|
70
|
-
if (!memory) {
|
|
71
|
-
window.history.pushState(state, "", href);
|
|
72
|
-
return;
|
|
73
|
-
}
|
|
74
|
-
memory.entries = [...memory.entries.slice(0, memory.idx + 1), { href, state }];
|
|
75
|
-
memory.idx = memory.entries.length - 1;
|
|
45
|
+
window.history.pushState(state, "", href);
|
|
76
46
|
}
|
|
77
47
|
|
|
78
48
|
replace(state: unknown, href?: string) {
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
if (href === undefined) window.history.replaceState(state, "");
|
|
82
|
-
else window.history.replaceState(state, "", href);
|
|
83
|
-
return;
|
|
84
|
-
}
|
|
85
|
-
const current = memory.entries[memory.idx];
|
|
86
|
-
memory.entries[memory.idx] = { href: href ?? current?.href ?? "/", state };
|
|
49
|
+
if (href === undefined) window.history.replaceState(state, "");
|
|
50
|
+
else window.history.replaceState(state, "", href);
|
|
87
51
|
}
|
|
88
52
|
|
|
89
53
|
back() {
|
|
90
|
-
|
|
91
|
-
if (!memory) {
|
|
92
|
-
window.history.back();
|
|
93
|
-
return;
|
|
94
|
-
}
|
|
95
|
-
if (memory.idx === 0) return;
|
|
96
|
-
memory.idx -= 1;
|
|
97
|
-
const state = memory.entries[memory.idx]?.state ?? null;
|
|
98
|
-
|
|
99
|
-
setTimeout(() => window.dispatchEvent(new PopStateEvent("popstate", { state })), 0);
|
|
54
|
+
window.history.back();
|
|
100
55
|
}
|
|
101
56
|
|
|
102
57
|
assign(href: string) {
|
|
103
|
-
|
|
104
|
-
window.location.assign(href);
|
|
105
|
-
return;
|
|
106
|
-
}
|
|
107
|
-
this.#owner.leave(href);
|
|
58
|
+
window.location.assign(href);
|
|
108
59
|
}
|
|
109
60
|
|
|
110
61
|
reload() {
|
|
111
|
-
|
|
112
|
-
else this.#owner.reload();
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
#entry() {
|
|
116
|
-
const memory = this.#memory;
|
|
117
|
-
return memory ? memory.entries[memory.idx] : undefined;
|
|
62
|
+
window.location.reload();
|
|
118
63
|
}
|
|
119
64
|
}
|
|
120
65
|
|
package/client/csrTypes.ts
CHANGED
|
@@ -109,6 +109,13 @@ export type PageLoadingRender = (props: PageLoadingProps) => PromiseOrObject<Rea
|
|
|
109
109
|
export type LayoutLoadingRender = (props: LayoutLoadingProps) => PromiseOrObject<ReactNode>;
|
|
110
110
|
export type LayoutNotFoundRender = (props: LayoutNotFoundProps) => PromiseOrObject<ReactNode>;
|
|
111
111
|
export type LayoutErrorRender = (props: LayoutErrorProps) => PromiseOrObject<ReactNode>;
|
|
112
|
+
export interface RouteGuardProps {
|
|
113
|
+
params: Record<string, string>;
|
|
114
|
+
searchParams: Record<string, string | string[]>;
|
|
115
|
+
/** MCP is listing the screen, so no argument is known: a guard reading one throws `RouteArgPending`. */
|
|
116
|
+
listing?: boolean;
|
|
117
|
+
}
|
|
118
|
+
export type RouteGuard = (props: RouteGuardProps) => Promise<void>;
|
|
112
119
|
export interface RouteRender {
|
|
113
120
|
render: LayoutRender | PageRender;
|
|
114
121
|
isAsync?: boolean;
|
|
@@ -125,6 +132,8 @@ export interface RouteRender {
|
|
|
125
132
|
resolveHead?: ResolveHead;
|
|
126
133
|
/** Parses the declared args without rendering, so a path value the type refuses answers not-found before any byte. */
|
|
127
134
|
checkArgs?: (props: PageProps) => Promise<void>;
|
|
135
|
+
/** The route's `.guard()`, which `RouteGuards.run` calls root layout first, ahead of every layout body. */
|
|
136
|
+
guard?: RouteGuard;
|
|
128
137
|
getPageConfig?: () => PromiseOrObject<PageConfig | undefined>;
|
|
129
138
|
getLayoutPageConfig?: () => PromiseOrObject<PageConfig | undefined>;
|
|
130
139
|
/** A root layout's `theme`, readable before it renders. */
|
|
@@ -175,12 +184,14 @@ export interface PageModule {
|
|
|
175
184
|
head?: Head;
|
|
176
185
|
generateHead?: GenerateHead;
|
|
177
186
|
Loading?: PageLoadingRender;
|
|
187
|
+
guard?: RouteGuard;
|
|
178
188
|
}
|
|
179
189
|
export interface LayoutModule {
|
|
180
190
|
default?: LayoutRender;
|
|
181
191
|
pageConfig?: PageConfig;
|
|
182
192
|
head?: Head;
|
|
183
193
|
generateHead?: GenerateHead;
|
|
194
|
+
guard?: RouteGuard;
|
|
184
195
|
fonts?: ReactFont[];
|
|
185
196
|
manifest?: WebAppManifest;
|
|
186
197
|
theme?: string;
|
|
@@ -279,6 +290,12 @@ export interface History {
|
|
|
279
290
|
cachedLocationMap: Map<string, Location>;
|
|
280
291
|
idx: number;
|
|
281
292
|
dormant?: Set<string>;
|
|
293
|
+
account?: CsrStackAccount;
|
|
294
|
+
}
|
|
295
|
+
export interface CsrStackAccount {
|
|
296
|
+
token: string | null;
|
|
297
|
+
generation: number;
|
|
298
|
+
leaving?: { key: string; generation: number };
|
|
282
299
|
}
|
|
283
300
|
|
|
284
301
|
export interface RouterProps {
|
|
@@ -360,10 +377,6 @@ export const usePathCtx = () => {
|
|
|
360
377
|
return contextValues;
|
|
361
378
|
};
|
|
362
379
|
|
|
363
|
-
/** Inside a layout's own markup that an MCP view hides while the page is drawn on its own: frame chrome draws nothing. */
|
|
364
|
-
export const hiddenLayoutContext = sharedContext<boolean>("hiddenLayout", false);
|
|
365
|
-
export const useInHiddenLayout = () => useContext(hiddenLayoutContext);
|
|
366
|
-
|
|
367
380
|
export type PageActivity = "current" | "prev" | "pending" | "hidden";
|
|
368
381
|
export interface PageActivityState {
|
|
369
382
|
activity: PageActivity;
|
|
@@ -1,8 +1,13 @@
|
|
|
1
|
+
import type { PromiseOrObject } from "akanjs/base";
|
|
2
|
+
import { AppScope } from "akanjs/fetch";
|
|
3
|
+
import type { ReactNode } from "react";
|
|
1
4
|
import { type RouteArgsShape, RouteDefinition } from "./RouteDefinition";
|
|
2
5
|
import type {
|
|
6
|
+
RouteArgInfo,
|
|
3
7
|
RouteArgOption,
|
|
4
8
|
RouteArgType,
|
|
5
9
|
RouteArgValue,
|
|
10
|
+
RouteBaseArgs,
|
|
6
11
|
RoutePromptArgument,
|
|
7
12
|
RoutePromptMeta,
|
|
8
13
|
RouteSearchType,
|
|
@@ -13,7 +18,19 @@ interface PagePrompt {
|
|
|
13
18
|
description: string;
|
|
14
19
|
}
|
|
15
20
|
|
|
16
|
-
|
|
21
|
+
interface PageApp {
|
|
22
|
+
name: string;
|
|
23
|
+
description: string;
|
|
24
|
+
load: (args: never) => PromiseOrObject<ReactNode>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** `never` for a name the page's render already receives, so a clash fails to compile as well as at definition. */
|
|
28
|
+
type FreeName<Name extends string, Taken> = Name extends "lang" | keyof Taken ? never : Name;
|
|
29
|
+
|
|
30
|
+
export class PageDefinition<
|
|
31
|
+
Args extends RouteArgsShape = Record<never, never>,
|
|
32
|
+
Apps extends RouteArgsShape = Record<never, never>,
|
|
33
|
+
> extends RouteDefinition<Args> {
|
|
17
34
|
/** MCP names an entry by `^[A-Za-z0-9_-]{1,64}$`; a prompt name outside it never reaches a client's slash menu. */
|
|
18
35
|
static readonly promptName = /^[A-Za-z0-9_-]{1,64}$/;
|
|
19
36
|
/** Appended to a list argument's description: the flat string map of `prompts/get` has no other spelling. */
|
|
@@ -21,33 +38,109 @@ export class PageDefinition<Args extends RouteArgsShape = Record<never, never>>
|
|
|
21
38
|
|
|
22
39
|
readonly kind = "page" as const;
|
|
23
40
|
#prompt?: PagePrompt;
|
|
41
|
+
readonly #apps: PageApp[] = [];
|
|
24
42
|
|
|
25
|
-
param<Name extends string, Type extends RouteArgType>(
|
|
43
|
+
param<Name extends string, Type extends RouteArgType>(
|
|
44
|
+
name: Name & FreeName<Name, Apps>,
|
|
45
|
+
type: Type,
|
|
46
|
+
option: RouteArgOption = {},
|
|
47
|
+
) {
|
|
26
48
|
this.declare({ kind: "param", name, type, list: false, desc: option.desc });
|
|
27
|
-
return this as unknown as PageDefinition<Args & { [Key in Name]: RouteArgValue<Type> }>;
|
|
49
|
+
return this as unknown as PageDefinition<Args & { [Key in Name]: RouteArgValue<Type> }, Apps>;
|
|
28
50
|
}
|
|
29
51
|
|
|
30
|
-
search<Name extends string, Type extends RouteSearchType>(
|
|
52
|
+
search<Name extends string, Type extends RouteSearchType>(
|
|
53
|
+
name: Name & FreeName<Name, Apps>,
|
|
54
|
+
type: Type,
|
|
55
|
+
option: RouteArgOption = {},
|
|
56
|
+
) {
|
|
31
57
|
const list = Array.isArray(type);
|
|
32
58
|
this.declare({ kind: "search", name, type: (list ? type[0] : type) as RouteArgType, list, desc: option.desc });
|
|
33
|
-
return this as unknown as PageDefinition<Args & { [Key in Name]?: RouteArgValue<Type> }>;
|
|
59
|
+
return this as unknown as PageDefinition<Args & { [Key in Name]?: RouteArgValue<Type> }, Apps>;
|
|
34
60
|
}
|
|
35
61
|
|
|
36
62
|
/** Publishes this screen as an MCP prompt: the description is the whole instruction a model receives. English. */
|
|
37
63
|
prompt(name: string, description: string) {
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
64
|
+
PageDefinition.#assertEntry("prompt", name, description);
|
|
65
|
+
if (this.#apps.some((app) => app.name === name))
|
|
66
|
+
throw new Error(`[route-convention] page() names "${name}" as both an app and its prompt; MCP lists both`);
|
|
41
67
|
this.#prompt = { name, description };
|
|
42
68
|
return this;
|
|
43
69
|
}
|
|
44
70
|
|
|
71
|
+
/**
|
|
72
|
+
* Publishes one component of this screen as an MCP App: a read-only tool whose arguments are the page's `.param()`
|
|
73
|
+
* and `.search()`, and whose result a host draws with `load`'s element. `load` may only query. On the web the
|
|
74
|
+
* element arrives as the render argument `name`. English description.
|
|
75
|
+
*/
|
|
76
|
+
app<Name extends string>(
|
|
77
|
+
name: Name & FreeName<Name, Args & Apps>,
|
|
78
|
+
description: string,
|
|
79
|
+
load: (args: RouteBaseArgs & Args) => PromiseOrObject<ReactNode>,
|
|
80
|
+
) {
|
|
81
|
+
PageDefinition.#assertEntry("app", name, description);
|
|
82
|
+
if (name === "lang")
|
|
83
|
+
throw new Error(`[route-convention] app "lang" would hide the lang every route receives; name it otherwise`);
|
|
84
|
+
if (this.args.some((arg) => arg.name === name) || this.#apps.some((app) => app.name === name))
|
|
85
|
+
throw new Error(
|
|
86
|
+
`[route-convention] page() names "${name}" twice; an app's element arrives as the render argument of its name`,
|
|
87
|
+
);
|
|
88
|
+
if (this.#prompt?.name === name)
|
|
89
|
+
throw new Error(`[route-convention] page() names "${name}" as both an app and its prompt; MCP lists both`);
|
|
90
|
+
this.#apps.push({ name, description, load: load as PageApp["load"] });
|
|
91
|
+
return this as unknown as PageDefinition<Args, Apps & { [Key in Name]: ReactNode }>;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
override render(render: (args: RouteBaseArgs & Args & Apps) => PromiseOrObject<ReactNode>) {
|
|
95
|
+
return super.render(render as never);
|
|
96
|
+
}
|
|
97
|
+
|
|
45
98
|
get promptMeta(): RoutePromptMeta | undefined {
|
|
46
99
|
if (!this.#prompt) return undefined;
|
|
47
|
-
|
|
100
|
+
return { ...this.#prompt, arguments: this.#entryArguments() };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
get appMetas(): RoutePromptMeta[] {
|
|
104
|
+
return this.#apps.map(({ name, description }) => ({ name, description, arguments: this.#entryArguments() }));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** One app's element for these args, alone: what an MCP call runs after the route guards, and its view draws. */
|
|
108
|
+
async loadApp(name: string, args: RouteBaseArgs & Args): Promise<ReactNode> {
|
|
109
|
+
const app = this.#apps.find((candidate) => candidate.name === name);
|
|
110
|
+
if (!app) throw new Error(`[route-convention] this page declares no app "${name}"`);
|
|
111
|
+
return await PageDefinition.#load(app, args);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
protected override declare(arg: RouteArgInfo) {
|
|
115
|
+
if (this.#apps.some((app) => app.name === arg.name))
|
|
116
|
+
throw new Error(
|
|
117
|
+
`[route-convention] page() names "${arg.name}" twice; an app's element arrives as the render argument of its name`,
|
|
118
|
+
);
|
|
119
|
+
super.declare(arg);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
protected override renderArgsOf(args: RouteBaseArgs & Args): PromiseOrObject<RouteBaseArgs & Args> {
|
|
123
|
+
if (!this.#apps.length) return args;
|
|
124
|
+
return Promise.all(this.#apps.map((app) => PageDefinition.#load(app, args))).then((elements) => ({
|
|
125
|
+
...args,
|
|
126
|
+
...Object.fromEntries(this.#apps.map((app, idx) => [app.name, elements[idx]])),
|
|
127
|
+
}));
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
#entryArguments(): RoutePromptArgument[] {
|
|
131
|
+
return this.args.map((arg) => {
|
|
48
132
|
const description = [arg.desc, arg.list ? PageDefinition.commaSeparated : undefined].filter(Boolean).join(" ");
|
|
49
133
|
return { name: arg.name, ...(description ? { description } : {}), required: arg.kind === "param" };
|
|
50
134
|
});
|
|
51
|
-
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
static async #load(app: PageApp, args: object): Promise<ReactNode> {
|
|
138
|
+
return await AppScope.run(app.name, () => app.load(args as never));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
static #assertEntry(kind: "prompt" | "app", name: string, description: string) {
|
|
142
|
+
if (!PageDefinition.promptName.test(name))
|
|
143
|
+
throw new Error(`[route-convention] ${kind} name "${name}" must match ${PageDefinition.promptName}`);
|
|
144
|
+
if (!description.trim()) throw new Error(`[route-convention] ${kind} "${name}" needs a description`);
|
|
52
145
|
}
|
|
53
146
|
}
|
|
@@ -9,9 +9,10 @@ import {
|
|
|
9
9
|
import { parseAkanI18nEnv } from "akanjs/common";
|
|
10
10
|
import { deserialize } from "akanjs/constant";
|
|
11
11
|
import type { ReactNode } from "react";
|
|
12
|
-
import type { Head, LayoutModule, PageConfig, PageModule } from "../csrTypes";
|
|
12
|
+
import type { Head, LayoutModule, PageConfig, PageModule, RouteGuardProps } from "../csrTypes";
|
|
13
13
|
import { AkanNotFoundError } from "../router";
|
|
14
|
-
import {
|
|
14
|
+
import { Translator } from "../translator";
|
|
15
|
+
import { RouteArgError, type RouteArgInfo, type RouteArgInput, RouteArgPending, type RouteBaseArgs } from "./routeArgs";
|
|
15
16
|
|
|
16
17
|
export type RouteArgsShape = Record<string, unknown>;
|
|
17
18
|
export type RouteKind = "page" | "layout" | "rootLayout";
|
|
@@ -44,6 +45,7 @@ export abstract class RouteDefinition<
|
|
|
44
45
|
#config?: PageConfig;
|
|
45
46
|
|
|
46
47
|
#head?: HeadStage<never>;
|
|
48
|
+
#guard?: (args: never) => void | Promise<void>;
|
|
47
49
|
#loading?: (args: never) => PromiseOrObject<ReactNode>;
|
|
48
50
|
#render?: (args: never) => PromiseOrObject<ReactNode>;
|
|
49
51
|
#renderRoute?: {
|
|
@@ -60,6 +62,13 @@ export abstract class RouteDefinition<
|
|
|
60
62
|
this.#head = head as HeadStage<never>;
|
|
61
63
|
return this;
|
|
62
64
|
}
|
|
65
|
+
/** Runs before any layout body; refuses with `router.redirect()` or `router.notFound()`. */
|
|
66
|
+
guard(guard: (args: RouteBaseArgs & Args) => void | Promise<void>) {
|
|
67
|
+
if (this.#guard)
|
|
68
|
+
throw new Error(`[route-convention] ${this.kind}() declares .guard() twice; run every check from one guard`);
|
|
69
|
+
this.#guard = guard as (args: never) => void | Promise<void>;
|
|
70
|
+
return this;
|
|
71
|
+
}
|
|
63
72
|
loading(render: (args: RouteBaseArgs & Args & Extra) => PromiseOrObject<ReactNode>) {
|
|
64
73
|
this.#loading = render as (args: never) => PromiseOrObject<ReactNode>;
|
|
65
74
|
return this;
|
|
@@ -127,10 +136,12 @@ export abstract class RouteDefinition<
|
|
|
127
136
|
if (!render)
|
|
128
137
|
throw new Error(`[route-convention] a ${this.kind}() chain ends with .render(), and this one has none`);
|
|
129
138
|
const head = this.#head;
|
|
139
|
+
const guard = this.#guard;
|
|
130
140
|
const loading = this.#loading;
|
|
131
141
|
const module: PageModule & LayoutModule = {
|
|
132
142
|
default: this.#renderRouteOf(render) as never,
|
|
133
143
|
...(this.#config ? { pageConfig: this.#config } : {}),
|
|
144
|
+
...(guard ? { guard: this.#guardRouteOf(guard) } : {}),
|
|
134
145
|
...(head === undefined
|
|
135
146
|
? {}
|
|
136
147
|
: typeof head === "function"
|
|
@@ -150,10 +161,28 @@ export abstract class RouteDefinition<
|
|
|
150
161
|
return module;
|
|
151
162
|
}
|
|
152
163
|
|
|
164
|
+
/** What `.render()` receives beyond the declared args; a page adds its apps' elements. */
|
|
165
|
+
protected renderArgsOf(args: RouteBaseArgs & Args & Extra): PromiseOrObject<RouteBaseArgs & Args & Extra> {
|
|
166
|
+
return args;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
#guardRouteOf(guard: (args: never) => void | Promise<void>) {
|
|
170
|
+
const route = async (props: RouteGuardProps) => await guard(this.#guardArgsOf(props) as never);
|
|
171
|
+
Object.defineProperty(route, routeArgsMarker, { value: this.args.map(({ kind, name }) => ({ kind, name })) });
|
|
172
|
+
return route;
|
|
173
|
+
}
|
|
174
|
+
|
|
153
175
|
#renderRouteOf(render: (args: never) => PromiseOrObject<ReactNode>) {
|
|
154
176
|
const kept = this.#renderRoute;
|
|
155
177
|
if (kept?.render === render && kept.argCount === this.args.length) return kept.route;
|
|
156
|
-
|
|
178
|
+
|
|
179
|
+
const route = async (props: RouteRenderProps) => {
|
|
180
|
+
const args = this.renderArgsOf(this.#argsOf(props));
|
|
181
|
+
if (!(args instanceof Promise)) return await render(args as never);
|
|
182
|
+
const path = Translator.getRenderPath();
|
|
183
|
+
const resolved = await args;
|
|
184
|
+
return await Translator.renderAt(path, () => render(resolved as never));
|
|
185
|
+
};
|
|
157
186
|
Object.defineProperty(route, routeArgsMarker, { value: this.args.map(({ kind, name }) => ({ kind, name })) });
|
|
158
187
|
this.#renderRoute = { render, argCount: this.args.length, route };
|
|
159
188
|
return route;
|
|
@@ -190,6 +219,18 @@ export abstract class RouteDefinition<
|
|
|
190
219
|
return (this.kind === "page" ? args : { ...args, children: props.children }) as RouteBaseArgs & Args & Extra;
|
|
191
220
|
}
|
|
192
221
|
|
|
222
|
+
#guardArgsOf({ params, searchParams, listing }: RouteGuardProps): RouteBaseArgs & Args {
|
|
223
|
+
const { lang = parseAkanI18nEnv().defaultLocale } = params;
|
|
224
|
+
if (!listing) return { lang, ...this.resolveArgs({ params, searchParams }) } as RouteBaseArgs & Args;
|
|
225
|
+
const declared = new Set(this.args.map((arg) => arg.name));
|
|
226
|
+
return new Proxy({ lang } as RouteBaseArgs & Args, {
|
|
227
|
+
get: (target, key, receiver) => {
|
|
228
|
+
if (typeof key === "string" && declared.has(key)) throw new RouteArgPending(key);
|
|
229
|
+
return Reflect.get(target, key, receiver);
|
|
230
|
+
},
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
|
|
193
234
|
/** One value where a list was declared is a one-item list (`?tags=a`); under `strict` a list is comma-separated. */
|
|
194
235
|
static #lift(arg: RouteArgInfo, raw: string | string[], strict: boolean): unknown {
|
|
195
236
|
if (!arg.list) return Array.isArray(raw) ? raw[0] : raw;
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { RouteGuard, RouteGuardProps, RouteRender } from "../csrTypes";
|
|
2
|
+
import { AkanNotFoundError } from "../router";
|
|
3
|
+
import { routeArgsMarker } from "./RouteDefinition";
|
|
4
|
+
|
|
5
|
+
/** Runs a route's `.guard()` stages: the root layout's first, the page's last, each before any layout body. */
|
|
6
|
+
export class RouteGuards {
|
|
7
|
+
static readonly #refusedAt = new WeakMap<object, number>();
|
|
8
|
+
|
|
9
|
+
/** The first refusal stops the chain and is thrown: a redirect, a not-found, or the error a guard leaked. */
|
|
10
|
+
static async run(renders: (RouteRender | undefined)[], props: RouteGuardProps): Promise<void> {
|
|
11
|
+
for (const [idx, render] of renders.entries()) {
|
|
12
|
+
if (!render?.guard) continue;
|
|
13
|
+
try {
|
|
14
|
+
await render.guard(props);
|
|
15
|
+
} catch (error) {
|
|
16
|
+
const refusal = RouteGuards.refusalOf(error);
|
|
17
|
+
|
|
18
|
+
const thrown = typeof refusal === "object" && refusal !== null ? refusal : new Error(String(refusal));
|
|
19
|
+
RouteGuards.#refusedAt.set(thrown, idx);
|
|
20
|
+
throw thrown;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** The index, in the renders `run` was given, whose guard threw `error`; `undefined` for anything else. */
|
|
26
|
+
static refusedAt(error: unknown): number | undefined {
|
|
27
|
+
return typeof error === "object" && error !== null ? RouteGuards.#refusedAt.get(error) : undefined;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Whether MCP may list the screen to this caller: a guard that reads an argument leaves the verdict to the call. */
|
|
31
|
+
static async admits(renders: (RouteRender | undefined)[], props: RouteGuardProps): Promise<boolean> {
|
|
32
|
+
for (const render of renders) {
|
|
33
|
+
if (!render?.guard) continue;
|
|
34
|
+
try {
|
|
35
|
+
await render.guard({ ...props, listing: true });
|
|
36
|
+
} catch (error) {
|
|
37
|
+
if (!RouteGuards.isArgPending(error)) return false;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
static refusalOf(error: unknown): unknown {
|
|
44
|
+
const status = (error as { statusCode?: unknown } | null)?.statusCode;
|
|
45
|
+
return status === 403 || status === 404 ? new AkanNotFoundError() : error;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
static isArgPending(error: unknown): boolean {
|
|
49
|
+
return (error as { digest?: unknown } | null)?.digest === "AKAN_ROUTE_ARG_PENDING";
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** One guard running each of `guards` in order, or none when no guard is given. */
|
|
53
|
+
static compose(...guards: (RouteGuard | undefined)[]): RouteGuard | undefined {
|
|
54
|
+
const chain = guards.filter((guard): guard is RouteGuard => !!guard);
|
|
55
|
+
if (chain.length <= 1) return chain[0];
|
|
56
|
+
const composed: RouteGuard = async (props) => {
|
|
57
|
+
for (const guard of chain) await guard(props);
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const reads = chain.map((guard) => (guard as { [routeArgsMarker]?: unknown[] })[routeArgsMarker]);
|
|
61
|
+
if (reads.every(Array.isArray)) Object.defineProperty(composed, routeArgsMarker, { value: reads.flat() });
|
|
62
|
+
return composed;
|
|
63
|
+
}
|
|
64
|
+
}
|
package/client/route/index.ts
CHANGED
|
@@ -2,6 +2,7 @@ export * from "./LayoutDefinition";
|
|
|
2
2
|
export * from "./PageDefinition";
|
|
3
3
|
export * from "./RootLayoutDefinition";
|
|
4
4
|
export * from "./RouteDefinition";
|
|
5
|
+
export * from "./RouteGuards";
|
|
5
6
|
export * from "./RouteHead";
|
|
6
7
|
export * from "./resolveRouteModule";
|
|
7
8
|
export * from "./routeArgs";
|
|
@@ -40,3 +40,11 @@ export const resolveRouteModule = (
|
|
|
40
40
|
if (pattern) definition.assertPattern(pattern, key);
|
|
41
41
|
return { module: definition.toRouteModule(), definition };
|
|
42
42
|
};
|
|
43
|
+
|
|
44
|
+
/** The export sets allow `guard` for an unfolded chain and the generated root layout; the legacy shape never gained it. */
|
|
45
|
+
export const assertGuardOnChain = (label: string, resolved: ResolvedRouteModule): ResolvedRouteModule => {
|
|
46
|
+
if (resolved.definition || !("guard" in resolved.module)) return resolved;
|
|
47
|
+
throw new Error(
|
|
48
|
+
`[route-convention] ${label} exports guard beside a legacy default; declare it with .guard() on its chain`,
|
|
49
|
+
);
|
|
50
|
+
};
|
|
@@ -49,3 +49,12 @@ export class RouteArgError extends Error {
|
|
|
49
49
|
this.arg = arg;
|
|
50
50
|
}
|
|
51
51
|
}
|
|
52
|
+
|
|
53
|
+
/** A guard read an argument while MCP listed its screen, when none is known yet: the call decides with them. */
|
|
54
|
+
export class RouteArgPending extends Error {
|
|
55
|
+
readonly digest = "AKAN_ROUTE_ARG_PENDING";
|
|
56
|
+
constructor(name: string) {
|
|
57
|
+
super(`Argument "${name}" is not known while the screen is listed`);
|
|
58
|
+
this.name = "RouteArgPending";
|
|
59
|
+
}
|
|
60
|
+
}
|
package/client/router.ts
CHANGED
|
@@ -177,6 +177,7 @@ class Router {
|
|
|
177
177
|
#listensToPopstate: boolean = false;
|
|
178
178
|
#backsAwaitingPopstate = 0;
|
|
179
179
|
#redirects = 0;
|
|
180
|
+
#navigations = 0;
|
|
180
181
|
#instance: RouterInstance = {
|
|
181
182
|
push: (href: string) => {
|
|
182
183
|
const { href: fullHref } = this.#getPathInfo(href);
|
|
@@ -390,6 +391,7 @@ class Router {
|
|
|
390
391
|
}
|
|
391
392
|
push(href: string, routeOptions?: RouteOptions) {
|
|
392
393
|
this.#checkInitialized();
|
|
394
|
+
this.#navigations += 1;
|
|
393
395
|
this.#track(this.#instance.push(href, routeOptions));
|
|
394
396
|
this.#rememberHistoryState("push");
|
|
395
397
|
this.#postDevSyncNavigation("push", href);
|
|
@@ -397,6 +399,7 @@ class Router {
|
|
|
397
399
|
}
|
|
398
400
|
replace(href: string, routeOptions?: RouteOptions) {
|
|
399
401
|
this.#checkInitialized();
|
|
402
|
+
this.#navigations += 1;
|
|
400
403
|
this.#track(this.#instance.replace(href, routeOptions));
|
|
401
404
|
this.#rememberHistoryState("replace");
|
|
402
405
|
this.#postDevSyncNavigation("replace", href);
|
|
@@ -433,6 +436,7 @@ class Router {
|
|
|
433
436
|
back(routeOptions?: RouteOptions) {
|
|
434
437
|
if (getEnv().side === "server") throw new Error("back is only available in client side");
|
|
435
438
|
this.#checkInitialized();
|
|
439
|
+
this.#navigations += 1;
|
|
436
440
|
this.#instance.back(routeOptions);
|
|
437
441
|
return undefined as never;
|
|
438
442
|
}
|
|
@@ -447,6 +451,11 @@ class Router {
|
|
|
447
451
|
redirectCount(): number {
|
|
448
452
|
return this.#redirects;
|
|
449
453
|
}
|
|
454
|
+
/** Client-side moves so far — push, replace, back, redirect, notFound: a route guard that saw this change sent the
|
|
455
|
+
* visitor elsewhere, so its page does not draw. */
|
|
456
|
+
navigationCount(): number {
|
|
457
|
+
return this.#navigations;
|
|
458
|
+
}
|
|
450
459
|
redirect(href: string, options: RedirectOptions = {}): never {
|
|
451
460
|
const method = options.method ?? "replace";
|
|
452
461
|
const status = options.status ?? 307;
|
|
@@ -463,6 +472,7 @@ class Router {
|
|
|
463
472
|
throw new AkanRedirectError(fullHref, method, status);
|
|
464
473
|
} else {
|
|
465
474
|
this.#redirects += 1;
|
|
475
|
+
this.#navigations += 1;
|
|
466
476
|
this.#instance[method](href);
|
|
467
477
|
}
|
|
468
478
|
return undefined as never;
|
|
@@ -474,6 +484,7 @@ class Router {
|
|
|
474
484
|
}
|
|
475
485
|
this.#checkInitialized();
|
|
476
486
|
this.#redirects += 1;
|
|
487
|
+
this.#navigations += 1;
|
|
477
488
|
this.#instance.replace("/404");
|
|
478
489
|
return undefined as never;
|
|
479
490
|
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
|
|
2
|
+
|
|
3
|
+
/** The pre-scoping key. Read as a fallback, never written; drop it once deployments have rotated past it. */
|
|
4
|
+
export const legacyAuthTokenKey = "jwt";
|
|
5
|
+
|
|
6
|
+
/** Matches both spellings so a signout can sweep a jar written before and after the key was scoped. */
|
|
7
|
+
export const isAuthTokenKey = (key: string): boolean =>
|
|
8
|
+
key === legacyAuthTokenKey || key.startsWith(`${legacyAuthTokenKey}:`);
|
|
9
|
+
|
|
10
|
+
const authCookiePattern = new RegExp(`(?:^|;\\s*)${legacyAuthTokenKey}(?::[^=\\s;]+)?=`);
|
|
11
|
+
|
|
12
|
+
export const cookieHeaderHasAuthToken = (cookieHeader: string | null | undefined): boolean =>
|
|
13
|
+
!!cookieHeader && authCookiePattern.test(cookieHeader);
|
package/common/authToken.ts
CHANGED
|
@@ -1,20 +1,9 @@
|
|
|
1
1
|
import { getEnv } from "akanjs/base";
|
|
2
|
+
import { legacyAuthTokenKey } from "./authCookie";
|
|
2
3
|
import { decodeJwtPayload } from "./jwtDecode";
|
|
3
4
|
|
|
4
|
-
/** The pre-scoping key. Read as a fallback, never written; drop it once deployments have rotated past it. */
|
|
5
|
-
export const legacyAuthTokenKey = "jwt";
|
|
6
|
-
|
|
7
5
|
export const authTokenKey = (): string => `${legacyAuthTokenKey}:${getEnv().appName}`;
|
|
8
6
|
|
|
9
|
-
/** Matches both spellings so a signout can sweep a jar written before and after the key was scoped. */
|
|
10
|
-
export const isAuthTokenKey = (key: string): boolean =>
|
|
11
|
-
key === legacyAuthTokenKey || key.startsWith(`${legacyAuthTokenKey}:`);
|
|
12
|
-
|
|
13
|
-
const authCookiePattern = new RegExp(`(?:^|;\\s*)${legacyAuthTokenKey}(?::[^=\\s;]+)?=`);
|
|
14
|
-
|
|
15
|
-
export const cookieHeaderHasAuthToken = (cookieHeader: string | null | undefined): boolean =>
|
|
16
|
-
!!cookieHeader && authCookiePattern.test(cookieHeader);
|
|
17
|
-
|
|
18
7
|
export const isOwnAuthToken = (jwt: string): boolean => {
|
|
19
8
|
const { appName, environment } = getEnv();
|
|
20
9
|
try {
|